diff --git a/test/website/command_palette_test.exs b/test/website/command_palette_test.exs index 627ac5f..302bd4a 100644 --- a/test/website/command_palette_test.exs +++ b/test/website/command_palette_test.exs @@ -33,4 +33,118 @@ defmodule Shadix.Website.CommandPaletteTest do Enum.find_index(entries, &(&1.type == :component)) end end + + describe "score/2" do + setup do + button = %{ + type: :component, + label: "Button", + path: "/components/button", + sub: "Forms", + slug: "button" + } + + dropdown = %{ + type: :component, + label: "Dropdown Menu", + path: "/components/dropdown_menu", + sub: "Overlays", + slug: "dropdown_menu" + } + + {:ok, button: button, dropdown: dropdown} + end + + test "exact label match scores highest (4)", %{button: button} do + assert CommandPalette.score(button, "button") == 4 + assert CommandPalette.score(button, "Button") == 4 + end + + test "label prefix match scores 3", %{button: button} do + assert CommandPalette.score(button, "but") == 3 + end + + test "slug exact match also scores 4 (components are searched by slug too)", %{ + dropdown: dropdown + } do + assert CommandPalette.score(dropdown, "dropdown_menu") == 4 + end + + test "word-boundary match scores 2", %{dropdown: dropdown} do + # "menu" is a token of "Dropdown Menu"; not a label prefix, not exact. + assert CommandPalette.score(dropdown, "menu") == 2 + # "dropdown" is the first token -> word-boundary, and also a label prefix, + # but prefix (3) wins over word-boundary (2). + assert CommandPalette.score(dropdown, "dropdown") == 3 + end + + test "plain substring match scores 1", %{button: button} do + assert CommandPalette.score(button, "tto") == 1 + end + + test "no match returns nil", %{button: button} do + assert CommandPalette.score(button, "zzz") == nil + end + end + + describe "search/1" do + test "empty query returns all entries, ranked, with both sections (pages first)" do + result = CommandPalette.search("") + + assert result.sections == [:pages, :components] + # Introduction is the only page. + assert Enum.map(result.pages, & &1.label) == ["Introduction"] + # All components present (empty query scores everything 4, alphabetical tiebreak). + assert length(result.components) == length(Shadix.Website.Components.Catalog.all()) + # Alphabetical tiebreak on equal scores: "Accordion" before "Alert". + assert Enum.find_index(result.components, &(&1.slug == "accordion")) < + Enum.find_index(result.components, &(&1.slug == "alert")) + end + + test "ranks a prefix match above a plain substring match within a section" do + # "bu" is a prefix of "Button" (3); "b" alone is a substring of many, but + # narrowing to "bu" should put Button first among components starting with b. + result = CommandPalette.search("bu") + components = result.components + assert hd(components).slug == "button" + end + + test "an exact match ranks above a prefix match" do + # "alert" exactly matches the Alert component's label; "alert dialog" also + # contains "alert" as a prefix of its first token. Exact (4) beats prefix (3). + result = CommandPalette.search("alert") + labels = Enum.map(result.components, & &1.label) + assert hd(labels) == "Alert" + end + + test "sections are ordered by their top hit's score, pages wins ties" do + # "introduction" exactly matches the Introduction page (4). No component + # matches, so the Pages section is present and Components is omitted. + result = CommandPalette.search("introduction") + assert result.sections == [:pages] + assert result.components == [] + + # "button" exactly matches the Button component (4); no page matches, so + # only Components is present. + result = CommandPalette.search("button") + assert result.sections == [:components] + assert result.pages == [] + end + + test "when both sections match, the higher-scoring section comes first" do + # "i" prefix-matches "Introduction" (page, 3) and substring-matches many + # components (e.g. "Dialog" -> 1, "Pagination" -> 1). Pages' top (3) beats + # Components' top (1), so Pages comes first. + result = CommandPalette.search("i") + assert result.sections == [:pages, :components] + assert hd(result.pages).label == "Introduction" + end + + test "a query matching nothing returns empty sections and no sections list" do + result = CommandPalette.search("zzzzzzz") + assert result.pages == [] + assert result.components == [] + assert result.sections == [] + end + end end diff --git a/website/command_palette.ex b/website/command_palette.ex index ce52560..6f03ede 100644 --- a/website/command_palette.ex +++ b/website/command_palette.ex @@ -46,4 +46,76 @@ defmodule Shadix.Website.CommandPalette do |> String.split("_") |> Enum.map_join(" ", &String.capitalize/1) end + + @doc """ + Scores an entry against a query. Higher is better; `nil` means no match. + + Tiers (best first): exact label/slug equality (4), prefix match (3), + word-boundary match (2), plain substring (1), no match (`nil`). Matching is + case-insensitive across the entry's label and (for components) its slug. + The best applicable tier wins. + """ + def score(entry, query) do + q = String.downcase(query) + label = String.downcase(entry.label) + slug = entry[:slug] && String.downcase(entry.slug) + + cond do + q == "" -> 4 + q == label or q == slug -> 4 + String.starts_with?(label, q) or (slug && String.starts_with?(slug, q)) -> 3 + word_boundary?(label, q) or (slug && word_boundary?(slug, q)) -> 2 + String.contains?(label, q) or (slug && String.contains?(slug, q)) -> 1 + true -> nil + end + end + + # True if `q` is a prefix of any token of `haystack` (tokens split on `_` + # and space). Used for the word-boundary tier. + defp word_boundary?(haystack, q) do + haystack + |> String.split(~r/[_\s]+/, trim: true) + |> Enum.any?(&String.starts_with?(&1, q)) + end + + @doc """ + Filters and sections the entries for `query`. + + Returns `%{pages: [...], components: [...], sections: [...]}` where each list + is ranked (highest score first, alphabetical by label as tiebreak) and + `sections` lists the non-empty sections in render order: the section whose top + hit scores higher comes first, pages winning ties. + """ + def search(query) do + scored = + entries() + |> Enum.map(fn entry -> {score(entry, query), entry} end) + |> Enum.reject(fn {s, _} -> is_nil(s) end) + |> Enum.sort_by(fn {score, entry} -> {-score, entry.label} end) + + pages = Enum.map(Enum.filter(scored, fn {_, e} -> e.type == :page end), &elem(&1, 1)) + + components = + Enum.map(Enum.filter(scored, fn {_, e} -> e.type == :component end), &elem(&1, 1)) + + sections = + case {pages, components} do + {[], []} -> + [] + + {[], _} -> + [:components] + + {_, []} -> + [:pages] + + {[page_top | _], [comp_top | _]} -> + page_score = score(page_top, query) + comp_score = score(comp_top, query) + + if comp_score > page_score, do: [:components, :pages], else: [:pages, :components] + end + + %{pages: pages, components: components, sections: sections} + end end