(ns frontend.components.select "Generic component for fuzzy searching items to select an item. See select-config to add a new use or select-type for this component. To use the new select-type, create an event that calls `select/dialog-select!` with the select-type. See the :graph/open command for a full example." (:require [clojure.string :as string] [frontend.config :as config] [frontend.context.i18n :refer [t]] [frontend.handler.common.developer :as dev-common-handler] [frontend.handler.repo :as repo-handler] [frontend.modules.shortcut.core :as shortcut] [frontend.search :as search] [frontend.state :as state] [frontend.ui :as ui] [frontend.util :as util] [frontend.util.text :as text-util] [logseq.shui.hooks :as hooks] [logseq.shui.ui :as shui] [reitit.frontend.easy :as rfe] [rum.core :as rum])) (rum/defc render-item < rum/reactive [result chosen? multiple-choices? *selected-choices] (let [value (if (map? result) (or (:label result) (:value result)) result) header (:header result) selected-choices (rum/react *selected-choices) row [:div.flex.flex-row.justify-between.w-full {:class (when chosen? "chosen") :on-pointer-down util/stop-propagation} [:div.flex.flex-row.items-center.gap-1 (when multiple-choices? (ui/checkbox {:checked (boolean (selected-choices (:value result))) :on-click (fn [e] (.preventDefault e)) :disabled (:disabled? result)})) value] (when (and (map? result) (:id result)) [:div.tip.flex [:code.opacity-20.bg-transparent (:id result)]])]] (if header [:div.flex.flex-col.gap-1 header row] row))) (rum/defc search-input [*input {:keys [prompt-key input-default-placeholder input-opts on-input]}] (let [[input set-input!] (hooks/use-state @*input)] (hooks/use-effect! (fn [] (reset! *input input) (when (fn? on-input) (on-input input))) [(hooks/use-debounced-value input 100)]) [:div.input-wrap {:style {:margin-bottom "-2px"}} [:input.cp__select-input.w-full (merge {:type "text" :class "!p-1.5" :placeholder (or input-default-placeholder (t prompt-key)) :auto-focus true :value input :on-change (fn [e] (let [v (util/evalue e)] (set-input! v)))} input-opts)]])) (rum/defcs ^:large-vars/cleanup-todo select "Provides a select dropdown powered by a fuzzy search. Takes the following options: * :items - Vec of things to select from. Assumes a vec of maps with :value key by default. Required option * :limit - Limit number of items to search. Default is 100 * :on-chosen - Optional fn to perform an action with chosen item * :extract-fn - Fn applied to each item during fuzzy search. Default is :value * :extract-chosen-fn - Fn applied to each item when choosing an item. Default is identity * :show-new-when-not-exact-match? - Boolean to allow new values be entered. Default is false * :exact-match-exclude-items - A set of strings that can't be added as a new item. Default is #{} * :transform-fn - Optional fn to transform search results given results and current input * :new-case-sensitive? - Boolean to allow new values to be case sensitive * :loading? - whether it's loading the items TODO: Describe more options" < rum/reactive shortcut/disable-all-shortcuts (rum/local "" ::input) (rum/local nil ::toggle) {:init (fn [state] (assoc state ::selected-choices (atom (set (:selected-choices (first (:rum/args state))))))) :will-remount (fn [_old-state new-state] (let [choices (set (:selected-choices (first (:rum/args new-state))))] (when (not= choices @(::selected-choices new-state)) (reset! (::selected-choices new-state) choices)) new-state)) :will-unmount (fn [state] (shui/dialog-close! :ls-select-modal) state)} [state {:keys [items limit on-chosen empty-placeholder grouped? prompt-key input-default-placeholder close-modal? extract-fn extract-chosen-fn host-opts on-input input-opts item-cp transform-fn tap-*input-val multiple-choices? on-apply new-case-sensitive? dropdown? show-new-when-not-exact-match? exact-match-exclude-items input-container initial-open? loading?] :or {limit 100 prompt-key :select/default-prompt empty-placeholder (fn [_t] [:div]) close-modal? true extract-fn :value extract-chosen-fn identity exact-match-exclude-items #{} initial-open? true}}] (let [input (::input state) *toggle (::toggle state) *selected-choices (::selected-choices state) selected-choices (rum/react *selected-choices) full-choices (cond->> (->> (concat (map (fn [v] {:value v}) selected-choices) items) (util/distinct-by-last-wins :value) (remove nil?)) (seq @input) (remove :clear?)) search-result' (->> (cond-> (search/fuzzy-search full-choices @input :limit limit :extract-fn extract-fn) (fn? transform-fn) (transform-fn @input)) (remove nil?)) exact-transform-fn (if new-case-sensitive? identity string/lower-case) exact-match? (contains? (set (map (comp exact-transform-fn str extract-fn) search-result')) (exact-transform-fn @input)) search-result' (if multiple-choices? (sort-by (fn [item] (not (contains? selected-choices (:value item)))) search-result') search-result') search-result (if (and show-new-when-not-exact-match? (not exact-match?) (not (string/blank? @input)) (not (exact-match-exclude-items @input))) (->> (cons (first search-result') (cons {:value @input :label (str "+ New option: " @input)} (rest search-result'))) (remove nil?)) search-result') input-opts' (if (fn? input-opts) (input-opts (empty? search-result)) input-opts) input-container (or input-container (search-input input {:prompt-key prompt-key :input-default-placeholder input-default-placeholder :input-opts input-opts' :on-input on-input})) results-container-f (fn [] (if loading? [:div.px-1.py-2 (ui/loading "Loading ...")] [:div {:class (when (seq search-result) "py-1")} [:div.item-results-wrap (ui/auto-complete search-result {:grouped? grouped? :item-render (or item-cp (fn [result chosen?] (render-item result chosen? multiple-choices? *selected-choices))) :class "cp__select-results" :on-chosen (fn [raw-chosen e] (reset! input "") (let [chosen (extract-chosen-fn raw-chosen)] (if multiple-choices? (if (selected-choices chosen) (do (swap! *selected-choices disj chosen) (when on-chosen (on-chosen chosen false @*selected-choices e))) (do (swap! *selected-choices conj chosen) (when on-chosen (on-chosen chosen true @*selected-choices e)))) (do (when (and close-modal? (not multiple-choices?)) (state/close-modal!)) (when on-chosen (on-chosen chosen true @*selected-choices e)))))) :empty-placeholder (empty-placeholder t)})] (when (and multiple-choices? (fn? on-apply)) [:div.p-4 (ui/button "Apply" {:small? true :on-pointer-down (fn [e] (util/stop e) (when @*toggle (@*toggle)) (on-apply selected-choices) (when close-modal? (state/close-modal!)))})])]))] (when (fn? tap-*input-val) (tap-*input-val input)) [:div.cp__select (merge {:class "cp__select-main"} host-opts) (if dropdown? (ui/dropdown (if (fn? input-container) input-container (fn [] input-container)) results-container-f {:initial-open? initial-open? :*toggle-fn *toggle}) [:<> (if (fn? input-container) (input-container) input-container) (results-container-f)])])) (defn select-config "Config that supports multiple types (uses) of this component. To add a new type, add a key with the value being a map with the following keys: * :items-fn - fn that returns items with a :value key that are used for the fuzzy search and selection. Items can have an optional :id and are displayed lightly for a given item. * :on-chosen - fn that is given item when it is chosen. * :empty-placeholder (optional) - fn that returns hiccup html to render if no matched graphs found. * :prompt-key (optional) - dictionary keyword that prompts when components is first open. Defaults to :select/default-prompt." [] {:graph-open {:items-fn (fn [] (->> (state/get-repos) (remove (fn [{:keys [url]}] (or (config/demo-graph? url) (= url (state/get-current-repo))))) (map (fn [{:keys [url]}] {:value (text-util/get-graph-name-from-path url) :id (config/get-repo-dir url) :graph url})))) :prompt-key :select.graph/prompt :on-chosen #(state/pub-event! [:graph/switch (:graph %)]) :empty-placeholder (fn [t] [:div.px-4.py-2 [:div.mb-2 (t :select.graph/empty-placeholder-description)] (ui/button (t :select.graph/add-graph) :href (rfe/href :graphs) :on-click state/close-modal!)])} :graph-remove {:items-fn (fn [] (->> (state/get-repos) (remove (fn [{:keys [url]}] (config/demo-graph? url))) (map (fn [{:keys [url] :as original-graph}] {:value (text-util/get-graph-name-from-path url) :id (config/get-repo-dir url) :graph url :original-graph original-graph})))) :on-chosen #(repo-handler/remove-repo! (:original-graph %))} :db-graph-replace {:items-fn (fn [] (let [current-repo (state/get-current-repo)] (->> (state/get-repos) (remove (fn [{:keys [url]}] ;; Can't replace current graph as ui wouldn't reload properly (or (= url current-repo) (not (config/db-based-graph? url))))) (map (fn [{:keys [url] :as original-graph}] {:value (text-util/get-graph-name-from-path url) :id (config/get-repo-dir url) :graph url :original-graph original-graph}))))) :on-chosen #(dev-common-handler/import-chosen-graph (:graph %))}}) (defn dialog-select! [select-type] (when select-type (let [select-type-config (get (select-config) select-type) on-chosen' (:on-chosen select-type-config)] (shui/dialog-open! #(select (-> select-type-config (assoc :on-chosen (fn [it] (on-chosen' it) (shui/dialog-close-all!))) (select-keys [:on-chosen :empty-placeholder :prompt-key]) (assoc :items ((:items-fn select-type-config))))) {:id :ls-select-modal :close-btn? false :align :top :content-props {:class "ls-dialog-select"}}))))