From c66adc144098c92d21dbac0a5d5d323b5a3236e3 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Thu, 13 Aug 2026 07:13:39 +0300 Subject: [PATCH 1/8] Add locale and priority options to the country selects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The country dropdown was English-only and strictly alphabetical: an Estonian admin picked their own country from 'Estonia' between 'Eritrea' and 'Eswatini', with 249 others in front of it. countries_for_select/1 and eu_countries_for_select/1 now take: * :locale — country names via BeamLabCountries.Translations, defaulting to the active PhoenixKitWeb.Gettext locale and falling back to the English name for any locale the data has no translations for, so a host only ever loses the translation, never the entry * :priority — alpha-2 codes pinned to the top in the order given, defaulting to config :phoenix_kit, :country_select_priority Sorting moves to the localized name, accents folded via NFD — plain byte order would exile every accented name past 'Z'. Both functions keep their zero-arity call shape, so existing callers (including phoenix_kit_billing's core_compat MFA list) are unaffected. --- lib/phoenix_kit/utils/country_data.ex | 134 +++++++++++++++---- test/phoenix_kit/utils/country_data_test.exs | 104 ++++++++++++++ 2 files changed, 215 insertions(+), 23 deletions(-) create mode 100644 test/phoenix_kit/utils/country_data_test.exs diff --git a/lib/phoenix_kit/utils/country_data.ex b/lib/phoenix_kit/utils/country_data.ex index bdcaea0b2..b78d51c6b 100644 --- a/lib/phoenix_kit/utils/country_data.ex +++ b/lib/phoenix_kit/utils/country_data.ex @@ -287,25 +287,40 @@ defmodule PhoenixKit.Utils.CountryData do Get list of countries for select dropdown. Returns list of tuples {display_name, alpha2_code} for use - in Phoenix form selects. + in Phoenix form selects, sorted by the country name in the active locale. + + ## Options + + * `:locale` — locale for the country names. Defaults to the active + `PhoenixKitWeb.Gettext` locale, reduced to its base code. Locales + `BeamLabCountries` ships no translations for fall back to English, so a + host only ever loses the translation, never the entry. + + * `:priority` — alpha-2 codes pinned to the top of the list, in the order + given; everything else follows alphabetically. Defaults to + `config :phoenix_kit, :country_select_priority`, so a host that serves + one region can put its own countries first without touching call sites. ## Examples - iex> countries = CountryData.countries_for_select() + iex> countries = CountryData.countries_for_select(locale: "en") iex> {"🇦🇫 Afghanistan", "AF"} in countries true + + iex> CountryData.countries_for_select(locale: "ru", priority: ["EE", "FI"]) + ...> |> Enum.take(2) + [{"🇪🇪 Эстония", "EE"}, {"🇫🇮 Финляндия", "FI"}] """ - def countries_for_select do - list_countries() - |> Enum.map(fn c -> - display_name = - case c.flag do - nil -> c.name - "" -> c.name - flag -> flag <> " " <> c.name - end + def countries_for_select(opts \\ []) do + locale = opts |> Keyword.get(:locale, active_locale()) |> normalize_locale() + priority = opts |> Keyword.get(:priority, configured_priority()) |> normalize_priority() - {display_name, c.alpha2} + entries = Enum.map(BeamLabCountries.all(), &select_entry(&1, locale)) + + {pinned, rest} = split_priority(entries, priority) + + Enum.map(pinned ++ sort_by_name(rest), fn {_name, display_name, code} -> + {display_name, code} end) end @@ -338,22 +353,95 @@ defmodule PhoenixKit.Utils.CountryData do @doc """ Get list of EU countries for select dropdown. + + Takes the same `:locale` and `:priority` options as + `countries_for_select/1`. """ - def eu_countries_for_select do - eu_countries() - |> Enum.sort_by(& &1.name) - |> Enum.map(fn c -> - display_name = - case c.flag do - nil -> c.name - "" -> c.name - flag -> flag <> " " <> c.name - end + def eu_countries_for_select(opts \\ []) do + locale = opts |> Keyword.get(:locale, active_locale()) |> normalize_locale() + priority = opts |> Keyword.get(:priority, configured_priority()) |> normalize_priority() - {display_name, c.alpha2} + entries = Enum.map(eu_countries(), &select_entry(&1, locale)) + {pinned, rest} = split_priority(entries, priority) + + Enum.map(pinned ++ sort_by_name(rest), fn {_name, display_name, code} -> + {display_name, code} end) end + # {sortable_name, display_name, alpha2} for one country in `locale`. + defp select_entry(country, locale) do + name = translated_name(country, locale) + + display_name = + case country.flag do + nil -> name + "" -> name + flag -> flag <> " " <> name + end + + {name, display_name, country.alpha2} + end + + defp translated_name(country, locale) do + with true <- is_binary(locale), + true <- BeamLabCountries.Translations.locale_supported?(locale), + name when is_binary(name) <- + BeamLabCountries.Translations.get_name(country.alpha2, locale) do + name + else + _ -> country.name + end + end + + # Pull the priority codes out in the order they were given; the remainder + # keeps its original order for the caller to sort. + defp split_priority(entries, []), do: {[], entries} + + defp split_priority(entries, priority) do + index = Map.new(entries, fn {_name, _display, code} = entry -> {code, entry} end) + + pinned_codes = Enum.filter(priority, &Map.has_key?(index, &1)) + pinned = Enum.map(pinned_codes, &Map.fetch!(index, &1)) + rest = Enum.reject(entries, fn {_name, _display, code} -> code in pinned_codes end) + + {pinned, rest} + end + + # Case- and diacritic-insensitive sort. Without ICU collation the byte order + # would exile every accented name past "Z" — "Ühendkuningriik" after + # "Zimbabwe" — which reads as a bug in any locale that uses them. + defp sort_by_name(entries) do + Enum.sort_by(entries, fn {name, _display, _code} -> + name |> String.downcase() |> :unicode.characters_to_nfd_binary() + end) + end + + defp active_locale do + Gettext.get_locale(PhoenixKitWeb.Gettext) + end + + defp normalize_locale(nil), do: nil + + defp normalize_locale(locale) when is_binary(locale) do + locale |> String.split(["-", "_"]) |> hd() |> String.downcase() + end + + defp normalize_locale(_), do: nil + + defp configured_priority do + Application.get_env(:phoenix_kit, :country_select_priority, []) + end + + defp normalize_priority(codes) when is_list(codes) do + codes + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.upcase/1) + |> Enum.uniq() + end + + defp normalize_priority(_), do: [] + @doc """ Get country currency code. diff --git a/test/phoenix_kit/utils/country_data_test.exs b/test/phoenix_kit/utils/country_data_test.exs new file mode 100644 index 000000000..61e5507bd --- /dev/null +++ b/test/phoenix_kit/utils/country_data_test.exs @@ -0,0 +1,104 @@ +defmodule PhoenixKit.Utils.CountryDataTest do + use ExUnit.Case, async: false + + # No `doctest` here: this module's examples are written against the bare + # `CountryData.` alias, which a doctest has no way to resolve. + + alias PhoenixKit.Utils.CountryData + + setup do + previous = Application.get_env(:phoenix_kit, :country_select_priority) + on_exit(fn -> restore(:country_select_priority, previous) end) + :ok + end + + defp restore(key, nil), do: Application.delete_env(:phoenix_kit, key) + defp restore(key, value), do: Application.put_env(:phoenix_kit, key, value) + + defp by_code(entries), do: Map.new(entries, fn {display, code} -> {code, display} end) + + # The display name carries a flag emoji, which sorts by country code rather + # than by name — compare the names the sort actually keys on. + defp without_flag(display), do: display |> String.split(" ", parts: 2) |> List.last() + + describe "countries_for_select/1 locale" do + test "translates names into the requested locale" do + names = by_code(CountryData.countries_for_select(locale: "ru")) + assert names["EE"] == "🇪🇪 Эстония" + assert names["DE"] == "🇩🇪 Германия" + end + + test "falls back to English for a locale with no translations" do + names = by_code(CountryData.countries_for_select(locale: "xx")) + assert names["EE"] == "🇪🇪 Estonia" + end + + test "reduces a dialect to its base code" do + assert CountryData.countries_for_select(locale: "ru-RU") == + CountryData.countries_for_select(locale: "ru") + end + + test "keeps every country regardless of locale" do + assert length(CountryData.countries_for_select(locale: "ru")) == + length(CountryData.countries_for_select(locale: "en")) + end + end + + describe "countries_for_select/1 priority" do + test "pins the given codes to the top, in the order given" do + result = CountryData.countries_for_select(locale: "en", priority: ["EE", "FI", "LV"]) + + assert Enum.take(result, 3) == [ + {"🇪🇪 Estonia", "EE"}, + {"🇫🇮 Finland", "FI"}, + {"🇱🇻 Latvia", "LV"} + ] + end + + test "lists each pinned country exactly once" do + codes = + CountryData.countries_for_select(locale: "en", priority: ["EE", "FI"]) + |> Enum.map(&elem(&1, 1)) + + assert Enum.count(codes, &(&1 == "EE")) == 1 + assert length(codes) == length(Enum.uniq(codes)) + end + + test "ignores unknown codes and normalizes case" do + result = CountryData.countries_for_select(locale: "en", priority: ["zz", "ee"]) + assert hd(result) == {"🇪🇪 Estonia", "EE"} + end + + test "defaults to the configured priority" do + Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) + assert hd(CountryData.countries_for_select(locale: "en")) == {"🇫🇮 Finland", "FI"} + end + + test "sorts the unpinned remainder alphabetically, accents folded" do + names = + CountryData.countries_for_select(locale: "en", priority: []) + |> Enum.map(&without_flag(elem(&1, 0))) + + assert names == Enum.sort_by(names, &:unicode.characters_to_nfd_binary(String.downcase(&1))) + end + + test "sorts by the localized name, not the English one" do + names = + CountryData.countries_for_select(locale: "ru", priority: []) + |> Enum.map(&without_flag(elem(&1, 0))) + + assert "Австралия" in names + + assert Enum.find_index(names, &(&1 == "Австралия")) < + Enum.find_index(names, &(&1 == "Швеция")) + end + end + + describe "eu_countries_for_select/1" do + test "translates and pins like countries_for_select/1" do + result = CountryData.eu_countries_for_select(locale: "ru", priority: ["EE"]) + assert hd(result) == {"🇪🇪 Эстония", "EE"} + assert length(result) == 27 + end + end +end From 89f2b40d95517615b8cb6949dd1dd4508468e027 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Thu, 13 Aug 2026 07:55:49 +0300 Subject: [PATCH 2/8] Update get_country_name/1 to follow the active locale too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The select list was translated but the single-country lookup was not, so anywhere a country is shown as text rather than picked from a dropdown — phoenix_kit_billing's company address block, format_company_address/0 — stayed English on a translated page. Takes the same :locale option and the same per-country English fallback. The default argument keeps the /1 shape that phoenix_kit_billing's core_compat probes for. --- lib/phoenix_kit/utils/country_data.ex | 20 ++++++++++++++----- test/phoenix_kit/utils/country_data_test.exs | 21 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/lib/phoenix_kit/utils/country_data.ex b/lib/phoenix_kit/utils/country_data.ex index b78d51c6b..aa9e1feb7 100644 --- a/lib/phoenix_kit/utils/country_data.ex +++ b/lib/phoenix_kit/utils/country_data.ex @@ -466,24 +466,34 @@ defmodule PhoenixKit.Utils.CountryData do def get_currency_code(_), do: nil @doc """ - Get country name. + Get country name in the active locale. + + Takes the same `:locale` option as `countries_for_select/1` and falls back + to the English name when that locale has no translation for the country. ## Examples - iex> CountryData.get_country_name("EE") + iex> CountryData.get_country_name("EE", locale: "en") "Estonia" + iex> CountryData.get_country_name("EE", locale: "ru") + "Эстония" + iex> CountryData.get_country_name("XX") nil """ - def get_country_name(country_code) when is_binary(country_code) do + def get_country_name(country_code, opts \\ []) + + def get_country_name(country_code, opts) when is_binary(country_code) do + locale = opts |> Keyword.get(:locale, active_locale()) |> normalize_locale() + case get_country(country_code) do - %{name: name} -> name + %{} = country -> translated_name(country, locale) _ -> nil end end - def get_country_name(_), do: nil + def get_country_name(_, _), do: nil @doc """ Get country flag (emoji). diff --git a/test/phoenix_kit/utils/country_data_test.exs b/test/phoenix_kit/utils/country_data_test.exs index 61e5507bd..22702b585 100644 --- a/test/phoenix_kit/utils/country_data_test.exs +++ b/test/phoenix_kit/utils/country_data_test.exs @@ -94,6 +94,27 @@ defmodule PhoenixKit.Utils.CountryDataTest do end end + describe "get_country_name/2" do + test "translates into the requested locale" do + assert CountryData.get_country_name("EE", locale: "ru") == "Эстония" + assert CountryData.get_country_name("EE", locale: "en") == "Estonia" + end + + test "falls back to the English name for an untranslated locale" do + assert CountryData.get_country_name("EE", locale: "xx") == "Estonia" + end + + test "still answers nil for an unknown country" do + assert CountryData.get_country_name("XX") == nil + assert CountryData.get_country_name(nil) == nil + end + + test "keeps its one-argument shape for existing callers" do + assert function_exported?(CountryData, :get_country_name, 1) + assert is_binary(CountryData.get_country_name("EE")) + end + end + describe "eu_countries_for_select/1" do test "translates and pins like countries_for_select/1" do result = CountryData.eu_countries_for_select(locale: "ru", priority: ["EE"]) From ff6e8917790ab52783795f6c3fa84f2cacd7a357 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Thu, 13 Aug 2026 09:37:27 +0300 Subject: [PATCH 3/8] Fix the sorting comment, memoize the list, and harden the options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on this branch. The comment above sort_by_name/1 had the collation backwards. It claimed byte order "would exile every accented name past Z", but Estonian sorts Ü at the very end (after W, Õ, Ä, Ö) and Swedish sorts Å, Ä, Ö after Z — so that position is correct there, and folding is what breaks it, pulling "Ühendkuningriik" into the U block. Folding stays, because it is right for most Latin-script locales and the BEAM ships no ICU, but it is now documented as the approximation it is, in the comment and in the @doc. The @doc also now states what the switch to BeamLabCountries.Translations actually changed: the names differ from the country struct's :name field even in English, for 20 of 250 countries — "Czech Republic" becomes "Czechia", "Turkey" becomes "Türkiye", and North Korea moves from the K's to the N's. A host that never passes :locale still sees new strings and a new order. The supported-locale set is named too, read from Translations.supported_locales/0 rather than from memory. countries_for_select/0 cost 1.27 ms per call and runs in LiveView mount, twice per connection. The localized, sorted entry list is now memoized in :persistent_term per locale and source, which takes a warm call to 0.018 ms. Priority pinning stays outside the cache, so a :country_select_priority change still takes effect immediately. Verified equivalent to the pre-cache implementation across 144 combinations of locale, priority and source. Bad opts raised from inside Keyword.get/3, pointing the caller at Keyword instead of at their own call. `when is_list(opts)` now names this module; get_country_name/2 needed its catch-all narrowed to a non-binary code, or it would have swallowed get_country_name("EE", "ru") — a realistic slip, since the option is :locale. Two tests were doing nothing. The sort test re-derived the implementation's own sort key and compared the output to it, so it could not fail for any collation; it now asserts literal neighbours (Azerbaijan < Åland Islands < Bahamas, and two more pairs), and dropping the fold turns it red. The "keeps every country" test compared lengths, 250 == 250 by construction; it now compares the code sets. Added the missing coverage for the zero-arity contract phoenix_kit_billing's core_compat probes, and for the default-locale branch, which nothing exercised. --- lib/phoenix_kit/utils/country_data.ex | 121 +++++++++++++++---- test/phoenix_kit/utils/country_data_test.exs | 56 ++++++++- 2 files changed, 150 insertions(+), 27 deletions(-) diff --git a/lib/phoenix_kit/utils/country_data.ex b/lib/phoenix_kit/utils/country_data.ex index aa9e1feb7..eb10a508e 100644 --- a/lib/phoenix_kit/utils/country_data.ex +++ b/lib/phoenix_kit/utils/country_data.ex @@ -289,17 +289,45 @@ defmodule PhoenixKit.Utils.CountryData do Returns list of tuples {display_name, alpha2_code} for use in Phoenix form selects, sorted by the country name in the active locale. + Names come from `BeamLabCountries.Translations`, not the country struct's + `:name` field — the two differ even in English, for 20 of the 250 + countries (as of beamlab_countries 1.1.0). For example: GB "United + Kingdom of Great Britain and Northern Ireland" -> "United Kingdom", US + "United States of America" -> "United States", CZ "Czech Republic" -> + "Czechia", TR "Turkey" -> "Türkiye", KP "Korea (Democratic People's + Republic of)" -> "North Korea" (which also moves its place in the sorted + list, from the K's to the N's). A host that never passes `:locale` still + gets different strings, and a different order, than a version of this + function that read `.name` directly. + + Sorting folds accented letters to their base form before comparing (e.g. + "ü" sorts with "u"), which is a deliberate approximation, not proper + collation — the BEAM has no ICU. It is correct for most Latin-script + locales, but wrong for locales that give diacritics their own place in + the alphabet: in Estonian, Ü belongs at the very end (after W, Õ, Ä, Ö), + and in Swedish, Å, Ä, Ö belong after Z; folding moves those names out of + that position instead of leaving them there. A host that needs strict + local collation should sort the returned list itself. + ## Options * `:locale` — locale for the country names. Defaults to the active - `PhoenixKitWeb.Gettext` locale, reduced to its base code. Locales - `BeamLabCountries` ships no translations for fall back to English, so a - host only ever loses the translation, never the entry. + `PhoenixKitWeb.Gettext` locale, reduced to its base code (`"ru-RU"` + normalizes to `"ru"`). `BeamLabCountries` 1.1.0 ships translations for + `ar`, `de`, `en`, `es`, `fr`, `it`, `ja`, `ko`, `nl`, `pl`, `pt`, `ru`, + `sv`, `uk`, `zh` (`BeamLabCountries.Translations.supported_locales/0`); + any other locale falls back to the country's English name, and so + does an unsupported *value* such as an atom (`locale: :ru`) — a host + only ever loses the translation, never the entry. * `:priority` — alpha-2 codes pinned to the top of the list, in the order given; everything else follows alphabetically. Defaults to `config :phoenix_kit, :country_select_priority`, so a host that serves one region can put its own countries first without touching call sites. + A non-list value is treated as `[]`. + + `opts` itself must be a keyword list — a map or a bare string raises + `FunctionClauseError` naming this function rather than `Keyword`. ## Examples @@ -311,17 +339,13 @@ defmodule PhoenixKit.Utils.CountryData do ...> |> Enum.take(2) [{"🇪🇪 Эстония", "EE"}, {"🇫🇮 Финляндия", "FI"}] """ - def countries_for_select(opts \\ []) do + def countries_for_select(opts \\ []) when is_list(opts) do locale = opts |> Keyword.get(:locale, active_locale()) |> normalize_locale() priority = opts |> Keyword.get(:priority, configured_priority()) |> normalize_priority() - entries = Enum.map(BeamLabCountries.all(), &select_entry(&1, locale)) - - {pinned, rest} = split_priority(entries, priority) + {pinned, rest} = locale |> sorted_entries(:all) |> split_priority(priority) - Enum.map(pinned ++ sort_by_name(rest), fn {_name, display_name, code} -> - {display_name, code} - end) + Enum.map(pinned ++ rest, fn {_name, display_name, code} -> {display_name, code} end) end @doc """ @@ -355,18 +379,16 @@ defmodule PhoenixKit.Utils.CountryData do Get list of EU countries for select dropdown. Takes the same `:locale` and `:priority` options as - `countries_for_select/1`. + `countries_for_select/1`, including its fallback, leniency, and sorting + caveats. """ - def eu_countries_for_select(opts \\ []) do + def eu_countries_for_select(opts \\ []) when is_list(opts) do locale = opts |> Keyword.get(:locale, active_locale()) |> normalize_locale() priority = opts |> Keyword.get(:priority, configured_priority()) |> normalize_priority() - entries = Enum.map(eu_countries(), &select_entry(&1, locale)) - {pinned, rest} = split_priority(entries, priority) + {pinned, rest} = locale |> sorted_entries(:eu) |> split_priority(priority) - Enum.map(pinned ++ sort_by_name(rest), fn {_name, display_name, code} -> - {display_name, code} - end) + Enum.map(pinned ++ rest, fn {_name, display_name, code} -> {display_name, code} end) end # {sortable_name, display_name, alpha2} for one country in `locale`. @@ -394,8 +416,46 @@ defmodule PhoenixKit.Utils.CountryData do end end + # Localized, alphabetically-sorted {sortable_name, display_name, alpha2} + # entries for `source` (:all or :eu), memoized in :persistent_term per + # locale — countries_for_select/1 runs in LiveView mount twice per + # connection, and rebuilding + sort-keying ~250 translated names on every + # call was measured as the expensive part (the translation lookup itself + # is cheap). Keyed by locale *and* source so the EU subset can never + # collide with the full list. A cache miss computes once and stores; + # realistic callers only ever use the handful of locales this host's + # Gettext config and BeamLabCountries between them support, so the number + # of distinct writes is bounded. Priority pinning is deliberately *not* + # part of the cache key — split_priority/2 below is cheap (one pass over + # already-sorted input) and runs on every call, so + # `:country_select_priority` changes take effect immediately without + # invalidating anything. + defp sorted_entries(locale, source) do + key = {__MODULE__, :sorted_entries, source, locale} + + case :persistent_term.get(key, :not_cached) do + :not_cached -> + entries = + source + |> countries_for_source() + |> Enum.map(&select_entry(&1, locale)) + |> sort_by_name() + + :persistent_term.put(key, entries) + entries + + entries -> + entries + end + end + + defp countries_for_source(:all), do: BeamLabCountries.all() + defp countries_for_source(:eu), do: eu_countries() + # Pull the priority codes out in the order they were given; the remainder - # keeps its original order for the caller to sort. + # keeps its incoming order. Callers now pass the already-sorted output of + # sorted_entries/2, so that incoming order is alphabetical — no further + # sort needed. defp split_priority(entries, []), do: {[], entries} defp split_priority(entries, priority) do @@ -408,9 +468,17 @@ defmodule PhoenixKit.Utils.CountryData do {pinned, rest} end - # Case- and diacritic-insensitive sort. Without ICU collation the byte order - # would exile every accented name past "Z" — "Ühendkuningriik" after - # "Zimbabwe" — which reads as a bug in any locale that uses them. + # Deliberate approximation, not proper collation: folding diacritics to + # their base letter before comparing is correct for locales that treat + # accented letters as variants of the base letter — most Latin-script + # locales (French, German, Spanish, Italian, Dutch, Polish, Portuguese, + # ...) — but wrong for locales that give diacritics their own position in + # the alphabet. Estonian sorts Ü at the very end, after W, Õ, Ä, Ö; + # Swedish sorts Å, Ä, Ö after Z. Folding pulls "Ühendkuningriik" into the U + # block and "Åland" between "Azerbajdzjan" and "Bahamas" instead of + # leaving them at the end, which is where correct collation puts them in + # those locales. Proper per-locale collation would need ICU, which the + # BEAM does not ship. defp sort_by_name(entries) do Enum.sort_by(entries, fn {name, _display, _code} -> name |> String.downcase() |> :unicode.characters_to_nfd_binary() @@ -468,9 +536,14 @@ defmodule PhoenixKit.Utils.CountryData do @doc """ Get country name in the active locale. - Takes the same `:locale` option as `countries_for_select/1` and falls back + Takes the same `:locale` option as `countries_for_select/1` — including + its supported-locale set and its fallback/leniency rules — and falls back to the English name when that locale has no translation for the country. + `opts` must be a keyword list. `get_country_name("EE", "ru")` is a + realistic slip (the option is `:locale`), and raises + `FunctionClauseError` naming this function rather than `Keyword`. + ## Examples iex> CountryData.get_country_name("EE", locale: "en") @@ -484,7 +557,7 @@ defmodule PhoenixKit.Utils.CountryData do """ def get_country_name(country_code, opts \\ []) - def get_country_name(country_code, opts) when is_binary(country_code) do + def get_country_name(country_code, opts) when is_binary(country_code) and is_list(opts) do locale = opts |> Keyword.get(:locale, active_locale()) |> normalize_locale() case get_country(country_code) do @@ -493,7 +566,7 @@ defmodule PhoenixKit.Utils.CountryData do end end - def get_country_name(_, _), do: nil + def get_country_name(country_code, _opts) when not is_binary(country_code), do: nil @doc """ Get country flag (emoji). diff --git a/test/phoenix_kit/utils/country_data_test.exs b/test/phoenix_kit/utils/country_data_test.exs index 22702b585..1af4e000b 100644 --- a/test/phoenix_kit/utils/country_data_test.exs +++ b/test/phoenix_kit/utils/country_data_test.exs @@ -39,8 +39,16 @@ defmodule PhoenixKit.Utils.CountryDataTest do end test "keeps every country regardless of locale" do - assert length(CountryData.countries_for_select(locale: "ru")) == - length(CountryData.countries_for_select(locale: "en")) + # Same country *codes*, not just the same count — a locale-dependent + # filter bug could drop one country and pick up another while leaving + # the length unchanged at 250. + codes = fn locale -> + CountryData.countries_for_select(locale: locale) + |> Enum.map(&elem(&1, 1)) + |> MapSet.new() + end + + assert codes.("ru") == codes.("en") end end @@ -79,7 +87,21 @@ defmodule PhoenixKit.Utils.CountryDataTest do CountryData.countries_for_select(locale: "en", priority: []) |> Enum.map(&without_flag(elem(&1, 0))) - assert names == Enum.sort_by(names, &:unicode.characters_to_nfd_binary(String.downcase(&1))) + index = fn name -> Enum.find_index(names, &(&1 == name)) end + + # Literal neighbours from the real sorted (English) output, so that + # changing the sort key breaks this test — unlike the previous version, + # which re-derived the same NFD-fold-and-downcase key the implementation + # uses and therefore could never fail. Folding places each accented name + # next to its unaccented neighbours instead of exiling it past "Z". + assert index.("Azerbaijan") < index.("Åland Islands") + assert index.("Åland Islands") < index.("Bahamas") + + assert index.("Costa Rica") < index.("Côte d'Ivoire") + assert index.("Côte d'Ivoire") < index.("Croatia") + + assert index.("Tuvalu") < index.("Türkiye") + assert index.("Türkiye") < index.("Uganda") end test "sorts by the localized name, not the English one" do @@ -110,6 +132,7 @@ defmodule PhoenixKit.Utils.CountryDataTest do end test "keeps its one-argument shape for existing callers" do + assert Code.ensure_loaded?(CountryData) assert function_exported?(CountryData, :get_country_name, 1) assert is_binary(CountryData.get_country_name("EE")) end @@ -122,4 +145,31 @@ defmodule PhoenixKit.Utils.CountryDataTest do assert length(result) == 27 end end + + describe "zero-arity contract" do + test "countries_for_select/0 and eu_countries_for_select/0 stay exported" do + # phoenix_kit_billing's core_compat lists + # {CountryData, :countries_for_select, 0} as an unguarded runtime call + # (lib/phoenix_kit_billing/core_compat.ex) — a regression here breaks + # billing at boot. eu_countries_for_select/0 isn't in that list today, + # but it shares the same `opts \\ []` contract, so it's asserted here + # too rather than leaving it uncovered. + # + # function_exported?/3 answers false for a module that hasn't been + # loaded yet, regardless of what it defines, so ensure_loaded? first. + assert Code.ensure_loaded?(CountryData) + assert function_exported?(CountryData, :countries_for_select, 0) + assert function_exported?(CountryData, :eu_countries_for_select, 0) + end + + test "countries_for_select/0 uses the active Gettext locale when :locale is omitted" do + previous = Gettext.get_locale(PhoenixKitWeb.Gettext) + on_exit(fn -> Gettext.put_locale(PhoenixKitWeb.Gettext, previous) end) + + Gettext.put_locale(PhoenixKitWeb.Gettext, "ru") + + names = by_code(CountryData.countries_for_select()) + assert names["EE"] == "🇪🇪 Эстония" + end + end end From 12c9a2c2a2956947020493e690d2b7e284485a3d Mon Sep 17 00:00:00 2001 From: Timujeen Date: Thu, 13 Aug 2026 10:10:46 +0300 Subject: [PATCH 4/8] Add an admin setting for the pinned countries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The priority list was compile-time config only, so reordering the dropdown meant editing a file and redeploying — and nothing in the admin UI said the feature existed. `country_select_priority` is now a setting, edited on Admin -> Settings -> Organization next to the company's own country, which is where "where does this business operate" already lives. The setting wins; `config :phoenix_kit, :country_select_priority` stays as the fallback for a blank or unset value, so hosts that never open the page keep the behaviour they configured. It lands where it costs nothing: priority pinning was deliberately left outside the memoized sorted list, so an edit takes effect on the next render with no cache to invalidate. The read goes through get_setting_cached/2, which is consulted before the update-mode short-circuit and therefore resolves without a database; a settings read can raise on an unowned checkout and exit on a dead pool, so both are caught — a country dropdown must not depend on the database being up. Input is normalized on save through the new parse_priority/1 and known_country_codes/1: any separator an operator actually types is accepted, codes are upper-cased and deduplicated, and codes that name no country are dropped, so what the field shows after saving is exactly what the dropdown will do. --- lib/phoenix_kit/utils/country_data.ex | 79 +++++++++++++++++-- .../live/settings/organization.ex | 15 ++++ .../live/settings/organization.html.heex | 18 +++++ test/phoenix_kit/utils/country_data_test.exs | 54 +++++++++++++ 4 files changed, 161 insertions(+), 5 deletions(-) diff --git a/lib/phoenix_kit/utils/country_data.ex b/lib/phoenix_kit/utils/country_data.ex index eb10a508e..5bd3a6120 100644 --- a/lib/phoenix_kit/utils/country_data.ex +++ b/lib/phoenix_kit/utils/country_data.ex @@ -321,10 +321,13 @@ defmodule PhoenixKit.Utils.CountryData do only ever loses the translation, never the entry. * `:priority` — alpha-2 codes pinned to the top of the list, in the order - given; everything else follows alphabetically. Defaults to - `config :phoenix_kit, :country_select_priority`, so a host that serves - one region can put its own countries first without touching call sites. - A non-list value is treated as `[]`. + given; everything else follows alphabetically. A non-list value is + treated as `[]`. The default comes from, in order: the + `country_select_priority` setting (Admin → Settings → Organization, + where an operator can reorder the dropdown without a deploy), then + `config :phoenix_kit, :country_select_priority` when that setting is + blank or unset. A host that serves one region can therefore put its own + countries first without touching any call site. `opts` itself must be a keyword list — a map or a bare string raises `FunctionClauseError` naming this function rather than `Keyword`. @@ -497,10 +500,76 @@ defmodule PhoenixKit.Utils.CountryData do defp normalize_locale(_), do: nil + # The admin-editable setting wins over the compile-time config, so an + # operator can reorder the dropdown without a deploy. A missing or blank + # setting means "not configured" and falls through to the config, which + # keeps every host that never opens the settings page working as before. + # + # Read through the cache: this runs on the hot path, and `get_setting_cached/2` + # is consulted before the update-mode short-circuit, so a primed key resolves + # without a database at all. A settings read can raise on an unowned checkout + # AND exit on a dead pool, so both are caught — the country list must not + # depend on the database being up. defp configured_priority do - Application.get_env(:phoenix_kit, :country_select_priority, []) + case setting_priority() do + [] -> Application.get_env(:phoenix_kit, :country_select_priority, []) + codes -> codes + end + end + + defp setting_priority do + "country_select_priority" + |> Settings.get_setting_cached("") + |> parse_priority() + rescue + _ -> [] + catch + :exit, _ -> [] + end + + @doc """ + Split an operator-entered priority string into alpha-2 codes. + + Accepts the separators a human actually types — commas, spaces, semicolons, + newlines — so `"EE, FI"`, `"ee fi"` and `"EE;FI"` all parse. Unknown codes + are kept here and dropped later by the same normalization every caller of + `:priority` goes through; use `known_country_codes/1` to report them. + + ## Examples + + iex> CountryData.parse_priority("EE, FI ; lv") + ["EE", "FI", "LV"] + + iex> CountryData.parse_priority("") + [] + """ + def parse_priority(value) when is_binary(value) do + value + |> String.split([",", ";", " ", "\n", "\t"], trim: true) + |> Enum.map(&(&1 |> String.trim() |> String.upcase())) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + def parse_priority(_), do: [] + + @doc """ + Keep only the codes that name a real country, in the order given. + + The counterpart of `parse_priority/1` for a settings form: it tells the + operator which of the codes they typed will actually pin something. + + ## Examples + + iex> CountryData.known_country_codes(["EE", "ZZ", "FI"]) + ["EE", "FI"] + """ + def known_country_codes(codes) when is_list(codes) do + Enum.filter(codes, fn code -> is_binary(code) and get_country(code) != nil end) end + def known_country_codes(_), do: [] + defp normalize_priority(codes) when is_list(codes) do codes |> Enum.filter(&is_binary/1) diff --git a/lib/phoenix_kit_web/live/settings/organization.ex b/lib/phoenix_kit_web/live/settings/organization.ex index ba7b82e0b..5606b8a76 100644 --- a/lib/phoenix_kit_web/live/settings/organization.ex +++ b/lib/phoenix_kit_web/live/settings/organization.ex @@ -84,6 +84,7 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do |> assign(:countries, CountryData.countries_for_select()) |> assign(:subdivision_label, get_subdivision_label(country)) |> assign(:eu_country, eu_country?(country)) + |> assign(:country_priority, Settings.get_setting("country_select_priority", "")) end defp assign_tax_settings(socket, _company_info) do @@ -344,6 +345,20 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do }) Settings.update_json_setting("company_info", company_info) + save_country_priority(params["country_priority"]) + end + + # Stored normalized — upper-cased, deduplicated, unknown codes dropped — so + # what the operator sees after saving is exactly what the dropdown will do. + # Blank clears the setting, which hands the default back to + # `config :phoenix_kit, :country_select_priority`. + defp save_country_priority(value) do + codes = + value + |> CountryData.parse_priority() + |> CountryData.known_country_codes() + + Settings.update_setting("country_select_priority", Enum.join(codes, ", ")) end defp save_bank_details(params, iban, swift) do diff --git a/lib/phoenix_kit_web/live/settings/organization.html.heex b/lib/phoenix_kit_web/live/settings/organization.html.heex index 7ec4225d8..3491cfbb7 100644 --- a/lib/phoenix_kit_web/live/settings/organization.html.heex +++ b/lib/phoenix_kit_web/live/settings/organization.html.heex @@ -48,6 +48,24 @@ + <%!-- Row: countries pinned to the top of every country dropdown --%> +
+ <.input + type="text" + name="country_priority" + value={@country_priority} + label={gettext("Preferred countries")} + placeholder="EE, FI, LV, LT, SE" + /> + +
+
{gettext("Address")}
<%!-- Row: Street Address --%> diff --git a/test/phoenix_kit/utils/country_data_test.exs b/test/phoenix_kit/utils/country_data_test.exs index 1af4e000b..efc77758c 100644 --- a/test/phoenix_kit/utils/country_data_test.exs +++ b/test/phoenix_kit/utils/country_data_test.exs @@ -116,6 +116,60 @@ defmodule PhoenixKit.Utils.CountryDataTest do end end + describe "countries_for_select/1 priority from settings" do + # The settings cache is consulted before the update-mode short-circuit, so + # priming it exercises the real read path with no database involved. The + # cache is a globally named process, hence async: false for the file. + setup do + start_supervised!({PhoenixKit.Cache.Registry, []}) + start_supervised!({PhoenixKit.Cache, name: :settings}) + :ok + end + + defp put_priority_setting(value), + do: PhoenixKit.Cache.put(:settings, "country_select_priority", value) + + test "the setting wins over the config" do + Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) + put_priority_setting("EE, LV") + + assert CountryData.countries_for_select(locale: "en") |> Enum.take(2) == [ + {"🇪🇪 Estonia", "EE"}, + {"🇱🇻 Latvia", "LV"} + ] + end + + test "a blank setting falls back to the config" do + Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) + put_priority_setting("") + + assert hd(CountryData.countries_for_select(locale: "en")) == {"🇫🇮 Finland", "FI"} + end + + test "an explicit :priority still overrides both" do + Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) + put_priority_setting("EE") + + assert hd(CountryData.countries_for_select(locale: "en", priority: ["LV"])) == + {"🇱🇻 Latvia", "LV"} + end + end + + describe "parse_priority/1 and known_country_codes/1" do + test "parses the separators an operator actually types" do + assert CountryData.parse_priority("EE, FI ; lv") == ["EE", "FI", "LV"] + assert CountryData.parse_priority("ee\nfi") == ["EE", "FI"] + assert CountryData.parse_priority("EE, EE") == ["EE"] + assert CountryData.parse_priority("") == [] + assert CountryData.parse_priority(nil) == [] + end + + test "drops codes that name no country" do + assert CountryData.known_country_codes(["EE", "ZZ", "FI"]) == ["EE", "FI"] + assert CountryData.known_country_codes(["", nil, :ee]) == [] + end + end + describe "get_country_name/2" do test "translates into the requested locale" do assert CountryData.get_country_name("EE", locale: "ru") == "Эстония" From b55f861d1f9940b9b847edd5419432fc6a78763b Mon Sep 17 00:00:00 2001 From: Timujeen Date: Thu, 13 Aug 2026 10:46:57 +0300 Subject: [PATCH 5/8] Fix the country-priority save path and let the UI turn pinning off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the setting added in the previous commit. The edit was discarded whenever an unrelated company field failed validation: the save ran inside save_company_info/2, which handle_event("save_company", …) only reaches once validate_company_data/1 returns []. Worse, the error branch skips load_settings/1, so the typed text stayed on screen with nothing to signal the loss. On a fresh install it was circular — every required field is empty, so the operator could not save a priority list until they had filled in the whole company form, which is exactly when picking a country out of 250 is hardest. The priority is an independent setting and now persists on every submit. Total rejection was reported as success: "Estonia, USA" left nothing after known_country_codes/1, stored blank, silently reverted the dropdown to the config list, and flashed "saved". Rejected codes are now named in the flash, and a wholesale rejection says so instead of implying success. An operator could not turn pinning off at all — blank means "fall back to config", and a stored empty string is indistinguishable from an absent key at the settings layer (get_setting_cached/2 answers with the default for both). So "none" is now an explicit sentinel, honoured over the config and round-tripped in the field, and it is checked before known_country_codes/1 — it is not a country code and would otherwise be dropped like any other unknown one. Also: the :priority and :locale defaults were evaluated eagerly, which since the previous commit meant a settings read on every call even when the caller passed the option; they move behind Keyword.fetch/2. The rescue/catch around the settings read is gone — get_setting_cached/2 and the get_setting/2 it falls through to already rescue and catch :exit, so those handlers were unreachable and a bare rescue would only swallow a future programmer error. update_setting/2's {:error, changeset} is no longer discarded: 250 codes joined is 998 characters against a 1000-char column, so one more country would have turned this into a silent no-save. Verified against the running app: a submit with a blank company name still stores "LT, LV"; "Estonia, USA" flashes the rejection and stores nothing; " NoNe " stores "none" and the dropdown starts at Afghanistan with the config still set to EE FI LV LT SE. --- lib/phoenix_kit/utils/country_data.ex | 127 ++++++++++++++---- .../live/settings/organization.ex | 104 +++++++++++--- .../live/settings/organization.html.heex | 2 +- test/phoenix_kit/utils/country_data_test.exs | 88 ++++++++++++ 4 files changed, 272 insertions(+), 49 deletions(-) diff --git a/lib/phoenix_kit/utils/country_data.ex b/lib/phoenix_kit/utils/country_data.ex index 5bd3a6120..d62389702 100644 --- a/lib/phoenix_kit/utils/country_data.ex +++ b/lib/phoenix_kit/utils/country_data.ex @@ -34,6 +34,8 @@ defmodule PhoenixKit.Utils.CountryData do alias PhoenixKit.Settings alias PhoenixKitBilling.IbanData + @none_priority "none" + @doc """ Get all countries sorted by name. @@ -326,8 +328,14 @@ defmodule PhoenixKit.Utils.CountryData do `country_select_priority` setting (Admin → Settings → Organization, where an operator can reorder the dropdown without a deploy), then `config :phoenix_kit, :country_select_priority` when that setting is - blank or unset. A host that serves one region can therefore put its own - countries first without touching any call site. + blank or unset. A stored value of `"none"` (case-insensitive, trimmed + — see `none_priority?/1`) is an explicit sentinel meaning "pin + nothing"; it is honoured over the config, which is the only way an + operator can disable pinning on a host that has the config set + without a deploy — a blank/absent setting can't be told apart from + "not configured" and falls back to the config just the same. A host + that serves one region can therefore put its own countries first + without touching any call site. `opts` itself must be a keyword list — a map or a bare string raises `FunctionClauseError` naming this function rather than `Keyword`. @@ -343,8 +351,8 @@ defmodule PhoenixKit.Utils.CountryData do [{"🇪🇪 Эстония", "EE"}, {"🇫🇮 Финляндия", "FI"}] """ def countries_for_select(opts \\ []) when is_list(opts) do - locale = opts |> Keyword.get(:locale, active_locale()) |> normalize_locale() - priority = opts |> Keyword.get(:priority, configured_priority()) |> normalize_priority() + locale = opts |> fetch_opt(:locale, &active_locale/0) |> normalize_locale() + priority = opts |> fetch_opt(:priority, &configured_priority/0) |> normalize_priority() {pinned, rest} = locale |> sorted_entries(:all) |> split_priority(priority) @@ -386,8 +394,8 @@ defmodule PhoenixKit.Utils.CountryData do caveats. """ def eu_countries_for_select(opts \\ []) when is_list(opts) do - locale = opts |> Keyword.get(:locale, active_locale()) |> normalize_locale() - priority = opts |> Keyword.get(:priority, configured_priority()) |> normalize_priority() + locale = opts |> fetch_opt(:locale, &active_locale/0) |> normalize_locale() + priority = opts |> fetch_opt(:priority, &configured_priority/0) |> normalize_priority() {pinned, rest} = locale |> sorted_entries(:eu) |> split_priority(priority) @@ -500,31 +508,48 @@ defmodule PhoenixKit.Utils.CountryData do defp normalize_locale(_), do: nil + # Keyword.get(opts, key, default) evaluates `default` eagerly even when + # `opts` already has `key` — cheap when the default is a literal, but here + # the defaults are a Gettext lookup and a settings-cache read (which can + # fall through to a database query on a cold key), and the result would be + # thrown away whenever the caller passed an explicit value. Only compute + # the default in the :error branch. + defp fetch_opt(opts, key, default_fun) do + case Keyword.fetch(opts, key) do + {:ok, value} -> value + :error -> default_fun.() + end + end + # The admin-editable setting wins over the compile-time config, so an - # operator can reorder the dropdown without a deploy. A missing or blank + # operator can reorder the dropdown without a deploy. A blank or absent # setting means "not configured" and falls through to the config, which # keeps every host that never opens the settings page working as before. + # A stored value of `"none"` (see `none_priority?/1`) is an explicit + # sentinel meaning "pin nothing", honoured over the config — the only way + # an operator can disable pinning on a host that has the config set, + # since a blank/absent setting can't be told apart from "not configured" + # at the settings layer. # - # Read through the cache: this runs on the hot path, and `get_setting_cached/2` - # is consulted before the update-mode short-circuit, so a primed key resolves - # without a database at all. A settings read can raise on an unowned checkout - # AND exit on a dead pool, so both are caught — the country list must not - # depend on the database being up. + # Read through the cache: this runs on the hot path, and + # `get_setting_cached/2` is consulted before the update-mode + # short-circuit, so a primed key resolves without a database at all. + # `Settings.get_setting_cached/2` — and the `get_setting/2` it falls back + # to on a cache error — already rescue AND catch `:exit` internally, so + # the country list degrading to the config when the database is + # unreachable is handled entirely by the Settings layer, not by this + # function. defp configured_priority do - case setting_priority() do - [] -> Application.get_env(:phoenix_kit, :country_select_priority, []) - codes -> codes - end - end + raw = Settings.get_setting_cached("country_select_priority", "") - defp setting_priority do - "country_select_priority" - |> Settings.get_setting_cached("") - |> parse_priority() - rescue - _ -> [] - catch - :exit, _ -> [] + if none_priority?(raw) do + [] + else + case parse_priority(raw) do + [] -> Application.get_env(:phoenix_kit, :country_select_priority, []) + codes -> codes + end + end end @doc """ @@ -532,8 +557,11 @@ defmodule PhoenixKit.Utils.CountryData do Accepts the separators a human actually types — commas, spaces, semicolons, newlines — so `"EE, FI"`, `"ee fi"` and `"EE;FI"` all parse. Unknown codes - are kept here and dropped later by the same normalization every caller of - `:priority` goes through; use `known_country_codes/1` to report them. + are kept here, not dropped — `normalize_priority/1` only filters + non-binaries, upcases, and dedupes. They are dropped later, when + `split_priority/2` looks each one up against the real country list and + finds no match; use `known_country_codes/1` to report them to the + operator before that happens. ## Examples @@ -570,6 +598,49 @@ defmodule PhoenixKit.Utils.CountryData do def known_country_codes(_), do: [] + @doc """ + The sentinel value for the `country_select_priority` setting that means + "pin nothing", honoured over `config :phoenix_kit, + :country_select_priority` by `countries_for_select/1` and + `eu_countries_for_select/1` (see `configured_priority/0`). A caller that + writes the setting (the Organization settings form) should store this + exact value rather than hardcoding the literal string, so the write side + and `none_priority?/1` never drift apart. + + ## Examples + + iex> CountryData.none_priority_value() + "none" + """ + def none_priority_value, do: @none_priority + + @doc """ + True if `value`, trimmed and downcased, is the `"none"` sentinel. + + This is the only way to disable country-priority pinning on a host that + has `config :phoenix_kit, :country_select_priority` set: a blank or + absent setting can't be told apart from "not configured" + (`Settings.get_setting_cached/2` returns the default for both) and falls + back to the config either way, so an operator needs an explicit value + that means "pin nothing" instead. + + ## Examples + + iex> CountryData.none_priority?("none") + true + + iex> CountryData.none_priority?(" NONE ") + true + + iex> CountryData.none_priority?("EE") + false + """ + def none_priority?(value) when is_binary(value) do + value |> String.trim() |> String.downcase() == @none_priority + end + + def none_priority?(_), do: false + defp normalize_priority(codes) when is_list(codes) do codes |> Enum.filter(&is_binary/1) @@ -627,7 +698,7 @@ defmodule PhoenixKit.Utils.CountryData do def get_country_name(country_code, opts \\ []) def get_country_name(country_code, opts) when is_binary(country_code) and is_list(opts) do - locale = opts |> Keyword.get(:locale, active_locale()) |> normalize_locale() + locale = opts |> fetch_opt(:locale, &active_locale/0) |> normalize_locale() case get_country(country_code) do %{} = country -> translated_name(country, locale) diff --git a/lib/phoenix_kit_web/live/settings/organization.ex b/lib/phoenix_kit_web/live/settings/organization.ex index 5606b8a76..d760d9232 100644 --- a/lib/phoenix_kit_web/live/settings/organization.ex +++ b/lib/phoenix_kit_web/live/settings/organization.ex @@ -84,7 +84,7 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do |> assign(:countries, CountryData.countries_for_select()) |> assign(:subdivision_label, get_subdivision_label(country)) |> assign(:eu_country, eu_country?(country)) - |> assign(:country_priority, Settings.get_setting("country_select_priority", "")) + |> assign(:country_priority, Settings.get_setting_cached("country_select_priority", "")) end defp assign_tax_settings(socket, _company_info) do @@ -136,21 +136,29 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do def handle_event("save_company", params, socket) do data = extract_company_data(params) - case validate_company_data(data) do - [] -> - save_company_info(data, params) + # The country-priority list is an independent setting from the rest of + # the company form — it must persist whether or not the company data + # below validates, so it is saved unconditionally here rather than from + # inside the `[] ->` branch. + priority_result = save_country_priority(params["country_priority"]) - # Broadcast to all admin sessions - broadcast_settings_change(:company_info_updated) + socket = + case validate_company_data(data) do + [] -> + save_company_info(data, params) - {:noreply, - socket - |> load_settings() - |> put_flash(:info, gettext("Organization information saved"))} + # Broadcast to all admin sessions + broadcast_settings_change(:company_info_updated) - errors -> - {:noreply, put_flash(socket, :error, Enum.join(errors, ". "))} - end + socket + |> load_settings() + |> put_flash(:info, gettext("Organization information saved")) + + errors -> + put_flash(socket, :error, Enum.join(errors, ". ")) + end + + {:noreply, put_country_priority_flash(socket, priority_result)} end def handle_event("save_tax", params, socket) do @@ -345,20 +353,76 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do }) Settings.update_json_setting("company_info", company_info) - save_country_priority(params["country_priority"]) end # Stored normalized — upper-cased, deduplicated, unknown codes dropped — so # what the operator sees after saving is exactly what the dropdown will do. # Blank clears the setting, which hands the default back to - # `config :phoenix_kit, :country_select_priority`. + # `config :phoenix_kit, :country_select_priority`. Typing the literal word + # "none" (case-insensitive, trimmed — `CountryData.none_priority?/1`) + # stores the sentinel `CountryData.configured_priority/0` honours over + # that config, so it must be checked BEFORE `known_country_codes/1` runs — + # "none" is not a real country code and would otherwise be silently + # dropped just like any other unknown one, storing blank instead of the + # sentinel and leaving pinning impossible to turn off. + # + # Returns `{:ok, %{kept: [...], rejected: [...]}}` — the codes actually + # stored and the ones the operator typed that name no real country, for + # the caller to report — or `{:error, changeset}` if the write itself + # failed (e.g. over the settings value's 1000-character cap). defp save_country_priority(value) do - codes = - value - |> CountryData.parse_priority() - |> CountryData.known_country_codes() + trimmed = (value || "") |> String.trim() + + if CountryData.none_priority?(trimmed) do + write_country_priority(CountryData.none_priority_value(), %{kept: [], rejected: []}) + else + typed = CountryData.parse_priority(trimmed) + known = CountryData.known_country_codes(typed) + rejected = typed -- known + + write_country_priority(Enum.join(known, ", "), %{kept: known, rejected: rejected}) + end + end + + defp write_country_priority(stored_value, outcome) do + case Settings.update_setting("country_select_priority", stored_value) do + {:ok, _setting} -> {:ok, outcome} + {:error, changeset} -> {:error, changeset} + end + end + + # A partial drop still saved something, so it rides alongside whatever + # flash the company-info save already produced. A total drop (something + # was typed, nothing survived) gets the same treatment — it must not be + # silently folded into an unqualified "saved" flash — but says so plainly + # rather than implying a partial success. Both are :error, matching this + # LiveView's only two flash kinds; company_info's own flash is untouched + # either way. + defp put_country_priority_flash(socket, {:ok, %{rejected: []}}), do: socket + + defp put_country_priority_flash(socket, {:ok, %{kept: [], rejected: rejected}}) do + put_flash( + socket, + :error, + gettext( + "None of the preferred-country codes you entered are valid, so preferred countries was cleared: %{codes}", + codes: Enum.join(rejected, ", ") + ) + ) + end + + defp put_country_priority_flash(socket, {:ok, %{rejected: rejected}}) do + put_flash( + socket, + :error, + gettext("Preferred countries saved, but ignored unrecognized code(s): %{codes}", + codes: Enum.join(rejected, ", ") + ) + ) + end - Settings.update_setting("country_select_priority", Enum.join(codes, ", ")) + defp put_country_priority_flash(socket, {:error, _changeset}) do + put_flash(socket, :error, gettext("Preferred countries could not be saved")) end defp save_bank_details(params, iban, swift) do diff --git a/lib/phoenix_kit_web/live/settings/organization.html.heex b/lib/phoenix_kit_web/live/settings/organization.html.heex index 3491cfbb7..18ea31d09 100644 --- a/lib/phoenix_kit_web/live/settings/organization.html.heex +++ b/lib/phoenix_kit_web/live/settings/organization.html.heex @@ -60,7 +60,7 @@ diff --git a/test/phoenix_kit/utils/country_data_test.exs b/test/phoenix_kit/utils/country_data_test.exs index efc77758c..9e0dc6b21 100644 --- a/test/phoenix_kit/utils/country_data_test.exs +++ b/test/phoenix_kit/utils/country_data_test.exs @@ -4,6 +4,7 @@ defmodule PhoenixKit.Utils.CountryDataTest do # No `doctest` here: this module's examples are written against the bare # `CountryData.` alias, which a doctest has no way to resolve. + alias Ecto.Adapters.SQL.Sandbox alias PhoenixKit.Utils.CountryData setup do @@ -120,7 +121,21 @@ defmodule PhoenixKit.Utils.CountryDataTest do # The settings cache is consulted before the update-mode short-circuit, so # priming it exercises the real read path with no database involved. The # cache is a globally named process, hence async: false for the file. + # + # A cache MISS still falls through to `PhoenixKit.Settings`: with no + # database `test_helper.exs` short-circuits that read to the default, but + # when a database IS reachable it becomes a real query from a process + # that owns no sandbox connection — an OwnershipError, not a miss. + # Checking out here (copied from `safe_destination_settings_test.exs`, + # which recorded the same failure on its first run against a database) + # makes the file behave the same either way. Every test below primes the + # key it reads except the "absent key" one, which is exactly why this + # guard is needed at the describe level rather than per-test. setup do + if Application.get_env(:phoenix_kit, :test_repo_available, false) do + :ok = Sandbox.checkout(PhoenixKit.Test.Repo) + end + start_supervised!({PhoenixKit.Cache.Registry, []}) start_supervised!({PhoenixKit.Cache, name: :settings}) :ok @@ -146,6 +161,15 @@ defmodule PhoenixKit.Utils.CountryDataTest do assert hd(CountryData.countries_for_select(locale: "en")) == {"🇫🇮 Finland", "FI"} end + test "an absent setting (never primed) falls back to the config" do + Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) + # No put_priority_setting call: nothing was ever written for this key, + # simulating a host that never opened the settings page — as distinct + # from "a blank setting" above, which IS a written, empty row. + + assert hd(CountryData.countries_for_select(locale: "en")) == {"🇫🇮 Finland", "FI"} + end + test "an explicit :priority still overrides both" do Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) put_priority_setting("EE") @@ -153,6 +177,25 @@ defmodule PhoenixKit.Utils.CountryDataTest do assert hd(CountryData.countries_for_select(locale: "en", priority: ["LV"])) == {"🇱🇻 Latvia", "LV"} end + + test "a stored \"none\" sentinel beats the config" do + Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) + put_priority_setting(CountryData.none_priority_value()) + + assert CountryData.countries_for_select(locale: "en") == + CountryData.countries_for_select(locale: "en", priority: []) + end + + test "the stored sentinel is recognized case-insensitively and trimmed" do + Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) + unpinned = CountryData.countries_for_select(locale: "en", priority: []) + + for stored <- ["NONE", "None", " none ", "\tnone\n"] do + put_priority_setting(stored) + + assert CountryData.countries_for_select(locale: "en") == unpinned + end + end end describe "parse_priority/1 and known_country_codes/1" do @@ -168,6 +211,51 @@ defmodule PhoenixKit.Utils.CountryDataTest do assert CountryData.known_country_codes(["EE", "ZZ", "FI"]) == ["EE", "FI"] assert CountryData.known_country_codes(["", nil, :ee]) == [] end + + test "a fully-rejected input keeps the unknown codes for reporting, but pins nothing" do + # The scenario the settings form's flash has to report: every code the + # operator typed is unknown, so nothing survives to be stored. + typed = CountryData.parse_priority("Estonia, USA, Suomi") + assert typed == ["ESTONIA", "USA", "SUOMI"] + assert CountryData.known_country_codes(typed) == [] + end + end + + describe "none_priority?/1 and none_priority_value/0" do + test "recognizes the sentinel case-insensitively and trimmed" do + assert CountryData.none_priority?("none") + assert CountryData.none_priority?("NONE") + assert CountryData.none_priority?("None") + assert CountryData.none_priority?(" none ") + assert CountryData.none_priority?("\tnone\n") + end + + test "rejects everything else, including near-misses" do + refute CountryData.none_priority?("EE") + refute CountryData.none_priority?("") + refute CountryData.none_priority?("none, EE") + refute CountryData.none_priority?("noneEE") + refute CountryData.none_priority?(nil) + refute CountryData.none_priority?(:none) + end + + test "the canonical value round-trips through the check" do + assert CountryData.none_priority_value() == "none" + assert CountryData.none_priority?(CountryData.none_priority_value()) + end + + test "the sentinel is not a real country code, so known_country_codes/1 would drop it" do + # This is why the settings form must check `none_priority?/1` BEFORE + # running the typed value through `parse_priority/1` and + # `known_country_codes/1`: without that check, "none" is just another + # unrecognized code and gets silently dropped like any other, storing + # blank instead of the sentinel. + refute CountryData.exists?(CountryData.none_priority_value()) + + assert CountryData.none_priority_value() + |> CountryData.parse_priority() + |> CountryData.known_country_codes() == [] + end end describe "get_country_name/2" do From 55c38fd65dbb42d2a133b21e3f837864abef4f71 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Thu, 13 Aug 2026 11:35:24 +0300 Subject: [PATCH 6/8] Remove the config, and make the pinned countries a real list editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were wrong with the first cut of this setting. The compile-time config stayed underneath it as a fallback. That meant a host installing PhoenixKit found country dropdowns already reordered before anyone had chosen anything — whichever countries the config's author happens to serve — and the operator could not tell the difference between "reordered on purpose" and "reordered by a default they never saw". The setting is now the only source: nothing is pinned until someone picks something, and an untouched install is plain alphabetical. Losing the config left the question of where a new host starts, and the answer is in the data rather than in a constant. suggested_priority/2 proposes the organization's own country followed by its nearest neighbours, computed from the country centroids the dataset already carries — Estonia gets Latvia and Finland, Germany gets Luxembourg and the Netherlands, Singapore gets Malaysia and Indonesia. The settings card offers it; nothing is applied until the operator accepts. The free-text field is gone too. It asked an operator to type alpha-2 codes, then silently dropped whatever it did not recognize, so the whole class of "was my input taken?" problems came from the input method. The new card on Admin → Settings → Organization is a list: pick a country from the dropdown, add it, reorder with the arrows, remove with the cross. Every action writes immediately — there is no second field whose validation can hold the list hostage, which is what made the earlier save-behind-validation bug possible. Estonian and Russian translations for the card's strings are included; the config key is removed from the host app in the same change. --- lib/phoenix_kit/utils/country_data.ex | 163 ++++++++------ .../live/settings/organization.ex | 201 +++++++++++------- .../live/settings/organization.html.heex | 123 +++++++++-- priv/gettext/default.pot | 45 ++++ priv/gettext/en/LC_MESSAGES/default.po | 45 ++++ priv/gettext/et/LC_MESSAGES/default.po | 45 ++++ priv/gettext/ru/LC_MESSAGES/default.po | 45 ++++ test/phoenix_kit/utils/country_data_test.exs | 120 +++++------ 8 files changed, 564 insertions(+), 223 deletions(-) diff --git a/lib/phoenix_kit/utils/country_data.ex b/lib/phoenix_kit/utils/country_data.ex index d62389702..26871be15 100644 --- a/lib/phoenix_kit/utils/country_data.ex +++ b/lib/phoenix_kit/utils/country_data.ex @@ -34,8 +34,6 @@ defmodule PhoenixKit.Utils.CountryData do alias PhoenixKit.Settings alias PhoenixKitBilling.IbanData - @none_priority "none" - @doc """ Get all countries sorted by name. @@ -324,18 +322,15 @@ defmodule PhoenixKit.Utils.CountryData do * `:priority` — alpha-2 codes pinned to the top of the list, in the order given; everything else follows alphabetically. A non-list value is - treated as `[]`. The default comes from, in order: the - `country_select_priority` setting (Admin → Settings → Organization, - where an operator can reorder the dropdown without a deploy), then - `config :phoenix_kit, :country_select_priority` when that setting is - blank or unset. A stored value of `"none"` (case-insensitive, trimmed - — see `none_priority?/1`) is an explicit sentinel meaning "pin - nothing"; it is honoured over the config, which is the only way an - operator can disable pinning on a host that has the config set - without a deploy — a blank/absent setting can't be told apart from - "not configured" and falls back to the config just the same. A host - that serves one region can therefore put its own countries first - without touching any call site. + treated as `[]`. The default is the `country_select_priority` setting + (Admin → Settings → Organization), and nothing else: **there is no + compile-time config**, deliberately. A default baked into the library + would pin whatever countries its author serves, so every other host + would install it and find the dropdown already reordered before anyone + chose anything. Until an operator stores a list, nothing is pinned and + the order is plain alphabetical; `suggested_priority/2` is what the + settings UI offers them as a starting point, derived from their own + country rather than from a constant. `opts` itself must be a keyword list — a map or a bare string raises `FunctionClauseError` naming this function rather than `Keyword`. @@ -521,35 +516,25 @@ defmodule PhoenixKit.Utils.CountryData do end end - # The admin-editable setting wins over the compile-time config, so an - # operator can reorder the dropdown without a deploy. A blank or absent - # setting means "not configured" and falls through to the config, which - # keeps every host that never opens the settings page working as before. - # A stored value of `"none"` (see `none_priority?/1`) is an explicit - # sentinel meaning "pin nothing", honoured over the config — the only way - # an operator can disable pinning on a host that has the config set, - # since a blank/absent setting can't be told apart from "not configured" - # at the settings layer. + # The stored setting is the ONLY source. There is deliberately no + # compile-time config fallback: a default baked into the library would + # pin whatever countries its author happens to serve, and every other + # host installs it to find a list already filtered before anyone chose + # anything. Nothing is pinned until an operator says so — see + # `suggested_priority/2` for how the settings UI proposes a starting + # list from the organization's own country instead of from a constant. # # Read through the cache: this runs on the hot path, and # `get_setting_cached/2` is consulted before the update-mode # short-circuit, so a primed key resolves without a database at all. # `Settings.get_setting_cached/2` — and the `get_setting/2` it falls back # to on a cache error — already rescue AND catch `:exit` internally, so - # the country list degrading to the config when the database is - # unreachable is handled entirely by the Settings layer, not by this - # function. + # an unreachable database degrades to "nothing pinned" rather than + # raising, without this function handling anything itself. defp configured_priority do - raw = Settings.get_setting_cached("country_select_priority", "") - - if none_priority?(raw) do - [] - else - case parse_priority(raw) do - [] -> Application.get_env(:phoenix_kit, :country_select_priority, []) - codes -> codes - end - end + "country_select_priority" + |> Settings.get_setting_cached("") + |> parse_priority() end @doc """ @@ -599,47 +584,95 @@ defmodule PhoenixKit.Utils.CountryData do def known_country_codes(_), do: [] @doc """ - The sentinel value for the `country_select_priority` setting that means - "pin nothing", honoured over `config :phoenix_kit, - :country_select_priority` by `countries_for_select/1` and - `eu_countries_for_select/1` (see `configured_priority/0`). A caller that - writes the setting (the Organization settings form) should store this - exact value rather than hardcoding the literal string, so the write side - and `none_priority?/1` never drift apart. + Suggest a starting priority list for a host based in `country_code`. + + Returns that country first, then its nearest neighbours by great-circle + distance between country centroids — so a host in Estonia is offered + Latvia, Finland, Lithuania and Sweden, one in Germany gets Luxembourg, + the Netherlands, Czechia and Belgium, and one in Singapore gets Malaysia, + Indonesia and Cambodia. The point is that it is derived from the host's + own data rather than from a constant baked in by whoever wrote the + library. + + This is a *suggestion* for the settings UI to offer, never applied on its + own: nothing is pinned until an operator stores a list. The result can + include dependent territories (Åland is the second-nearest thing to + Estonia) — the data has no "sovereign state" flag — so the operator is + expected to prune it. + + Dissolved countries are excluded. Countries with no coordinates cannot be + ranked and are skipped. + + ## Options + + * `:limit` — how many neighbours to add after the country itself. + Defaults to 4. ## Examples - iex> CountryData.none_priority_value() - "none" + iex> CountryData.suggested_priority("EE", limit: 2) + ["EE", "LV", "AX"] + + iex> CountryData.suggested_priority("XX") + [] """ - def none_priority_value, do: @none_priority + def suggested_priority(country_code, opts \\ []) - @doc """ - True if `value`, trimmed and downcased, is the `"none"` sentinel. + def suggested_priority(country_code, opts) when is_binary(country_code) and is_list(opts) do + limit = Keyword.get(opts, :limit, 4) + + case get_country(country_code) do + %{geo: %{latitude: lat, longitude: lon}} = origin when is_number(lat) and is_number(lon) -> + [origin.alpha2 | nearest_codes(origin, limit)] - This is the only way to disable country-priority pinning on a host that - has `config :phoenix_kit, :country_select_priority` set: a blank or - absent setting can't be told apart from "not configured" - (`Settings.get_setting_cached/2` returns the default for both) and falls - back to the config either way, so an operator needs an explicit value - that means "pin nothing" instead. + %{} = origin -> + [origin.alpha2] - ## Examples + _ -> + [] + end + end - iex> CountryData.none_priority?("none") - true + def suggested_priority(_, _), do: [] - iex> CountryData.none_priority?(" NONE ") - true + defp nearest_codes(origin, limit) when limit > 0 do + BeamLabCountries.all() + |> Enum.filter(&rankable_neighbour?(&1, origin)) + |> Enum.sort_by(&distance_km(origin, &1)) + |> Enum.take(limit) + |> Enum.map(& &1.alpha2) + end - iex> CountryData.none_priority?("EE") - false - """ - def none_priority?(value) when is_binary(value) do - value |> String.trim() |> String.downcase() == @none_priority + defp nearest_codes(_origin, _limit), do: [] + + defp rankable_neighbour?( + %{alpha2: code, dissolved_on: nil, geo: %{latitude: lat, longitude: lon}}, + origin + ) + when is_number(lat) and is_number(lon), + do: code != origin.alpha2 + + defp rankable_neighbour?(_, _), do: false + + # Haversine over the country centroids the dataset carries. Centroids are a + # coarse proxy for "neighbouring" — a large country's centroid can sit far + # from the border it shares with the origin — but the dataset has no border + # list, and the result only has to be a plausible starting point an operator + # then edits. + defp distance_km(a, b) do + lat1 = deg_to_rad(a.geo.latitude) + lat2 = deg_to_rad(b.geo.latitude) + dlat = lat2 - lat1 + dlon = deg_to_rad(b.geo.longitude - a.geo.longitude) + + h = + :math.pow(:math.sin(dlat / 2), 2) + + :math.cos(lat1) * :math.cos(lat2) * :math.pow(:math.sin(dlon / 2), 2) + + 6371 * 2 * :math.asin(min(1.0, :math.sqrt(h))) end - def none_priority?(_), do: false + defp deg_to_rad(degrees), do: degrees * :math.pi() / 180 defp normalize_priority(codes) when is_list(codes) do codes diff --git a/lib/phoenix_kit_web/live/settings/organization.ex b/lib/phoenix_kit_web/live/settings/organization.ex index d760d9232..92d0fe004 100644 --- a/lib/phoenix_kit_web/live/settings/organization.ex +++ b/lib/phoenix_kit_web/live/settings/organization.ex @@ -84,7 +84,61 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do |> assign(:countries, CountryData.countries_for_select()) |> assign(:subdivision_label, get_subdivision_label(country)) |> assign(:eu_country, eu_country?(country)) - |> assign(:country_priority, Settings.get_setting_cached("country_select_priority", "")) + |> assign_main_countries(stored_main_countries(), country) + end + + defp stored_main_countries do + "country_select_priority" + |> Settings.get_setting_cached("") + |> CountryData.parse_priority() + |> CountryData.known_country_codes() + end + + # Everything the card renders is precomputed here rather than called from + # the template: a `defp` invoked from HEEX cannot be verified by the + # compiler. `:main_country_suggestion` is what the host's own country + # proposes and is deliberately empty once every suggested code is already + # in the list — there is nothing left to offer. + defp assign_main_countries(socket, codes, country) do + chosen = MapSet.new(codes) + + socket + |> assign(:main_countries, codes) + |> assign(:main_country_rows, main_country_rows(codes)) + |> assign( + :main_country_options, + Enum.reject(CountryData.countries_for_select(), fn {_label, code} -> + MapSet.member?(chosen, code) + end) + ) + |> assign(:main_country_suggestion, main_country_suggestion(country, chosen)) + end + + defp main_country_rows(codes) do + last = length(codes) - 1 + + codes + |> Enum.with_index() + |> Enum.map(fn {code, index} -> + %{ + code: code, + label: CountryData.get_flag(code) <> " " <> CountryData.get_country_name(code), + first?: index == 0, + last?: index == last + } + end) + end + + defp main_country_suggestion(country, chosen) do + country + |> CountryData.suggested_priority() + |> Enum.reject(&MapSet.member?(chosen, &1)) + |> Enum.map(fn code -> + %{ + code: code, + label: CountryData.get_flag(code) <> " " <> CountryData.get_country_name(code) + } + end) end defp assign_tax_settings(socket, _company_info) do @@ -136,29 +190,45 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do def handle_event("save_company", params, socket) do data = extract_company_data(params) - # The country-priority list is an independent setting from the rest of - # the company form — it must persist whether or not the company data - # below validates, so it is saved unconditionally here rather than from - # inside the `[] ->` branch. - priority_result = save_country_priority(params["country_priority"]) + case validate_company_data(data) do + [] -> + save_company_info(data, params) - socket = - case validate_company_data(data) do - [] -> - save_company_info(data, params) + # Broadcast to all admin sessions + broadcast_settings_change(:company_info_updated) - # Broadcast to all admin sessions - broadcast_settings_change(:company_info_updated) + {:noreply, + socket + |> load_settings() + |> put_flash(:info, gettext("Organization information saved"))} - socket - |> load_settings() - |> put_flash(:info, gettext("Organization information saved")) + errors -> + {:noreply, put_flash(socket, :error, Enum.join(errors, ". "))} + end + end - errors -> - put_flash(socket, :error, Enum.join(errors, ". ")) - end + # The main-countries card writes on every action rather than behind a save + # button: each click is already a deliberate edit, and there is no second + # field whose validation could hold the list hostage. + def handle_event("add_main_country", %{"code" => code}, socket) do + {:noreply, put_main_countries(socket, socket.assigns.main_countries ++ [code])} + end + + def handle_event("add_main_country", _params, socket), do: {:noreply, socket} + + def handle_event("remove_main_country", %{"code" => code}, socket) do + {:noreply, + put_main_countries(socket, Enum.reject(socket.assigns.main_countries, &(&1 == code)))} + end + + def handle_event("move_main_country", %{"code" => code, "direction" => direction}, socket) do + {:noreply, put_main_countries(socket, move(socket.assigns.main_countries, code, direction))} + end + + def handle_event("apply_main_country_suggestion", _params, socket) do + suggested = Enum.map(socket.assigns.main_country_suggestion, & &1.code) - {:noreply, put_country_priority_flash(socket, priority_result)} + {:noreply, put_main_countries(socket, socket.assigns.main_countries ++ suggested)} end def handle_event("save_tax", params, socket) do @@ -355,74 +425,47 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do Settings.update_json_setting("company_info", company_info) end - # Stored normalized — upper-cased, deduplicated, unknown codes dropped — so - # what the operator sees after saving is exactly what the dropdown will do. - # Blank clears the setting, which hands the default back to - # `config :phoenix_kit, :country_select_priority`. Typing the literal word - # "none" (case-insensitive, trimmed — `CountryData.none_priority?/1`) - # stores the sentinel `CountryData.configured_priority/0` honours over - # that config, so it must be checked BEFORE `known_country_codes/1` runs — - # "none" is not a real country code and would otherwise be silently - # dropped just like any other unknown one, storing blank instead of the - # sentinel and leaving pinning impossible to turn off. - # - # Returns `{:ok, %{kept: [...], rejected: [...]}}` — the codes actually - # stored and the ones the operator typed that name no real country, for - # the caller to report — or `{:error, changeset}` if the write itself - # failed (e.g. over the settings value's 1000-character cap). - defp save_country_priority(value) do - trimmed = (value || "") |> String.trim() - - if CountryData.none_priority?(trimmed) do - write_country_priority(CountryData.none_priority_value(), %{kept: [], rejected: []}) - else - typed = CountryData.parse_priority(trimmed) - known = CountryData.known_country_codes(typed) - rejected = typed -- known - - write_country_priority(Enum.join(known, ", "), %{kept: known, rejected: rejected}) + # Only real, deduplicated country codes ever reach the setting: the card + # offers a picker over the country list, so there is no free text to + # sanitize, and `known_country_codes/1` is the belt-and-braces guard on a + # forged phx-value. An empty list means nothing is pinned and every country + # list is plain alphabetical — the setting is the only source, there is no + # config underneath it. + defp put_main_countries(socket, codes) do + codes = + codes + |> Enum.map(&String.upcase/1) + |> Enum.uniq() + |> CountryData.known_country_codes() + + case Settings.update_setting("country_select_priority", Enum.join(codes, ", ")) do + {:ok, _setting} -> + assign_main_countries(socket, codes, socket.assigns.company_country) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Main countries could not be saved")) end end - defp write_country_priority(stored_value, outcome) do - case Settings.update_setting("country_select_priority", stored_value) do - {:ok, _setting} -> {:ok, outcome} - {:error, changeset} -> {:error, changeset} + defp move(codes, code, direction) do + case Enum.find_index(codes, &(&1 == code)) do + nil -> codes + index -> swap(codes, index, target_index(index, direction, length(codes))) end end - # A partial drop still saved something, so it rides alongside whatever - # flash the company-info save already produced. A total drop (something - # was typed, nothing survived) gets the same treatment — it must not be - # silently folded into an unqualified "saved" flash — but says so plainly - # rather than implying a partial success. Both are :error, matching this - # LiveView's only two flash kinds; company_info's own flash is untouched - # either way. - defp put_country_priority_flash(socket, {:ok, %{rejected: []}}), do: socket - - defp put_country_priority_flash(socket, {:ok, %{kept: [], rejected: rejected}}) do - put_flash( - socket, - :error, - gettext( - "None of the preferred-country codes you entered are valid, so preferred countries was cleared: %{codes}", - codes: Enum.join(rejected, ", ") - ) - ) - end + defp target_index(index, "up", _length), do: max(index - 1, 0) + defp target_index(index, "down", length), do: min(index + 1, length - 1) + defp target_index(index, _direction, _length), do: index - defp put_country_priority_flash(socket, {:ok, %{rejected: rejected}}) do - put_flash( - socket, - :error, - gettext("Preferred countries saved, but ignored unrecognized code(s): %{codes}", - codes: Enum.join(rejected, ", ") - ) - ) - end + defp swap(codes, index, index), do: codes + + defp swap(codes, from, to) do + moved = Enum.at(codes, from) - defp put_country_priority_flash(socket, {:error, _changeset}) do - put_flash(socket, :error, gettext("Preferred countries could not be saved")) + codes + |> List.delete_at(from) + |> List.insert_at(to, moved) end defp save_bank_details(params, iban, swift) do diff --git a/lib/phoenix_kit_web/live/settings/organization.html.heex b/lib/phoenix_kit_web/live/settings/organization.html.heex index 18ea31d09..883e42b68 100644 --- a/lib/phoenix_kit_web/live/settings/organization.html.heex +++ b/lib/phoenix_kit_web/live/settings/organization.html.heex @@ -48,24 +48,6 @@ - <%!-- Row: countries pinned to the top of every country dropdown --%> -
- <.input - type="text" - name="country_priority" - value={@country_priority} - label={gettext("Preferred countries")} - placeholder="EE, FI, LV, LT, SE" - /> - -
-
{gettext("Address")}
<%!-- Row: Street Address --%> @@ -174,8 +156,111 @@ - <%!-- Right column: Bank + Tax + Site URL stacked --%> + <%!-- Right column: Main countries + Bank + Tax + Site URL stacked --%>
+ <%!-- Main countries --%> +
+
+

+ <.icon name="hero-map-pin" class="w-5 h-5" /> + {gettext("Main countries")} +

+

+ {gettext("Shown at the top of every country list, in this order")} +

+ + <%= if @main_country_rows == [] do %> +

+ {gettext("Nothing chosen yet — country lists are plain alphabetical.")} +

+ <% else %> +
    +
  • + {row.label} + + + + + + +
  • +
+ <% end %> + + <%= if @main_country_suggestion != [] do %> +
+ + {gettext("Based on your country: %{countries}", + countries: + Enum.map_join(@main_country_suggestion, ", ", fn suggested -> + suggested.label + end) + )} + + +
+ <% end %> + +
+
+ <.select + name="code" + value="" + options={@main_country_options} + prompt={gettext("Select country...")} + label={gettext("Add a country")} + /> +
+ +
+
+
+ <%!-- Bank Details --%>
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 218c657d8..6467fa50f 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -12179,11 +12179,56 @@ msgstr "" msgid "Where PhoenixKit sends visitors who are not signed in. Empty = the sign-in page." msgstr "" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:139 +#, elixir-autogen, elixir-format +msgid "Add a country" +msgstr "" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:127 +#, elixir-autogen, elixir-format +msgid "Add these" +msgstr "" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:113 +#, elixir-autogen, elixir-format +msgid "Based on your country: %{countries}" +msgstr "" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:167 +#, elixir-autogen, elixir-format +msgid "Main countries" +msgstr "" + +#: lib/phoenix_kit_web/live/settings/organization.ex:445 +#, elixir-autogen, elixir-format +msgid "Main countries could not be saved" +msgstr "" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:96 +#, elixir-autogen, elixir-format +msgid "Move down" +msgstr "" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:84 +#, elixir-autogen, elixir-format +msgid "Move up" +msgstr "" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Nothing chosen yet — country lists are plain alphabetical." +msgstr "" + #: lib/phoenix_kit_web/live/settings/authorization.html.heex:350 #, elixir-autogen, elixir-format msgid "Require the provider to confirm the email address" msgstr "" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:60 +#, elixir-autogen, elixir-format +msgid "Shown at the top of every country list, in this order" +msgstr "" + #: lib/phoenix_kit_web/live/settings/authorization.html.heex:354 #, elixir-autogen, elixir-format msgid "When on, signing in with a provider can only join an existing account if the provider states it verified that address. Turning this off lets anyone who gets an address onto a provider account sign in as its owner — only do so for a provider that does not report verification." diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 3403eeac9..77d173c58 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -12180,11 +12180,56 @@ msgstr "" msgid "Where PhoenixKit sends visitors who are not signed in. Empty = the sign-in page." msgstr "" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:139 +#, elixir-autogen, elixir-format +msgid "Add a country" +msgstr "Add a country" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:127 +#, elixir-autogen, elixir-format +msgid "Add these" +msgstr "Add these" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:113 +#, elixir-autogen, elixir-format +msgid "Based on your country: %{countries}" +msgstr "Based on your country: %{countries}" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:167 +#, elixir-autogen, elixir-format +msgid "Main countries" +msgstr "Main countries" + +#: lib/phoenix_kit_web/live/settings/organization.ex:445 +#, elixir-autogen, elixir-format +msgid "Main countries could not be saved" +msgstr "Main countries could not be saved" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:96 +#, elixir-autogen, elixir-format +msgid "Move down" +msgstr "Move down" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:84 +#, elixir-autogen, elixir-format +msgid "Move up" +msgstr "Move up" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Nothing chosen yet — country lists are plain alphabetical." +msgstr "Nothing chosen yet — country lists are plain alphabetical." + #: lib/phoenix_kit_web/live/settings/authorization.html.heex:350 #, elixir-autogen, elixir-format msgid "Require the provider to confirm the email address" msgstr "" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:60 +#, elixir-autogen, elixir-format +msgid "Shown at the top of every country list, in this order" +msgstr "Shown at the top of every country list, in this order" + #: lib/phoenix_kit_web/live/settings/authorization.html.heex:354 #, elixir-autogen, elixir-format msgid "When on, signing in with a provider can only join an existing account if the provider states it verified that address. Turning this off lets anyone who gets an address onto a provider account sign in as its owner — only do so for a provider that does not report verification." diff --git a/priv/gettext/et/LC_MESSAGES/default.po b/priv/gettext/et/LC_MESSAGES/default.po index 2309224eb..7c07d6e20 100644 --- a/priv/gettext/et/LC_MESSAGES/default.po +++ b/priv/gettext/et/LC_MESSAGES/default.po @@ -12197,11 +12197,56 @@ msgstr "Avaleht" msgid "Where PhoenixKit sends visitors who are not signed in. Empty = the sign-in page." msgstr "Kuhu PhoenixKit suunab sisse logimata külastajad. Tühi = sisselogimisleht." +#: lib/phoenix_kit_web/live/settings/organization.html.heex:139 +#, elixir-autogen, elixir-format +msgid "Add a country" +msgstr "Lisa riik" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:127 +#, elixir-autogen, elixir-format +msgid "Add these" +msgstr "Lisa need" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:113 +#, elixir-autogen, elixir-format +msgid "Based on your country: %{countries}" +msgstr "Sinu riigi põhjal: %{countries}" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:167 +#, elixir-autogen, elixir-format +msgid "Main countries" +msgstr "Põhiriigid" + +#: lib/phoenix_kit_web/live/settings/organization.ex:445 +#, elixir-autogen, elixir-format +msgid "Main countries could not be saved" +msgstr "Põhiriike ei õnnestunud salvestada" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:96 +#, elixir-autogen, elixir-format +msgid "Move down" +msgstr "Liiguta alla" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:84 +#, elixir-autogen, elixir-format +msgid "Move up" +msgstr "Liiguta üles" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Nothing chosen yet — country lists are plain alphabetical." +msgstr "Midagi pole veel valitud — riikide loendid on tähestikulises järjekorras." + #: lib/phoenix_kit_web/live/settings/authorization.html.heex:350 #, elixir-autogen, elixir-format msgid "Require the provider to confirm the email address" msgstr "Nõua, et teenusepakkuja kinnitaks e-posti aadressi" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:60 +#, elixir-autogen, elixir-format +msgid "Shown at the top of every country list, in this order" +msgstr "Kuvatakse iga riikide loendi alguses, selles järjekorras" + #: lib/phoenix_kit_web/live/settings/authorization.html.heex:354 #, elixir-autogen, elixir-format msgid "When on, signing in with a provider can only join an existing account if the provider states it verified that address. Turning this off lets anyone who gets an address onto a provider account sign in as its owner — only do so for a provider that does not report verification." diff --git a/priv/gettext/ru/LC_MESSAGES/default.po b/priv/gettext/ru/LC_MESSAGES/default.po index 3fb8bb9af..0f95b9c60 100644 --- a/priv/gettext/ru/LC_MESSAGES/default.po +++ b/priv/gettext/ru/LC_MESSAGES/default.po @@ -12220,11 +12220,56 @@ msgstr "Главная страница" msgid "Where PhoenixKit sends visitors who are not signed in. Empty = the sign-in page." msgstr "Куда PhoenixKit направляет посетителей, не выполнивших вход. Пусто = страница входа." +#: lib/phoenix_kit_web/live/settings/organization.html.heex:139 +#, elixir-autogen, elixir-format +msgid "Add a country" +msgstr "Добавить страну" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:127 +#, elixir-autogen, elixir-format +msgid "Add these" +msgstr "Добавить их" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:113 +#, elixir-autogen, elixir-format +msgid "Based on your country: %{countries}" +msgstr "Исходя из вашей страны: %{countries}" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:167 +#, elixir-autogen, elixir-format +msgid "Main countries" +msgstr "Основные страны" + +#: lib/phoenix_kit_web/live/settings/organization.ex:445 +#, elixir-autogen, elixir-format +msgid "Main countries could not be saved" +msgstr "Не удалось сохранить основные страны" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:96 +#, elixir-autogen, elixir-format +msgid "Move down" +msgstr "Переместить вниз" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:84 +#, elixir-autogen, elixir-format +msgid "Move up" +msgstr "Переместить вверх" + +#: lib/phoenix_kit_web/live/settings/organization.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Nothing chosen yet — country lists are plain alphabetical." +msgstr "Пока ничего не выбрано — списки стран идут по алфавиту." + #: lib/phoenix_kit_web/live/settings/authorization.html.heex:350 #, elixir-autogen, elixir-format msgid "Require the provider to confirm the email address" msgstr "Требовать подтверждение адреса электронной почты от провайдера" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:60 +#, elixir-autogen, elixir-format +msgid "Shown at the top of every country list, in this order" +msgstr "Показываются в начале каждого списка стран, в этом порядке" + #: lib/phoenix_kit_web/live/settings/authorization.html.heex:354 #, elixir-autogen, elixir-format msgid "When on, signing in with a provider can only join an existing account if the provider states it verified that address. Turning this off lets anyone who gets an address onto a provider account sign in as its owner — only do so for a provider that does not report verification." diff --git a/test/phoenix_kit/utils/country_data_test.exs b/test/phoenix_kit/utils/country_data_test.exs index 9e0dc6b21..350c22d38 100644 --- a/test/phoenix_kit/utils/country_data_test.exs +++ b/test/phoenix_kit/utils/country_data_test.exs @@ -78,9 +78,13 @@ defmodule PhoenixKit.Utils.CountryDataTest do assert hd(result) == {"🇪🇪 Estonia", "EE"} end - test "defaults to the configured priority" do + test "pins nothing when no priority is given or stored" do + # There is no compile-time config underneath the setting, so an + # untouched install must be plain alphabetical. Setting the old config + # key must have no effect whatsoever. Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) - assert hd(CountryData.countries_for_select(locale: "en")) == {"🇫🇮 Finland", "FI"} + + assert hd(CountryData.countries_for_select(locale: "en")) == {"🇦🇫 Afghanistan", "AF"} end test "sorts the unpinned remainder alphabetically, accents folded" do @@ -144,57 +148,47 @@ defmodule PhoenixKit.Utils.CountryDataTest do defp put_priority_setting(value), do: PhoenixKit.Cache.put(:settings, "country_select_priority", value) - test "the setting wins over the config" do - Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) + test "the stored setting pins the countries" do put_priority_setting("EE, LV") assert CountryData.countries_for_select(locale: "en") |> Enum.take(2) == [ - {"🇪🇪 Estonia", "EE"}, - {"🇱🇻 Latvia", "LV"} + {"\u{1F1EA}\u{1F1EA} Estonia", "EE"}, + {"\u{1F1F1}\u{1F1FB} Latvia", "LV"} ] end - test "a blank setting falls back to the config" do - Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) + test "a blank setting pins nothing" do put_priority_setting("") - assert hd(CountryData.countries_for_select(locale: "en")) == {"🇫🇮 Finland", "FI"} + assert hd(CountryData.countries_for_select(locale: "en")) == + {"\u{1F1E6}\u{1F1EB} Afghanistan", "AF"} end - test "an absent setting (never primed) falls back to the config" do - Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) - # No put_priority_setting call: nothing was ever written for this key, - # simulating a host that never opened the settings page — as distinct - # from "a blank setting" above, which IS a written, empty row. - - assert hd(CountryData.countries_for_select(locale: "en")) == {"🇫🇮 Finland", "FI"} + test "an absent setting pins nothing" do + # No put_priority_setting call: a host that never opened the settings + # page. Nothing is pinned — there is no config underneath to inherit. + assert hd(CountryData.countries_for_select(locale: "en")) == + {"\u{1F1E6}\u{1F1EB} Afghanistan", "AF"} end - test "an explicit :priority still overrides both" do + test "the old config key is ignored entirely" do Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) put_priority_setting("EE") - assert hd(CountryData.countries_for_select(locale: "en", priority: ["LV"])) == - {"🇱🇻 Latvia", "LV"} - end + assert hd(CountryData.countries_for_select(locale: "en")) == + {"\u{1F1EA}\u{1F1EA} Estonia", "EE"} - test "a stored \"none\" sentinel beats the config" do - Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) - put_priority_setting(CountryData.none_priority_value()) + put_priority_setting("") - assert CountryData.countries_for_select(locale: "en") == - CountryData.countries_for_select(locale: "en", priority: []) + assert hd(CountryData.countries_for_select(locale: "en")) == + {"\u{1F1E6}\u{1F1EB} Afghanistan", "AF"} end - test "the stored sentinel is recognized case-insensitively and trimmed" do - Application.put_env(:phoenix_kit, :country_select_priority, ["FI"]) - unpinned = CountryData.countries_for_select(locale: "en", priority: []) - - for stored <- ["NONE", "None", " none ", "\tnone\n"] do - put_priority_setting(stored) + test "an explicit :priority overrides the setting" do + put_priority_setting("EE") - assert CountryData.countries_for_select(locale: "en") == unpinned - end + assert hd(CountryData.countries_for_select(locale: "en", priority: ["LV"])) == + {"\u{1F1F1}\u{1F1FB} Latvia", "LV"} end end @@ -221,40 +215,46 @@ defmodule PhoenixKit.Utils.CountryDataTest do end end - describe "none_priority?/1 and none_priority_value/0" do - test "recognizes the sentinel case-insensitively and trimmed" do - assert CountryData.none_priority?("none") - assert CountryData.none_priority?("NONE") - assert CountryData.none_priority?("None") - assert CountryData.none_priority?(" none ") - assert CountryData.none_priority?("\tnone\n") + describe "suggested_priority/2" do + test "puts the host's own country first, then its nearest neighbours" do + assert ["EE" | neighbours] = CountryData.suggested_priority("EE", limit: 4) + assert "LV" in neighbours + assert "FI" in neighbours + assert length(neighbours) == 4 end - test "rejects everything else, including near-misses" do - refute CountryData.none_priority?("EE") - refute CountryData.none_priority?("") - refute CountryData.none_priority?("none, EE") - refute CountryData.none_priority?("noneEE") - refute CountryData.none_priority?(nil) - refute CountryData.none_priority?(:none) + test "works anywhere, not just where the library was written" do + # The point of deriving this from the data rather than from a constant: + # a host in Germany or Singapore must get ITS neighbours. + assert ["DE" | de] = CountryData.suggested_priority("DE", limit: 3) + assert "NL" in de or "LU" in de + + assert ["SG" | sg] = CountryData.suggested_priority("SG", limit: 3) + assert "MY" in sg + refute "EE" in sg end - test "the canonical value round-trips through the check" do - assert CountryData.none_priority_value() == "none" - assert CountryData.none_priority?(CountryData.none_priority_value()) + test "never repeats the origin country" do + codes = CountryData.suggested_priority("EE", limit: 6) + assert Enum.count(codes, &(&1 == "EE")) == 1 + assert codes == Enum.uniq(codes) end - test "the sentinel is not a real country code, so known_country_codes/1 would drop it" do - # This is why the settings form must check `none_priority?/1` BEFORE - # running the typed value through `parse_priority/1` and - # `known_country_codes/1`: without that check, "none" is just another - # unrecognized code and gets silently dropped like any other, storing - # blank instead of the sentinel. - refute CountryData.exists?(CountryData.none_priority_value()) + test "honours :limit, including zero" do + assert length(CountryData.suggested_priority("EE", limit: 0)) == 1 + assert length(CountryData.suggested_priority("EE", limit: 1)) == 2 + assert length(CountryData.suggested_priority("EE")) == 5 + end + + test "answers [] for a code that names no country" do + assert CountryData.suggested_priority("XX") == [] + assert CountryData.suggested_priority(nil) == [] + assert CountryData.suggested_priority(123) == [] + end - assert CountryData.none_priority_value() - |> CountryData.parse_priority() - |> CountryData.known_country_codes() == [] + test "returns codes that are all real countries" do + codes = CountryData.suggested_priority("EE", limit: 8) + assert CountryData.known_country_codes(codes) == codes end end From ca9042ceb4c1ca282bba65878c8ae1edfbbddcf4 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Thu, 13 Aug 2026 11:56:02 +0300 Subject: [PATCH 7/8] Move the main-countries card under the company card and drag to reorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the card got wrong. It sat in the right-hand column with bank and tax details, away from the company country it is about. It now stacks directly under the company card. The picker was a plain select over 250 options with no search, and its options came from countries_for_select/1 WITH the pinning applied — so the first thing it offered was whatever is already pinned, and picking "the top one" added a country the operator never chose. It is now the SearchableSelect component, and its options are built with `priority: []` so the list you pick from is never reordered by the list you are editing. Ordering was two arrow buttons per row. This repo already ships DnD for exactly this (`<.draggable_list>` over the SortableGrid hook), so the rows are draggable by a handle instead. `reorder_main_countries` takes the order SortableGrid pushes, but keeps only codes that were already pinned and re-appends anything the payload omitted, so a forged `ordered_ids` can neither add a country nor silently drop one. Verified against the running app: dragging to SE, LV, EE, FI, LT stores that order; a payload of ["ZZ", "LT"] stores "LT, SE, LV, EE, FI" — unknown code ignored, nothing lost. The picker now starts at Afganistan, Ahvenamaa, Albaania rather than at the pinned five. --- .../live/settings/organization.ex | 47 +-- .../live/settings/organization.html.heex | 313 +++++++++--------- priv/gettext/default.pot | 7 +- priv/gettext/en/LC_MESSAGES/default.po | 9 +- priv/gettext/et/LC_MESSAGES/default.po | 9 +- priv/gettext/ru/LC_MESSAGES/default.po | 9 +- 6 files changed, 193 insertions(+), 201 deletions(-) diff --git a/lib/phoenix_kit_web/live/settings/organization.ex b/lib/phoenix_kit_web/live/settings/organization.ex index 92d0fe004..caa7b11dc 100644 --- a/lib/phoenix_kit_web/live/settings/organization.ex +++ b/lib/phoenix_kit_web/live/settings/organization.ex @@ -107,7 +107,10 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do |> assign(:main_country_rows, main_country_rows(codes)) |> assign( :main_country_options, - Enum.reject(CountryData.countries_for_select(), fn {_label, code} -> + # `priority: []` on purpose: the picker is where you go to CHANGE the + # pinned set, so it must not itself be reordered by it — otherwise the + # first thing the dropdown offers is whatever is already pinned. + Enum.reject(CountryData.countries_for_select(priority: []), fn {_label, code} -> MapSet.member?(chosen, code) end) ) @@ -115,16 +118,10 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do end defp main_country_rows(codes) do - last = length(codes) - 1 - - codes - |> Enum.with_index() - |> Enum.map(fn {code, index} -> + Enum.map(codes, fn code -> %{ code: code, - label: CountryData.get_flag(code) <> " " <> CountryData.get_country_name(code), - first?: index == 0, - last?: index == last + label: CountryData.get_flag(code) <> " " <> CountryData.get_country_name(code) } end) end @@ -221,8 +218,15 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do put_main_countries(socket, Enum.reject(socket.assigns.main_countries, &(&1 == code)))} end - def handle_event("move_main_country", %{"code" => code, "direction" => direction}, socket) do - {:noreply, put_main_countries(socket, move(socket.assigns.main_countries, code, direction))} + # SortableGrid pushes the full order on drop, so the list is taken as given + # rather than diffed — but only codes that were already pinned are honoured, + # so a forged payload can neither add a country nor drop one silently. + def handle_event("reorder_main_countries", %{"ordered_ids" => ordered}, socket) do + current = socket.assigns.main_countries + reordered = Enum.filter(ordered, &(&1 in current)) + codes = reordered ++ (current -- reordered) + + {:noreply, put_main_countries(socket, codes)} end def handle_event("apply_main_country_suggestion", _params, socket) do @@ -447,27 +451,6 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do end end - defp move(codes, code, direction) do - case Enum.find_index(codes, &(&1 == code)) do - nil -> codes - index -> swap(codes, index, target_index(index, direction, length(codes))) - end - end - - defp target_index(index, "up", _length), do: max(index - 1, 0) - defp target_index(index, "down", length), do: min(index + 1, length - 1) - defp target_index(index, _direction, _length), do: index - - defp swap(codes, index, index), do: codes - - defp swap(codes, from, to) do - moved = Enum.at(codes, from) - - codes - |> List.delete_at(from) - |> List.insert_at(to, moved) - end - defp save_bank_details(params, iban, swift) do bank_details = %{ "bank_name" => (params["bank_name"] || "") |> String.trim(), diff --git a/lib/phoenix_kit_web/live/settings/organization.html.heex b/lib/phoenix_kit_web/live/settings/organization.html.heex index 883e42b68..8ad084543 100644 --- a/lib/phoenix_kit_web/live/settings/organization.html.heex +++ b/lib/phoenix_kit_web/live/settings/organization.html.heex @@ -10,154 +10,154 @@ >
- <%!-- Company Information --%> -
-
-

- <.icon name="hero-building-office-2" class="w-5 h-5" /> - {gettext("Company Information")} -

-

- {gettext("Used in legal documents and invoices")} -

+ <%!-- Left column: Company + Main countries stacked --%> +
+ <%!-- Company Information --%> +
+
+

+ <.icon name="hero-building-office-2" class="w-5 h-5" /> + {gettext("Company Information")} +

+

+ {gettext("Used in legal documents and invoices")} +

-
- <%!-- Row: Company Name + Country --%> -
-
- <.input - type="text" - name="company_name" - value={@company_name} - label={gettext("Company Name")} - placeholder={gettext("Your Company Name")} - required - /> -
+ + <%!-- Row: Company Name + Country --%> +
+
+ <.input + type="text" + name="company_name" + value={@company_name} + label={gettext("Company Name")} + placeholder={gettext("Your Company Name")} + required + /> +
-
- <.select - name="company_country" - value={@company_country} - phx-change="country_changed" - required - label={gettext("Country")} - prompt={gettext("Select country...")} - options={@countries} - /> +
+ <.select + name="company_country" + value={@company_country} + phx-change="country_changed" + required + label={gettext("Country")} + prompt={gettext("Select country...")} + options={@countries} + /> +
-
- -
{gettext("Address")}
- - <%!-- Row: Street Address --%> -
- <.input - type="text" - name="company_address_line1" - value={@company_address_line1} - label={gettext("Street Address")} - placeholder={gettext("123 Business Street")} - required - /> -
- <%!-- Row: Address Line 2 --%> -
- <.input - type="text" - name="company_address_line2" - value={@company_address_line2} - label={gettext("Address Line 2")} - placeholder={gettext("Suite, Floor, Building (optional)")} - /> -
+
{gettext("Address")}
- <%!-- Row: City + State/Province + Postal Code --%> -
+ <%!-- Row: Street Address --%>
<.input type="text" - name="company_city" - value={@company_city} - label={gettext("City")} - placeholder={gettext("City")} + name="company_address_line1" + value={@company_address_line1} + label={gettext("Street Address")} + placeholder={gettext("123 Business Street")} required />
+ <%!-- Row: Address Line 2 --%>
<.input type="text" - name="company_state" - value={@company_state} - label={@subdivision_label} - placeholder={gettext("(optional)")} + name="company_address_line2" + value={@company_address_line2} + label={gettext("Address Line 2")} + placeholder={gettext("Suite, Floor, Building (optional)")} />
-
- <.input - type="text" - name="company_postal_code" - value={@company_postal_code} - label={gettext("Postal Code")} - placeholder="10115" - class="font-mono" - /> -
-
+ <%!-- Row: City + State/Province + Postal Code --%> +
+
+ <.input + type="text" + name="company_city" + value={@company_city} + label={gettext("City")} + placeholder={gettext("City")} + required + /> +
-
{gettext("Tax & Registration")}
+
+ <.input + type="text" + name="company_state" + value={@company_state} + label={@subdivision_label} + placeholder={gettext("(optional)")} + /> +
- <%!-- Row: VAT Number + Registration Number --%> -
-
- <.input - type="text" - name="company_vat" - value={@company_vat} - label={gettext("VAT Number")} - placeholder={ - if @eu_country, do: "#{@company_country}123456789", else: gettext("Tax ID") - } - required - class="font-mono" - /> - <%= if @eu_country do %> - - <% end %> +
+ <.input + type="text" + name="company_postal_code" + value={@company_postal_code} + label={gettext("Postal Code")} + placeholder="10115" + class="font-mono" + /> +
-
- <.input - type="text" - name="company_registration" - value={@company_registration} - label={gettext("Registration Number")} - placeholder={gettext("Business reg. number (optional)")} - class="font-mono" - /> +
{gettext("Tax & Registration")}
+ + <%!-- Row: VAT Number + Registration Number --%> +
+
+ <.input + type="text" + name="company_vat" + value={@company_vat} + label={gettext("VAT Number")} + placeholder={ + if @eu_country, do: "#{@company_country}123456789", else: gettext("Tax ID") + } + required + class="font-mono" + /> + <%= if @eu_country do %> + + <% end %> +
+ +
+ <.input + type="text" + name="company_registration" + value={@company_registration} + label={gettext("Registration Number")} + placeholder={gettext("Business reg. number (optional)")} + class="font-mono" + /> +
-
-
- -
- +
+ +
+ +
-
- <%!-- Right column: Main countries + Bank + Tax + Site URL stacked --%> -
<%!-- Main countries --%>
@@ -166,7 +166,7 @@ {gettext("Main countries")}

- {gettext("Shown at the top of every country list, in this order")} + {gettext("Shown at the top of every country list. Drag to reorder.")}

<%= if @main_country_rows == [] do %> @@ -174,37 +174,23 @@ {gettext("Nothing chosen yet — country lists are plain alphabetical.")}

<% else %> -
    -
  • + <.draggable_list + id="main-countries" + items={@main_country_rows} + item_id={& &1.code} + on_reorder="reorder_main_countries" + layout={:list} + gap="gap-1" + class="mt-4" + sortable_handle=".pk-drag-handle" + item_class="flex items-center gap-2 py-2 px-1 rounded-lg hover:bg-base-200" + > + <:item :let={row}> + <.icon + name="hero-bars-3" + class="pk-drag-handle w-4 h-4 shrink-0 text-base-content/40 cursor-grab active:cursor-grabbing" + /> {row.label} - - - - - -
  • -
+ + <% end %> <%= if @main_country_suggestion != [] do %> @@ -228,11 +214,7 @@ end) )} - @@ -245,12 +227,16 @@ class="flex items-end gap-2 mt-4" >
- <.select + <%!-- The id carries the current count so the picker remounts + (and clears its own selection) after every add. --%> + <.live_component + module={PhoenixKitWeb.Live.Components.SearchableSelect} + id={"main-country-picker-#{length(@main_country_rows)}"} name="code" value="" options={@main_country_options} - prompt={gettext("Select country...")} label={gettext("Add a country")} + placeholder={gettext("Search countries...")} />
+
+ <%!-- Right column: Bank + Tax + Site URL stacked --%> +
<%!-- Bank Details --%>
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 6467fa50f..e6457c62a 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -12224,9 +12224,14 @@ msgstr "" msgid "Require the provider to confirm the email address" msgstr "" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:229 +#, elixir-autogen, elixir-format +msgid "Search countries..." +msgstr "" + #: lib/phoenix_kit_web/live/settings/organization.html.heex:60 #, elixir-autogen, elixir-format -msgid "Shown at the top of every country list, in this order" +msgid "Shown at the top of every country list. Drag to reorder." msgstr "" #: lib/phoenix_kit_web/live/settings/authorization.html.heex:354 diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 77d173c58..9c56951f1 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -12225,10 +12225,15 @@ msgstr "Nothing chosen yet — country lists are plain alphabetical." msgid "Require the provider to confirm the email address" msgstr "" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:229 +#, elixir-autogen, elixir-format +msgid "Search countries..." +msgstr "Search countries..." + #: lib/phoenix_kit_web/live/settings/organization.html.heex:60 #, elixir-autogen, elixir-format -msgid "Shown at the top of every country list, in this order" -msgstr "Shown at the top of every country list, in this order" +msgid "Shown at the top of every country list. Drag to reorder." +msgstr "Shown at the top of every country list. Drag to reorder." #: lib/phoenix_kit_web/live/settings/authorization.html.heex:354 #, elixir-autogen, elixir-format diff --git a/priv/gettext/et/LC_MESSAGES/default.po b/priv/gettext/et/LC_MESSAGES/default.po index 7c07d6e20..451a653d0 100644 --- a/priv/gettext/et/LC_MESSAGES/default.po +++ b/priv/gettext/et/LC_MESSAGES/default.po @@ -12242,10 +12242,15 @@ msgstr "Midagi pole veel valitud — riikide loendid on tähestikulises järjeko msgid "Require the provider to confirm the email address" msgstr "Nõua, et teenusepakkuja kinnitaks e-posti aadressi" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:229 +#, elixir-autogen, elixir-format +msgid "Search countries..." +msgstr "Otsi riike..." + #: lib/phoenix_kit_web/live/settings/organization.html.heex:60 #, elixir-autogen, elixir-format -msgid "Shown at the top of every country list, in this order" -msgstr "Kuvatakse iga riikide loendi alguses, selles järjekorras" +msgid "Shown at the top of every country list. Drag to reorder." +msgstr "Kuvatakse iga riikide loendi alguses. Järjekorra muutmiseks lohista." #: lib/phoenix_kit_web/live/settings/authorization.html.heex:354 #, elixir-autogen, elixir-format diff --git a/priv/gettext/ru/LC_MESSAGES/default.po b/priv/gettext/ru/LC_MESSAGES/default.po index 0f95b9c60..e5cc590cb 100644 --- a/priv/gettext/ru/LC_MESSAGES/default.po +++ b/priv/gettext/ru/LC_MESSAGES/default.po @@ -12265,10 +12265,15 @@ msgstr "Пока ничего не выбрано — списки стран и msgid "Require the provider to confirm the email address" msgstr "Требовать подтверждение адреса электронной почты от провайдера" +#: lib/phoenix_kit_web/live/settings/organization.html.heex:229 +#, elixir-autogen, elixir-format +msgid "Search countries..." +msgstr "Поиск стран..." + #: lib/phoenix_kit_web/live/settings/organization.html.heex:60 #, elixir-autogen, elixir-format -msgid "Shown at the top of every country list, in this order" -msgstr "Показываются в начале каждого списка стран, в этом порядке" +msgid "Shown at the top of every country list. Drag to reorder." +msgstr "Показываются в начале каждого списка стран. Порядок меняется перетаскиванием." #: lib/phoenix_kit_web/live/settings/authorization.html.heex:354 #, elixir-autogen, elixir-format From f6a1d2ca498245f851aa331ab3ddf8c2cbd13f8b Mon Sep 17 00:00:00 2001 From: Timujeen Date: Thu, 13 Aug 2026 12:52:36 +0300 Subject: [PATCH 8/8] Fix the forged-payload crashes and give the card a keyboard path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the main-countries card. Two handlers crashed the LiveView on a payload the browser would never send but a WebSocket client can: add_main_country mapped String.upcase/1 over a non-binary code, and reorder_main_countries called Enum.filter/2 on a non-list ordered_ids. Both are guarded now, and reorder and remove gained the catch-all clause add already had — the asymmetry was the bug. Data integrity was never at risk: a forged payload still cannot add an unknown country, drop an omitted one, or duplicate an entry. Dragging was the only way to reorder, which left the list unorderable for keyboard and screen-reader users, and unorderable at all wherever SortableJS cannot be fetched — the hook loads it from a CDN, so a strict CSP or an offline deploy silently disables it. The up/down buttons are back alongside the drag handle, drag stays primary. Their msgids were still in all four catalogues from the earlier iteration, so nothing new needed translating. The card also updated only itself: the Company card's own country select sat directly above it, unchanged until a reload, and a second admin session never learned about the edit at all. The write path now refreshes :countries and broadcasts on the channel this LiveView already subscribes to. It also stops writing when nothing changed — an empty Add, a remove of an absent code, a drag that ends where it started. Smaller: the suggestion went stale after switching the company country; suggested_priority/2 raised on a non-integer :limit, because Erlang term ordering makes "4" > 0; and its docstring promised Estonia Sweden when the data answers Latvia, Åland, Finland, Lithuania. The gap that let both crashers ship was that nothing tested the card at all. 19 integration tests now cover the four handlers against a real database — forged payloads, the reorder invariants, dedup, the move clamps, the no-op writes, the select refresh and the cross-session broadcast. --- lib/phoenix_kit/utils/country_data.ex | 17 +- .../live/settings/organization.ex | 89 +++++- .../live/settings/organization.html.heex | 22 ++ .../organization_main_countries_test.exs | 275 ++++++++++++++++++ test/phoenix_kit/utils/country_data_test.exs | 8 + 5 files changed, 396 insertions(+), 15 deletions(-) create mode 100644 test/integration/phoenix_kit_web/live/settings/organization_main_countries_test.exs diff --git a/lib/phoenix_kit/utils/country_data.ex b/lib/phoenix_kit/utils/country_data.ex index 26871be15..af4a40cfa 100644 --- a/lib/phoenix_kit/utils/country_data.ex +++ b/lib/phoenix_kit/utils/country_data.ex @@ -588,11 +588,11 @@ defmodule PhoenixKit.Utils.CountryData do Returns that country first, then its nearest neighbours by great-circle distance between country centroids — so a host in Estonia is offered - Latvia, Finland, Lithuania and Sweden, one in Germany gets Luxembourg, + Latvia, Åland, Finland and Lithuania, one in Germany gets Luxembourg, the Netherlands, Czechia and Belgium, and one in Singapore gets Malaysia, - Indonesia and Cambodia. The point is that it is derived from the host's - own data rather than from a constant baked in by whoever wrote the - library. + Indonesia, Cambodia and Brunei. The point is that it is derived from the + host's own data rather than from a constant baked in by whoever wrote + the library. This is a *suggestion* for the settings UI to offer, never applied on its own: nothing is pinned until an operator stores a list. The result can @@ -619,7 +619,7 @@ defmodule PhoenixKit.Utils.CountryData do def suggested_priority(country_code, opts \\ []) def suggested_priority(country_code, opts) when is_binary(country_code) and is_list(opts) do - limit = Keyword.get(opts, :limit, 4) + limit = opts |> Keyword.get(:limit, 4) |> normalize_limit() case get_country(country_code) do %{geo: %{latitude: lat, longitude: lon}} = origin when is_number(lat) and is_number(lon) -> @@ -635,6 +635,13 @@ defmodule PhoenixKit.Utils.CountryData do def suggested_priority(_, _), do: [] + # A non-integer `:limit` (nil, a string, a float — Erlang term ordering + # makes all of those `> 0`, so a guard alone won't stop them) falls back + # to the same default `suggested_priority/2` uses when `:limit` is + # omitted, rather than reaching `Enum.take/2` and raising. + defp normalize_limit(limit) when is_integer(limit), do: limit + defp normalize_limit(_), do: 4 + defp nearest_codes(origin, limit) when limit > 0 do BeamLabCountries.all() |> Enum.filter(&rankable_neighbour?(&1, origin)) diff --git a/lib/phoenix_kit_web/live/settings/organization.ex b/lib/phoenix_kit_web/live/settings/organization.ex index caa7b11dc..321d3e746 100644 --- a/lib/phoenix_kit_web/live/settings/organization.ex +++ b/lib/phoenix_kit_web/live/settings/organization.ex @@ -118,10 +118,16 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do end defp main_country_rows(codes) do - Enum.map(codes, fn code -> + last = length(codes) - 1 + + codes + |> Enum.with_index() + |> Enum.map(fn {code, index} -> %{ code: code, - label: CountryData.get_flag(code) <> " " <> CountryData.get_country_name(code) + label: CountryData.get_flag(code) <> " " <> CountryData.get_country_name(code), + first?: index == 0, + last?: index == last } end) end @@ -176,12 +182,20 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do if rate != 0 and rate != current_rate, do: rate end + # The main-countries suggestion is derived from the company country too, + # so it goes stale the same way the tax rate would if left alone: an + # unsaved country pick must not leave the previous country's neighbours + # on screen. + suggestion = + main_country_suggestion(country_code, MapSet.new(socket.assigns.main_countries)) + {:noreply, socket |> assign(:company_country, country_code) |> assign(:subdivision_label, get_subdivision_label(country_code)) |> assign(:eu_country, eu_country?(country_code)) - |> assign(:suggested_tax_rate, suggested_rate)} + |> assign(:suggested_tax_rate, suggested_rate) + |> assign(:main_country_suggestion, suggestion)} end def handle_event("save_company", params, socket) do @@ -207,7 +221,7 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do # The main-countries card writes on every action rather than behind a save # button: each click is already a deliberate edit, and there is no second # field whose validation could hold the list hostage. - def handle_event("add_main_country", %{"code" => code}, socket) do + def handle_event("add_main_country", %{"code" => code}, socket) when is_binary(code) do {:noreply, put_main_countries(socket, socket.assigns.main_countries ++ [code])} end @@ -218,10 +232,13 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do put_main_countries(socket, Enum.reject(socket.assigns.main_countries, &(&1 == code)))} end + def handle_event("remove_main_country", _params, socket), do: {:noreply, socket} + # SortableGrid pushes the full order on drop, so the list is taken as given # rather than diffed — but only codes that were already pinned are honoured, # so a forged payload can neither add a country nor drop one silently. - def handle_event("reorder_main_countries", %{"ordered_ids" => ordered}, socket) do + def handle_event("reorder_main_countries", %{"ordered_ids" => ordered}, socket) + when is_list(ordered) do current = socket.assigns.main_countries reordered = Enum.filter(ordered, &(&1 in current)) codes = reordered ++ (current -- reordered) @@ -229,6 +246,19 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do {:noreply, put_main_countries(socket, codes)} end + def handle_event("reorder_main_countries", _params, socket), do: {:noreply, socket} + + # Restored alongside drag: SortableJS is a CDN fetch that a strict CSP or + # an offline deploy can block, and dragging itself has no keyboard path — + # without these buttons a keyboard/screen-reader operator could not + # reorder at all. + def handle_event("move_main_country", %{"code" => code, "direction" => direction}, socket) + when is_binary(code) and direction in ["up", "down"] do + {:noreply, put_main_countries(socket, move(socket.assigns.main_countries, code, direction))} + end + + def handle_event("move_main_country", _params, socket), do: {:noreply, socket} + def handle_event("apply_main_country_suggestion", _params, socket) do suggested = Enum.map(socket.assigns.main_country_suggestion, & &1.code) @@ -442,15 +472,54 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do |> Enum.uniq() |> CountryData.known_country_codes() - case Settings.update_setting("country_select_priority", Enum.join(codes, ", ")) do - {:ok, _setting} -> - assign_main_countries(socket, codes, socket.assigns.company_country) + if codes == socket.assigns.main_countries do + # Nothing actually changed (e.g. "Add" with an empty selection, or + # removing/reordering into the same set) — don't write an identical + # value and don't broadcast a no-op. + socket + else + case Settings.update_setting("country_select_priority", Enum.join(codes, ", ")) do + {:ok, _setting} -> + # Broadcast to all admin sessions, same mechanism the other cards + # use. `handle_info` only calls `load_settings/1` — it never + # broadcasts itself — so the acting session's own bounce-back + # cannot loop; it just reloads with the value already written. + broadcast_settings_change(:main_countries_updated) + + socket + # The Company card's country <.select> is computed once in + # `load_settings/1`; without recomputing it here it keeps + # offering the pre-pin alphabetical order until the next reload. + |> assign(:countries, CountryData.countries_for_select()) + |> assign_main_countries(codes, socket.assigns.company_country) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Main countries could not be saved")) + end + end + end - {:error, _changeset} -> - put_flash(socket, :error, gettext("Main countries could not be saved")) + defp move(codes, code, direction) do + case Enum.find_index(codes, &(&1 == code)) do + nil -> codes + index -> swap(codes, index, target_index(index, direction, length(codes))) end end + defp target_index(index, "up", _length), do: max(index - 1, 0) + defp target_index(index, "down", length), do: min(index + 1, length - 1) + defp target_index(index, _direction, _length), do: index + + defp swap(codes, index, index), do: codes + + defp swap(codes, from, to) do + moved = Enum.at(codes, from) + + codes + |> List.delete_at(from) + |> List.insert_at(to, moved) + end + defp save_bank_details(params, iban, swift) do bank_details = %{ "bank_name" => (params["bank_name"] || "") |> String.trim(), diff --git a/lib/phoenix_kit_web/live/settings/organization.html.heex b/lib/phoenix_kit_web/live/settings/organization.html.heex index 8ad084543..ddcc280ed 100644 --- a/lib/phoenix_kit_web/live/settings/organization.html.heex +++ b/lib/phoenix_kit_web/live/settings/organization.html.heex @@ -191,6 +191,28 @@ class="pk-drag-handle w-4 h-4 shrink-0 text-base-content/40 cursor-grab active:cursor-grabbing" /> {row.label} + +