Files
logseq/src/main/frontend/components/select.cljs
Tienson Qin e259be0c51 feat: quick add (#11986)
* feat: quick add

* enhance(ux): auto open block for editing for quick add

* enhance(ux): show notification after quick add

* add migration for quick add page
2025-07-07 16:18:53 +08:00

291 lines
15 KiB
Clojure

(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)])
(hooks/use-effect!
(fn []
(when (= "" @*input)
(set-input! "")))
[(hooks/use-debounced-value @*input 100)])
[:div.input-wrap
[: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)]]))
;; TODO: rewrite using hooks
(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]
(let [choices (:selected-choices (first (:rum/args state)))]
(assoc state ::selected-choices (atom (set choices)))))
: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?
clear-input-on-chosen?]
: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
clear-input-on-chosen? true}}]
(let [*input (::input state)
*toggle (::toggle state)
*selected-choices (::selected-choices state)
selected-choices (rum/react *selected-choices)
full-choices (cond->>
(remove nil? items)
(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 (and multiple-choices? (not (string/blank? @*input)))
(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]
(when clear-input-on-chosen?
(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"}}))))