Implement full-text search for members closes #11 #163
|
|
@ -166,6 +166,11 @@ defmodule Mv.Membership.Member do
|
||||||
attribute :postal_code, :string do
|
attribute :postal_code, :string do
|
||||||
allow_nil? true
|
allow_nil? true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attribute :search_vector, AshPostgres.Tsvector,
|
||||||
|
writable?: false,
|
||||||
|
public?: false,
|
||||||
|
select_by_default?: false
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
|
|
|
||||||
61
lib/mv_web/live/components/search_bar_component.ex
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
defmodule MvWeb.Components.SearchBarComponent do
|
||||||
|
@moduledoc """
|
||||||
|
Provides the SearchBar Live-Component.
|
||||||
|
|
||||||
|
- uses the DaisyUI search input field
|
||||||
|
- sends search_changed event to parent live view with a query
|
||||||
|
"""
|
||||||
|
use MvWeb, :live_component
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def update(_assigns, socket) do
|
||||||
|
simon marked this conversation as resolved
Outdated
simon
commented
Outdated
```
mix test
Compiling 5 files (.ex)
warning: variable "assigns" is unused (if the variable is not meant to be used, prefix it with an underscore)
│
11 │ def update(assigns, socket) do
│ ~~~~~~~
│
└─ lib/mv_web/live/components/search_bar_component.ex:11:14: MvWeb.Components.SearchBarComponent.update/2
```
|
|||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign_new(:query, fn -> "" end)
|
||||||
|
|> assign_new(:placeholder, fn -> gettext("Search...") end)
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<form phx-change="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
|
||||||
|
<label class="input">
|
||||||
|
<svg
|
||||||
|
class="h-[1em] opacity-50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="2.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<path d="m21 21-4.3-4.3"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder={@placeholder}
|
||||||
|
value={@query}
|
||||||
|
name="query"
|
||||||
|
phx-debounce="300"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
# Function to handle the search
|
||||||
|
def handle_event("search", %{"query" => q}, socket) do
|
||||||
|
# Forward a high level message to the parent
|
||||||
|
send(self(), {:search_changed, q})
|
||||||
|
{:noreply, assign(socket, :query, q)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
defmodule MvWeb.MemberLive.Index do
|
defmodule MvWeb.MemberLive.Index do
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
import Ash.Expr
|
||||||
|
import Ash.Query
|
||||||
import MvWeb.TableComponents
|
import MvWeb.TableComponents
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -10,12 +12,39 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Members"))
|
|> assign(:page_title, gettext("Members"))
|
||||||
|
|> assign(:query, "")
|
||||||
|> assign(:sort_field, :first_name)
|
|> assign(:sort_field, :first_name)
|
||||||
|> assign(:sort_order, :asc)
|
|> assign(:sort_order, :asc)
|
||||||
|> assign(:members, sorted)
|
|> assign(:members, sorted)
|
||||||
|> assign(:selected_members, [])}
|
|> assign(:selected_members, [])}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Receive messages from any toolbar component
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
|
# Function to handle search
|
||||||
|
@impl true
|
||||||
|
def handle_info({:search_changed, q}, socket) do
|
||||||
|
members =
|
||||||
|
if String.trim(q) == "" do
|
||||||
|
Ash.read!(Mv.Membership.Member)
|
||||||
|
carla marked this conversation as resolved
Outdated
rafael
commented
It seems like this returns an empty list when the search string is empty. Maybe we need a special case for that, so an empty search field shows all members instead? It seems like this returns an empty list when the search string is empty. Maybe we need a special case for that, so an empty search field shows all members instead?
|
|||||||
|
else
|
||||||
|
Mv.Membership.Member
|
||||||
|
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q)))
|
||||||
|
carla marked this conversation as resolved
Outdated
rafael
commented
Let's remove this before merging. Let's remove this before merging.
|
|||||||
|
|> Ash.read!()
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:query, q)
|
||||||
|
|> assign(:members, members)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Handle Events
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
member = Ash.get!(Mv.Membership.Member, id)
|
member = Ash.get!(Mv.Membership.Member, id)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SearchBarComponent}
|
||||||
|
id="search-bar"
|
||||||
|
query={@query}
|
||||||
|
placeholder={gettext("Search...")}
|
||||||
|
/>
|
||||||
|
|
||||||
<.table
|
<.table
|
||||||
id="members"
|
id="members"
|
||||||
rows={@members}
|
rows={@members}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddSearchVectorToMembers do
|
||||||
|
@moduledoc """
|
||||||
|
Updates resources based on their most recent snapshots.
|
||||||
|
|
||||||
|
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:members) do
|
||||||
|
add :search_vector, :tsvector
|
||||||
|
end
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE INDEX members_search_vector_idx
|
||||||
|
ON members
|
||||||
|
USING GIN (search_vector)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Eigene Trigger-Funktion mit Gewichtung
|
||||||
|
execute("""
|
||||||
|
CREATE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.search_vector :=
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.birth_date::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE TRIGGER update_search_vector
|
||||||
|
BEFORE INSERT OR UPDATE ON members
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION members_search_vector_trigger()
|
||||||
|
carla marked this conversation as resolved
rafael
commented
This constructs the search vector for all new future members, but can we run it in this migration for all existing members as well? This constructs the search vector for all new future members, but can we run it in this migration for all existing members as well?
carla
commented
But is this a use case? But is this a use case?
I would rather put that in an own low priority issue due to priorisation if that's fine?
rafael
commented
Yeah, I think if we all just reset our database we should be fine as well. No need to create an issue. I was just confused that I could not find all members in my database :D Yeah, I think if we all just reset our database we should be fine as well. No need to create an issue. I was just confused that I could not find all members in my database :D
|
|||||||
|
""")
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute("DROP TRIGGER IF EXISTS update_search_vector ON members")
|
||||||
|
execute("DROP FUNCTION IF EXISTS members_search_vector_trigger()")
|
||||||
|
execute("DROP INDEX IF EXISTS members_search_vector_idx")
|
||||||
|
|
||||||
|
alter table(:members) do
|
||||||
|
remove :search_vector
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
199
priv/resource_snapshots/repo/members/20250912085235.json
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"uuid_generate_v7()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "first_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "last_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "email",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "birth_date",
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "paid",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "phone_number",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "join_date",
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "exit_date",
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "notes",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "city",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "street",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "house_number",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "postal_code",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "search_vectors",
|
||||||
|
"type": "tsvector"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "3B162FD69B92BF8258DB56BA0CBB6108FBE996B1F7231C5F2D9EC53D956EFC75",
|
||||||
|
"identities": [],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "members"
|
||||||
|
}
|
||||||
33
test/mv_web/components/search_bar_component_test.exs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
defmodule MvWeb.Components.SearchBarComponentTest do
|
||||||
|
use MvWeb.ConnCase, async: true
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
describe "SearchBarComponent" do
|
||||||
|
test "renders with placeholder", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "input[placeholder='Search...']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates query when user types", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# simulate search input and check that other members are not listed
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("form[role=search]")
|
||||||
|
|> render_change(%{"query" => "Friedrich"})
|
||||||
|
|
||||||
|
refute html =~ "Greta"
|
||||||
|
simon marked this conversation as resolved
Outdated
simon
commented
Doesn't this just assert that "Friedrich" should occur anywhere in the html? Doesn't this just assert that "Friedrich" should occur anywhere in the html?
If I'm not wrong, this would also apply to the exact same search string "Friedrich" which is rendered in html, too, and therefore it doesn't matter for the test which result is presented?
carla
commented
Yes, you are right :) Changed that to checking if members are not present instead Yes, you are right :) Changed that to checking if members are not present instead
|
|||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("form[role=search]")
|
||||||
|
|> render_change(%{"query" => "Greta"})
|
||||||
|
|
||||||
|
refute html =~ "Friedrich"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -73,4 +73,16 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
assert has_element?(index_view, "#flash-group", "Member create successfully")
|
assert has_element?(index_view, "#flash-group", "Member create successfully")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
send(view.pid, {:search_changed, "Friedrich"})
|
||||||
|
|
||||||
|
state = :sys.get_state(view.pid)
|
||||||
|
carla marked this conversation as resolved
simon
commented
a bit picky, but this comment should be in english :) a bit picky, but this comment should be in english :)
|
|||||||
|
|
||||||
|
assert state.socket.assigns.query == "Friedrich"
|
||||||
|
assert is_list(state.socket.assigns.members)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||