Add support for Multi Filter Search

This commit is contained in:
@s.roertgen 2024-12-02 08:41:30 +01:00
parent f1ba7eeab0
commit 903291146c
7 changed files with 374 additions and 179 deletions

View file

@ -125,3 +125,15 @@
:d
"M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z",
:clip-rule "evenodd"}]])
(defn filter-icon []
[:svg
{:xmlns "http://www.w3.org/2000/svg",
:width "16",
:height "16",
:fill "currentColor",
:class "bi bi-filter",
:viewBox "0 0 16 16"}
[:path
{:d
"M6 10.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m-2-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5m-2-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5"}]])

View file

@ -20,13 +20,13 @@
;; values is an array of events that referenced the concept
(let [_ (println "values " values)
pubkeys (->> values
(filter #(= "de" (:label-language %)))
(map :pubkey))
(filter #(= "de" (:label-language %)))
(map :pubkey))
profiles (re-frame/subscribe [::subs/profiles pubkeys])
user-language (re-frame/subscribe [::subs/user-language])
_ (js/console.log "group key and values" (clj->js values))
_ (js/console.log (clj->js @profiles))]
[:div {:on-click #(re-frame/dispatch [::events/handle-filter-search ["about.id" group-key]])
user-language (re-frame/subscribe [::subs/user-language])]
[:div {:on-click #(do
(re-frame/dispatch [::events/navigate [:search-view]])
(re-frame/dispatch [::events/handle-filter-search ["about.id" group-key]]))
:data-tip (str
(str/join
", "

View file

@ -0,0 +1,141 @@
(ns ied.components.skos-multiselect
(:require [re-frame.core :as re-frame]
[reagent.core :as reagent]
[clojure.string :as str]
[ied.components.icons :as icons]
[ied.subs :as subs]
[ied.events :as events]))
(defn concept-checkbox [concept field toggled disable-on-change]
[:input {:type "checkbox"
:checked (or toggled false)
:class "checkbox checkbox-warning"
:on-change (fn [] (when-not disable-on-change (re-frame/dispatch [::events/toggle-concept [concept field]])))}])
(defn highlight-match
"Wraps the matching part of the text in bold markers, preserving the original capitalization.
Arguments:
- `text`: The full text (string).
- `query`: The search term (string).
Returns:
- A Hiccup structure with the matching part bolded, or the original text if no match."
[text query]
(if (and text query)
(let [lower-text (str/lower-case text)
lower-query (str/lower-case query)
index (str/index-of lower-text lower-query)]
(if index
[:span
(subs text 0 index)
[:span {:class "font-bold"} (subs text index (+ index (count query)))]
(subs text (+ index (count query)))]
text))
text))
(defn concept-label-component
[[concept field search-input]]
(fn []
(let [toggled-concepts @(re-frame/subscribe [::subs/toggled-concepts])
toggled (some #(= (:id %) (:id concept)) toggled-concepts) ;; TODO could also be a subscription?
prefLabel (highlight-match (-> concept :prefLabel :de) search-input)]
[:li
(if-let [narrower (:narrower concept)]
[:details {:open false}
[:summary {:class (when toggled "bg-orange-400 text-black")}
[concept-checkbox concept field toggled]
prefLabel]
[:ul {:tabIndex "0"}
(for [child narrower]
^{:key (:id child)} [concept-label-component [child field]])]]
[:a {:class (when toggled "bg-orange-400 text-black")
:on-click (fn [] (re-frame/dispatch [::events/toggle-concept [concept field]]))}
[concept-checkbox concept field toggled true]
[:p
prefLabel]])])))
(defn get-nested
"Retrieve all values from nested paths in a node.
Arguments:
- `node`: The map representing a node.
- `paths`: A sequence of keyword paths to retrieve values from the node."
[node paths]
(map #(get-in node %) paths))
(defn match-node?
"Checks if the query matches any field value in the provided paths.
Arguments:
- `node`: The map representing a node.
- `query`: The search term (string).
- `paths`: A sequence of keyword paths to check in the node."
[node query paths]
(some #(str/includes? (str/lower-case (str %)) (str/lower-case query)) (get-nested node paths)))
(defn search-concepts
"Recursively search for matches in `hasTopConcept` and `narrower`.
Arguments:
- `cs`: The Concept Scheme
- `query`: The search term (string).
- `paths`: A sequence of keyword paths to check in each concept node.
Returns:
- A map with the same structure as `cs`, containing only matching concepts
and their parents."
[cs query paths]
(letfn [(traverse [concept]
(let [children (:narrower concept)
matched-children (keep traverse children)
node-matches (match-node? concept query paths)]
(when (or node-matches (seq matched-children))
(-> concept
(assoc :narrower (when (seq matched-children) matched-children))))))]
(if-let [filtered-concepts (keep traverse (:hasTopConcept cs))]
(assoc cs :hasTopConcept filtered-concepts)
nil)))
(comment
(def data {:id "http://w3id.org/openeduhub/vocabs/locationType/"
:type "ConceptScheme"
:title {:de "Typ eines Ortes, einer Einrichtung oder Organisation"
:en "Type of place, institution or organisation"}
:hasTopConcept [{:id "http://w3id.org/openeduhub/vocabs/locationType/bdc2d9f1-bef6-4bf4-844d-4efc1c99ddbe"
:prefLabel {:de "Lernort"}
:narrower [{:id "http://w3id.org/openeduhub/vocabs/locationType/0d08a1e9-09d4-4024-9b5c-7e4028e28ce5"
:prefLabel {:de "Kindergarten/-betreuung"}}
{:id "http://w3id.org/openeduhub/vocabs/locationType/1d0965c1-4a3e-4228-a600-bfa1d267e4d9"
:prefLabel {:de "Schule"}}
{:id "http://w3id.org/openeduhub/vocabs/locationType/729b6724-1dd6-476e-b871-44383ac11ef3"
:prefLabel {:de "berufsbildende Schule"}}]}
{:id "http://test.com/"
:prefLabel {:de "Test"}}]})
(def fields [[:prefLabel :de] [:prefLabel :en]])
(def query "Test")
(println (pr-str (search-concepts data query fields))))
(defn skos-multiselect-component
[[cs field field-title]]
(let [search-input (reagent/atom "")]
(fn []
(let [filtered-cs (search-concepts cs @search-input [[:prefLabel :de]])] ;; TODO needs to be otherwise configured for multi language stuff
[:div
{:class "dropdown w-full"
:key (:id cs)}
[:div
{:tabIndex "0"
:role "button"
:class "btn m-1 grow w-full"}
field-title]
[:div {:class "dropdown-content z-[1]"}
[:ul {:class "menu bg-base-200 rounded-box w-96 "
:tabIndex "0"}
[:label {:class "input flex items-center gap-2"}
[:input {:class "grow"
:placeholder "Suche..."
:type "text"
:on-change (fn [e]
(reset! search-input (-> e .-target .-value)))}]
[icons/looking-glass]]
(doall
(for [concept (:hasTopConcept filtered-cs)]
^{:key (str @search-input (:id concept))} [concept-label-component [concept field @search-input]]))]]]))))

View file

@ -375,7 +375,7 @@
_ (.log js/console (clj->js dispatch-events))]
{:fx [[:dispatch-n dispatch-events]]})))
(defn sanitize-subscription-id
(defn sanitize-subscription-id
"cuts the subscription string at 64 chars"
[s]
(str/join "" (take 64 s)))
@ -461,7 +461,6 @@
{:#d d-tags}]]
{::request-from-relay [sockets query]})))
(defn cleanup-list-name [s]
(-> s
(str/replace #"\s" "-")
@ -732,13 +731,26 @@
:on-success [::save-concept-scheme]
:on-failure [::failure]}}))
(re-frame/reg-event-fx
::fetch-missing-concept-schemes
(fn [{:keys [db]} [_ missing-uris]]
{:db db
:http-xhrio (map (fn [uri]
{:method :get
:uri (jsonize-uri uri)
:timeout 5000
:response-format (ajax/json-response-format {:keywords? true})
:on-success [::save-concept-scheme]
:on-failure [::failure]})
missing-uris)}))
(re-frame/reg-event-db
::toggle-concept
(fn [db [_ [concept field]]]
(update-in db [:md-form-resource field] (fn [coll]
(if (some #(= (:id concept) (:id %)) coll)
(filter (fn [e] (not= (:id e) (:id concept))) coll)
(conj coll (select-keys concept [:id :notation :prefLabel] )))))))
(conj coll (select-keys concept [:id :notation :prefLabel])))))))
(re-frame/reg-event-db
::handle-md-form-input
@ -783,17 +795,72 @@
(defn sanitize-filter-term [term]
(str/replace term #"[ ()]" " "))
(defn build-url-for-multi-filter-search
"Builds a Typesense search URL with the given base URI, filters, and search term.
Arguments:
- `base-uri` (string): The base URL of the Typesense API (e.g., http://localhost:8108).
- `filters` (map): A map of filters where keys are filter attributes (keywords)
and values are collections of maps, each containing an `:id` key.
Example:
{:about [{:id \"https://example.org/1\"} {:id \"https://example.org/2\"}]
:type [{:id \"https://example.org/3\"}]}
- `search-term` (string): The term to search for, defaults to `*` for wildcard searches.
Returns:
- (string): A fully constructed URL for querying the Typesense API."
[base-uri filters search-term]
(let [extract-ids (fn [filter-key]
(->> (get filters filter-key)
(map :id)
(str/join ",")))
filter-by (->> filters
(filter (fn [[_ v]] (seq v))) ; Exclude empty values
(map (fn [[filter-key _]]
(let [ids (extract-ids filter-key)]
(str (name filter-key) ".id" ":=[" ids "]"))))
(str/join "&&"))]
(str base-uri
"?q=" (or search-term "*")
"&query_by=name,about,description,creator"
(when (seq filter-by)
(str "&filter_by=" filter-by)))))
(re-frame/reg-event-fx
::handle-multi-filter-search
(fn [cofx [_ [filters search-term]]]
(let [_ (.log js/console (clj->js filters ) )
uri (build-url-for-multi-filter-search (str config/typesense-uri "search")
filters
search-term)
_ (.log js/console "uri" uri)]
{:http-xhrio {:method :get
:uri uri
:headers {"x-typesense-api-key" "xyz"}
:timeout 5000
:response-format (ajax/json-response-format {:keywords? true})
:on-success [::save-search-results]
:on-failure [::failure]}})))
(comment
"http://localhost:8108/collections/amb/documents//collections/amb/documents/search?q=chemie&query_by=name,about,description,creator"
"http://localhost:8108/collections/amb/documents/search?q=biologie&query_by=name,about,description,creator&filter_by=about.id:=[https://w3id.org/kim/hochschulfaechersystematik/n42]&&learningResourceType.id:=[]"
)
(re-frame/reg-event-fx
::handle-filter-search
(fn [cofx [_ [filter-attribute filter-term]]]
(fn [cofx [_ [filter-attribute filter-term search-term]]]
(let [uri (str config/typesense-uri
"search?q=*"
"search?q="
(or search-term "*")
"&query_by="
"name"
"&filter_by="
filter-attribute
":="
(sanitize-filter-term filter-term ))] ;; parantetheses seem to cause error when filtering
"name,about,description,creator"
(when filter-attribute
(str
"&filter_by="
filter-attribute
":="
(sanitize-filter-term filter-term))))] ;; parantetheses seem to cause error when filtering
{:http-xhrio {:method :get
:uri uri
:headers {"x-typesense-api-key" "xyz"}

View file

@ -259,16 +259,37 @@
(filter (fn [e] (some #(= (:pubkey e) %) following-pks)))
(filter #(= 1 (:kind %))))))
; "Args:
; - `schemes`: Array of strings identifying the concept schemes "
; (re-frame/reg-sub
; ::concept-schemes
; (fn [db [_ schemes]]
; (into {} (map (fn [cs]
; [cs (get-in db [:concept-schemes cs]) nil])
; schemes))))
(defn concept-schemes-sub
"Args:
- `schemes`: Array of strings identifying the concept schemes.
Returns a map of concept scheme identifiers to their values in the app-db."
[db [_ schemes]]
(into {} (map (fn [cs]
[cs (get-in db [:concept-schemes cs])])
schemes)))
(re-frame/reg-sub
::concept-schemes
(fn [db [_ schemes]]
(into {} (map (fn [cs]
[cs (get-in db [:concept-schemes cs]) nil])
schemes))))
::concept-schemes
concept-schemes-sub)
(comment
(get-in {:concept-schemes {"1" :yes "2" :no}} [:concept-schemes "3"] nil))
(re-frame/reg-sub
::active-filters
(fn [db]
(:md-form-resource db)))
(re-frame/reg-sub
::toggled-concepts
(fn [db]

View file

@ -5,7 +5,8 @@
[ied.subs :as subs]
[ied.events :as events]
[ied.components.icons :as icons]
[ied.routes :as routes]))
[ied.routes :as routes]
[ied.components.skos-multiselect :refer [skos-multiselect-component ]]))
(def md-scheme-map
{:amblight {:name {:title "Name"
@ -81,137 +82,7 @@
:about {:type :skos
:schemes ["https://w3id.org/kim/hochschulfaechersystematik/scheme"]}}})
(defn concept-checkbox [concept field toggled disable-on-change]
[:input {:type "checkbox"
:checked (or toggled false)
:class "checkbox checkbox-warning"
:on-change (fn [] (when-not disable-on-change (re-frame/dispatch [::events/toggle-concept [concept field]])))}])
(defn highlight-match
"Wraps the matching part of the text in bold markers, preserving the original capitalization.
Arguments:
- `text`: The full text (string).
- `query`: The search term (string).
Returns:
- A Hiccup structure with the matching part bolded, or the original text if no match."
[text query]
(if (and text query)
(let [lower-text (str/lower-case text)
lower-query (str/lower-case query)
index (str/index-of lower-text lower-query)]
(if index
[:span
(subs text 0 index)
[:span {:class "font-bold"} (subs text index (+ index (count query)))]
(subs text (+ index (count query)))]
text))
text))
(defn concept-label-component
[[concept field search-input]]
(fn []
(let [toggled-concepts @(re-frame/subscribe [::subs/toggled-concepts])
toggled (some #(= (:id %) (:id concept)) toggled-concepts) ;; TODO could also be a subscription?
prefLabel (highlight-match (-> concept :prefLabel :de) search-input)]
[:li
(if-let [narrower (:narrower concept)]
[:details {:open false}
[:summary {:class (when toggled "bg-orange-400 text-black")}
[concept-checkbox concept field toggled]
prefLabel]
[:ul {:tabIndex "0"}
(for [child narrower]
^{:key (:id child)} [concept-label-component [child field]])]]
[:a {:class (when toggled "bg-orange-400 text-black")
:on-click (fn [] (re-frame/dispatch [::events/toggle-concept [concept field]]))}
[concept-checkbox concept field toggled true]
[:p
prefLabel]])])))
(defn get-nested
"Retrieve all values from nested paths in a node.
Arguments:
- `node`: The map representing a node.
- `paths`: A sequence of keyword paths to retrieve values from the node."
[node paths]
(map #(get-in node %) paths))
(defn match-node?
"Checks if the query matches any field value in the provided paths.
Arguments:
- `node`: The map representing a node.
- `query`: The search term (string).
- `paths`: A sequence of keyword paths to check in the node."
[node query paths]
(some #(str/includes? (str/lower-case (str %)) (str/lower-case query)) (get-nested node paths)))
(defn search-concepts
"Recursively search for matches in `hasTopConcept` and `narrower`.
Arguments:
- `cs`: The Concept Scheme
- `query`: The search term (string).
- `paths`: A sequence of keyword paths to check in each concept node.
Returns:
- A map with the same structure as `cs`, containing only matching concepts
and their parents."
[cs query paths]
(letfn [(traverse [concept]
(let [children (:narrower concept)
matched-children (keep traverse children)
node-matches (match-node? concept query paths)]
(when (or node-matches (seq matched-children))
(-> concept
(assoc :narrower (when (seq matched-children) matched-children))))))]
(if-let [filtered-concepts (keep traverse (:hasTopConcept cs))]
(assoc cs :hasTopConcept filtered-concepts)
nil)))
(comment
(def data {:id "http://w3id.org/openeduhub/vocabs/locationType/"
:type "ConceptScheme"
:title {:de "Typ eines Ortes, einer Einrichtung oder Organisation"
:en "Type of place, institution or organisation"}
:hasTopConcept [{:id "http://w3id.org/openeduhub/vocabs/locationType/bdc2d9f1-bef6-4bf4-844d-4efc1c99ddbe"
:prefLabel {:de "Lernort"}
:narrower [{:id "http://w3id.org/openeduhub/vocabs/locationType/0d08a1e9-09d4-4024-9b5c-7e4028e28ce5"
:prefLabel {:de "Kindergarten/-betreuung"}}
{:id "http://w3id.org/openeduhub/vocabs/locationType/1d0965c1-4a3e-4228-a600-bfa1d267e4d9"
:prefLabel {:de "Schule"}}
{:id "http://w3id.org/openeduhub/vocabs/locationType/729b6724-1dd6-476e-b871-44383ac11ef3"
:prefLabel {:de "berufsbildende Schule"}}]}
{:id "http://test.com/"
:prefLabel {:de "Test"}}]})
(def fields [[:prefLabel :de] [:prefLabel :en]])
(def query "Test")
(println (pr-str (search-concepts data query fields))))
(defn skos-concept-scheme-multiselect-component
[[cs field field-title]]
(let [search-input (reagent/atom "")]
(fn []
(let [filtered-cs (search-concepts cs @search-input [[:prefLabel :de]])] ;; TODO needs to be otherwise configured for multi language stuff
[:div
{:class "dropdown w-full"
:key (:id cs)}
[:div
{:tabIndex "0"
:role "button"
:class "btn m-1 grow w-full"}
field-title]
[:div {:class "dropdown-content z-[1]"}
[:ul {:class "menu bg-base-200 rounded-box w-96 "
:tabIndex "0"}
[:label {:class "input flex items-center gap-2"}
[:input {:class "grow"
:placeholder "Suche..."
:type "text"
:on-change (fn [e]
(reset! search-input (-> e .-target .-value)))}]
[icons/looking-glass]]
(doall
(for [concept (:hasTopConcept filtered-cs)]
^{:key (str @search-input (:id concept))} [concept-label-component [concept field @search-input]]))]]]))))
(defn array-fields-component [selected-md-scheme field field-title]
(let [array-items-type (get-in selected-md-scheme [field :items :type])
@ -315,7 +186,7 @@
(doall
(for [cs (keys concept-schemes)]
^{:key cs} [skos-concept-scheme-multiselect-component [(get concept-schemes cs)
^{:key cs} [skos-multiselect-component [(get concept-schemes cs)
field
field-title]])))
(= :array field-type)

View file

@ -6,8 +6,9 @@
[reagent.core :as reagent]
[clojure.string :as str]
[ied.components.resource :as resource-components]
[ied.components.skos-multiselect :refer [skos-multiselect-component]]
[ied.components.icons :as icons]
[ied.nostr :as nostr]
[ied.components.resource :as resource-component]
[cljs.core :as c]))
;; TODO refactor this to also work with raw event data
@ -71,7 +72,7 @@
[:div {:class "flex flex-wrap"}
(doall
(for [[k v] keywords]
^{:key k} (resource-component/keywords-component k)))]
^{:key k} (resource-components/keywords-component k)))]
[:p {:class "line-clamp-3"} description]
@ -79,7 +80,7 @@
[:label {:class "btn "
:on-click #(re-frame/dispatch [::events/navigate [:naddr-view :naddr naddr]])}
"Details"]
[resource-component/add-to-list latest-event]]]
[resource-components/add-to-list latest-event]]]
[:div {:class "w-1/4"}
[:figure
@ -90,31 +91,113 @@
(nostr/get-image-from-metadata-event latest-event)
:alt ""}]]]])))
(defn search-view-panel []
; (defn filter-bar []
; (let [concept-schemes (re-frame/subscribe
; [::subs/concept-schemes ["https://w3id.org/kim/hochschulfaechersystematik/scheme"]])
; _ (doall (for [[cs-uri _] (filter (fn [[_ v]] (nil? v)) @concept-schemes)]
; (re-frame/dispatch [::events/skos-concept-scheme-from-uri cs-uri])))]
; (fn []
; [:div
; (doall
; (for [cs (keys @concept-schemes)]
; ^{:key cs} [skos-multiselect-component [(get @concept-schemes "https://w3id.org/kim/hochschulfaechersystematik/scheme")
; :about
; "Fachsystematik"]]))]))) ;; TODO reuse skos components
;; TODO make filters configurable with a map
(def filters
[{:scheme "https://w3id.org/kim/hochschulfaechersystematik/scheme"
:field :about
:title "Fachsystematik"}
{:scheme "https://w3id.org/kim/hcrt/scheme"
:field :learningResourceType
:title "Typ"}
])
(defn filter-bar []
(let [concept-schemes (re-frame/subscribe
[::subs/concept-schemes (map :scheme filters)])]
(re-frame/dispatch
[::events/fetch-missing-concept-schemes
(->> @concept-schemes
(filter (fn [[_ v]] (nil? v)))
(map first))])
;; TODO maybe put this in concept-scheme component?
(fn []
[:div
(doall
(for [filter filters]
^{:key (get-in @concept-schemes [(:scheme filter) :id])}
[skos-multiselect-component [(get @concept-schemes (:scheme filter))
(:field filter)
(:title filter)]]))
#_(doall
(for [cs (keys @concept-schemes)]
^{:key (get-in @concept-schemes [cs :id])}
[skos-multiselect-component [(get @concept-schemes "https://w3id.org/kim/hcrt/scheme")
:learningResourceType
"Typ"]]))])))
(defn has-any-values?
"checks if a map has any values"
[m]
(some
(fn [[_ v]]
(cond
(map? v) (has-any-values? v)
(coll? v) (seq (filter (complement nil?) v))
:else (not (nil? v))))
m))
(defn active-filters []
(let [active-filters (re-frame/subscribe [::subs/active-filters])]
(when (has-any-values? @active-filters)
[:div
(doall
(for [field (keys @active-filters)]
[:div {:class "badge badge-secondary"} "hello"]))])))
(defn search-bar []
(let [search-term (reagent/atom nil)
grouped-results (re-frame/subscribe [::subs/grouped-search-results])
_ (.log js/console (clj->js @grouped-results))]
filters (re-frame/subscribe [::subs/active-filters])
show-filter-bar (reagent/atom false)]
(fn []
[:div {:class "flex flex-col"}
[:div {:class "flex"} ;;TODO fix layout
[:form {:class "w-3/4"
:on-submit (fn [e]
(.preventDefault e)
(re-frame/dispatch [::events/handle-multi-filter-search [@filters @search-term]]))}
[:label
{:class "input input-bordered flex items-center gap-2 w-1/2 mx-auto"}
[:input {:type "search"
:class "grow"
:placeholder "Search"
:on-change (fn [e] (reset! search-term (-> e .-target .-value)))}]
[:svg
{:xmlns "http://www.w3.org/2000/svg",
:viewBox "0 0 16 16",
:fill "currentColor",
:class "h-4 w-4 opacity-70"}
[:path
{:fill-rule "evenodd",
:d
"M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z",
:clip-rule "evenodd"}]]]]
[:button {:on-click #(reset! show-filter-bar (not @show-filter-bar))
:class "btn"} [icons/filter-icon] "Filter"]]
[active-filters]
(when @show-filter-bar
[filter-bar])])))
(defn search-view-panel []
(let [grouped-results (re-frame/subscribe [::subs/grouped-search-results])]
[:div {:class ""}
[:form {:on-submit (fn [e]
(.preventDefault e)
(.log js/console @search-term)
(re-frame/dispatch [::events/handle-search @search-term]))}
[:label
{:class "input input-bordered flex items-center gap-2 w-1/2 mx-auto"}
[:input {:type "search"
:class "grow"
:placeholder "Search"
:on-change (fn [e] (reset! search-term (-> e .-target .-value)))}]
[:svg
{:xmlns "http://www.w3.org/2000/svg",
:viewBox "0 0 16 16",
:fill "currentColor",
:class "h-4 w-4 opacity-70"}
[:path
{:fill-rule "evenodd",
:d
"M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z",
:clip-rule "evenodd"}]]]]
[search-bar]
(when (not-empty @grouped-results)
[:div {:class "divide-y divide-slate-700 flex flex-col gap-2"}
(doall