@rpcbase/client 0.194.0 → 0.196.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auth/getUid.js +1 -1
- package/index.js +1 -2
- package/package.json +6 -4
- package/rts/getUseQuery/index.js +10 -6
- package/rts/rts.js +1 -2
- package/rts/store/get_collection.js +7 -3
- package/ui/Search/SearchHistory/index.js +41 -0
- package/ui/Search/SearchHistory/search-history.scss +9 -0
- package/ui/Search/SearchHistory/useSearchHistory.tsx +56 -0
- package/ui/Search/SearchResults/getActions.js +6 -0
- package/ui/Search/SearchResults/index.js +188 -0
- package/ui/Search/SearchResults/useCombinedResultsActions.js +59 -0
- package/ui/Search/index.tsx +301 -0
- package/ui/Search/search.scss +0 -0
- package/ui/View/index.web.js +1 -2
- package/ui/helpers/useActiveListItemIndex/index.tsx +42 -0
- package/ui/helpers/useScrollSelectorIntoView/index.tsx +14 -0
package/auth/getUid.js
CHANGED
|
@@ -3,7 +3,7 @@ import {Platform} from "react-native"
|
|
|
3
3
|
|
|
4
4
|
import {privateGetUid} from "./index"
|
|
5
5
|
|
|
6
|
-
const getUid = Platform.OS === "web" ? privateGetUid : () => {
|
|
6
|
+
export const getUid = Platform.OS === "web" ? privateGetUid : () => {
|
|
7
7
|
console.warn("native should not call getUid, use userId from AuthContext")
|
|
8
8
|
return null
|
|
9
9
|
}
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rpcbase/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.196.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"build": "../../node_modules/.bin/wireit",
|
|
6
6
|
"test": "../../node_modules/.bin/wireit"
|
|
@@ -91,15 +91,17 @@
|
|
|
91
91
|
"figma-squircle": "0.3.1",
|
|
92
92
|
"firebase": "10.12.3",
|
|
93
93
|
"framer-motion": "11.3.22",
|
|
94
|
+
"fuzzysort": "3.0.2",
|
|
94
95
|
"i18next": "23.12.2",
|
|
95
96
|
"i18next-chained-backend": "4.6.2",
|
|
96
97
|
"i18next-resources-to-backend": "1.2.1",
|
|
97
98
|
"js-tree": "2.0.2",
|
|
98
99
|
"lz-string": "1.5.0",
|
|
99
100
|
"posthog-js": "1.147.0",
|
|
100
|
-
"pouchdb-adapter-indexeddb": "
|
|
101
|
-
"pouchdb-core": "
|
|
102
|
-
"pouchdb-find": "
|
|
101
|
+
"pouchdb-adapter-indexeddb": "9.0.0",
|
|
102
|
+
"pouchdb-core": "9.0.0",
|
|
103
|
+
"pouchdb-find": "9.0.0",
|
|
104
|
+
"react-bootstrap-typeahead": "6.3.2",
|
|
103
105
|
"react-i18next": "15.0.1",
|
|
104
106
|
"rrweb": "1.1.3",
|
|
105
107
|
"socket.io-client": "4.7.5",
|
package/rts/getUseQuery/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
/* @flow */
|
|
2
|
-
import assert from "assert"
|
|
3
2
|
import {Platform} from "react-native"
|
|
4
3
|
import {useCallback, useEffect, useState, useMemo, useId, useRef} from "react"
|
|
5
4
|
import debug from "debug"
|
|
@@ -25,9 +24,10 @@ const getUseQuery = (register_query) => (
|
|
|
25
24
|
) => {
|
|
26
25
|
const id = useId()
|
|
27
26
|
|
|
27
|
+
// TODO: should the uid be gone here ? it's part of the auth layer, not this here
|
|
28
28
|
// TODO: retrieve this from future AuthContext in client
|
|
29
29
|
const uid = useMemo(() => {
|
|
30
|
-
// TODO: why is there a options.userId here
|
|
30
|
+
// TODO: why is there a options.userId here?? (it was for mobile we need to unify this)
|
|
31
31
|
const _uid = Platform.OS === "web" ? getUid() : options.userId
|
|
32
32
|
return _uid
|
|
33
33
|
}, [])
|
|
@@ -75,12 +75,16 @@ const getUseQuery = (register_query) => (
|
|
|
75
75
|
// set data in a ref so that it doesn't force re-rendering ie: unsubscribe / resubscribe
|
|
76
76
|
dataRef.current = newData
|
|
77
77
|
|
|
78
|
+
// useStorage currently used as a fast local cache, indexedDB
|
|
78
79
|
// we only save network queries
|
|
80
|
+
// TODO: use localstorage in react native and pouchdb everywhere
|
|
79
81
|
if (useStorage && context.source === "network") {
|
|
80
82
|
if (Platform.OS === "web") {
|
|
81
83
|
localStorage.setItem(storageKey, LZString.compressToUTF16(JSON.stringify(newData)))
|
|
82
84
|
} else {
|
|
85
|
+
// TODO: this is done in pouchDB nOW ?????
|
|
83
86
|
// TODO: RN MMKV
|
|
87
|
+
console.log("mmkv NYI")
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
90
|
|
|
@@ -147,7 +151,7 @@ const getUseQuery = (register_query) => (
|
|
|
147
151
|
newData = _omit(queryResult, "__txn_id")
|
|
148
152
|
}
|
|
149
153
|
|
|
150
|
-
//
|
|
154
|
+
// we return once in any case
|
|
151
155
|
if (!hasFirstReply.current) {
|
|
152
156
|
hasFirstReply.current = true
|
|
153
157
|
|
|
@@ -168,7 +172,7 @@ const getUseQuery = (register_query) => (
|
|
|
168
172
|
return
|
|
169
173
|
}
|
|
170
174
|
|
|
171
|
-
if (isEqual(data, newData) && !isEqualValues(data, newData)
|
|
175
|
+
if (__DEV__ && isEqual(data, newData) && !isEqualValues(data, newData)) {
|
|
172
176
|
alert("EQUALITY MISMATCH THIS SHOULD NOT HAPPEN!", data, newData)
|
|
173
177
|
}
|
|
174
178
|
|
|
@@ -191,11 +195,11 @@ const getUseQuery = (register_query) => (
|
|
|
191
195
|
|
|
192
196
|
|
|
193
197
|
const loadNextPage = useCallback(() => {
|
|
194
|
-
console.log("NYI
|
|
198
|
+
console.log("loadNextPage NYI")
|
|
195
199
|
}, [])
|
|
196
200
|
|
|
197
201
|
|
|
198
|
-
const result = useMemo(() => ({data, source, error, loading}), [data, source, error, loading])
|
|
202
|
+
const result = useMemo(() => ({data, source, error, loading, loadNextPage}), [data, source, error, loading, loadNextPage])
|
|
199
203
|
|
|
200
204
|
// TODO:
|
|
201
205
|
// if (Array.isArray(result.data) && !result.source) {
|
package/rts/rts.js
CHANGED
|
@@ -10,6 +10,7 @@ import getBaseUrl from "../getBaseUrl"
|
|
|
10
10
|
|
|
11
11
|
import store from "./store"
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
const log = debug("rb:socket")
|
|
14
15
|
|
|
15
16
|
const TENANT_ID_HEADER = "rb-tenant-id"
|
|
@@ -197,8 +198,6 @@ export const register_query = (model_name, query, _options, _callback) => {
|
|
|
197
198
|
}
|
|
198
199
|
_queries_store[model_name][query]
|
|
199
200
|
|
|
200
|
-
console.log("QUERY OPTIONSSS", options)
|
|
201
|
-
|
|
202
201
|
// TODO: why both run and register query here ? the run_query should come straight from register ?
|
|
203
202
|
_socket.emit("run_query", {model_name, query, query_key, options})
|
|
204
203
|
_socket.emit("register_query", {model_name, query, query_key, options})
|
|
@@ -6,22 +6,26 @@ import FindPlugin from "pouchdb-find"
|
|
|
6
6
|
|
|
7
7
|
import {RB_TENANT_ID, RB_APP_NAME} from "env"
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
const log = debug("rb:rts:store")
|
|
10
11
|
|
|
11
|
-
let prefix = `rb
|
|
12
|
+
let prefix = `rb/`
|
|
12
13
|
|
|
13
14
|
if (RB_APP_NAME) prefix += `${RB_APP_NAME}/`
|
|
14
15
|
|
|
16
|
+
prefix += `${RB_TENANT_ID}/`
|
|
17
|
+
|
|
18
|
+
log("prefix:", prefix)
|
|
19
|
+
|
|
15
20
|
PouchDB.prefix = prefix
|
|
16
21
|
|
|
17
22
|
PouchDB.plugin(IndexedDBAdapter)
|
|
18
23
|
PouchDB.plugin(FindPlugin)
|
|
19
24
|
|
|
20
|
-
|
|
21
25
|
const _cols_store = Object.create(null)
|
|
22
26
|
|
|
23
|
-
|
|
24
27
|
const get_collection = (col_name) => {
|
|
28
|
+
|
|
25
29
|
if (!col_name) {
|
|
26
30
|
console.warn("supplied invalid / empty collection name to rts")
|
|
27
31
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {formatDistance} from "date-fns/formatDistance"
|
|
2
|
+
|
|
3
|
+
import {useSearchHistory} from "./useSearchHistory"
|
|
4
|
+
|
|
5
|
+
import "./search-history.scss"
|
|
6
|
+
|
|
7
|
+
// TODO: refactor out of rbt to be able to control the input
|
|
8
|
+
// TODO: search history must support keyboard navigation
|
|
9
|
+
|
|
10
|
+
// we pass the searchHistory prop from the top component
|
|
11
|
+
// because we are unmounted when results aren't shown,
|
|
12
|
+
// but we want history to be loaded anyway
|
|
13
|
+
export const SearchHistory = ({setSearchInput}) => {
|
|
14
|
+
const searchHistory = useSearchHistory()
|
|
15
|
+
|
|
16
|
+
const getClickHandler = (item) => (e) => {
|
|
17
|
+
e.preventDefault()
|
|
18
|
+
setSearchInput(item.query)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<div className="search-history-header ps-3 pb-2 mt-2 fw-bold border-bottom">
|
|
24
|
+
Previous Queries
|
|
25
|
+
</div>
|
|
26
|
+
{searchHistory.map((item, i) => (
|
|
27
|
+
<div
|
|
28
|
+
role="button"
|
|
29
|
+
key={`search-history-${i}`}
|
|
30
|
+
className="list-group-item list-group-item-action"
|
|
31
|
+
onClick={getClickHandler(item)}
|
|
32
|
+
>
|
|
33
|
+
<span>{item.query}</span>
|
|
34
|
+
<small className="text-secondary ms-2">
|
|
35
|
+
{formatDistance(item.timestamp, new Date(), {addSuffix: true})}
|
|
36
|
+
</small>
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
import {useEffect, useState} from "react"
|
|
3
|
+
|
|
4
|
+
import {useQuery} from "@rpcbase/client/rts"
|
|
5
|
+
import {getUid} from "@rpcbase/client/auth/getUid"
|
|
6
|
+
|
|
7
|
+
import stripDiacritics from "react-bootstrap-typeahead/cjs/utils/stripDiacritics"
|
|
8
|
+
|
|
9
|
+
const HISTORY_MAX_RESULTS = 10
|
|
10
|
+
|
|
11
|
+
export const useSearchHistory = () => {
|
|
12
|
+
const user_id = getUid()
|
|
13
|
+
|
|
14
|
+
const [history, setHistory] = useState([])
|
|
15
|
+
const historyQuery = useQuery("PersistedEvent", {_owners: {$in: [user_id]}, type: "search_query"})
|
|
16
|
+
|
|
17
|
+
// Initial load history
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!historyQuery.data) return
|
|
20
|
+
|
|
21
|
+
// map sorted items
|
|
22
|
+
const sorted_items = historyQuery.data
|
|
23
|
+
.filter((item) => !!item.data)
|
|
24
|
+
.sort((a, b) => new Date(b._created_at) - new Date(a._created_at))
|
|
25
|
+
.map((item) => {
|
|
26
|
+
const query = item.data.query
|
|
27
|
+
return {
|
|
28
|
+
query,
|
|
29
|
+
query_key: stripDiacritics(query.toLowerCase()),
|
|
30
|
+
timestamp: new Date(item._created_at),
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// add values and check for unicity
|
|
35
|
+
const results = []
|
|
36
|
+
for (let i = 0; i < sorted_items.length; i++) {
|
|
37
|
+
// stop if we have reached results limit
|
|
38
|
+
if (results.length > HISTORY_MAX_RESULTS) {
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const item = sorted_items[i]
|
|
43
|
+
|
|
44
|
+
// before inserting into results, check if there is another occurrence in results
|
|
45
|
+
if (results.find((o) => o.query_key === item.query_key)) {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
results.push(item)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setHistory(results)
|
|
53
|
+
}, [historyQuery.data, setHistory])
|
|
54
|
+
|
|
55
|
+
return history
|
|
56
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import assert from "assert"
|
|
2
|
+
import _pick from "lodash/pick"
|
|
3
|
+
import fuzzysort from "fuzzysort"
|
|
4
|
+
|
|
5
|
+
import page from "../../../page"
|
|
6
|
+
// import {withHashState} from "@rpcbase/client/hashState"
|
|
7
|
+
|
|
8
|
+
// import COLLECTIONS from "config/collections"
|
|
9
|
+
// import {useEnvContext} from "helpers/EnvContext"
|
|
10
|
+
import {useActiveListItemIndex} from "../../helpers/useActiveListItemIndex"
|
|
11
|
+
import {useScrollSelectorIntoView} from "../../helpers/useScrollSelectorIntoView"
|
|
12
|
+
|
|
13
|
+
import useCombinedResultsActions from "./useCombinedResultsActions"
|
|
14
|
+
|
|
15
|
+
// import save_search from "rpc!server/search-indexer/save_search"
|
|
16
|
+
const save_search = async() => null
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const resolveTargetUrl = (encodeHashLink, item) => {
|
|
20
|
+
let targetUrl
|
|
21
|
+
|
|
22
|
+
// Message
|
|
23
|
+
if (item._source.type === "message") {
|
|
24
|
+
const pageUrl = "/channel"
|
|
25
|
+
const channelId = item._source.channel_id
|
|
26
|
+
const convId = item._source.conversation_id
|
|
27
|
+
targetUrl = `${pageUrl}/${channelId}#${encodeHashLink({
|
|
28
|
+
activeConversationId: convId, // conversation
|
|
29
|
+
scrollToMessageId: item._id, // highlighted message
|
|
30
|
+
})}`
|
|
31
|
+
}
|
|
32
|
+
// Other items
|
|
33
|
+
else {
|
|
34
|
+
const pageUrl = COLLECTIONS[item.meta.collection_name].base_url
|
|
35
|
+
targetUrl = `${pageUrl}/${item.meta._id}#${encodeHashLink({
|
|
36
|
+
highlightId: item._id,
|
|
37
|
+
})}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return targetUrl
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const getKey = (i) => `search-anything-item-${i}`
|
|
44
|
+
|
|
45
|
+
export const SearchResults = ({
|
|
46
|
+
query,
|
|
47
|
+
results,
|
|
48
|
+
searchContext,
|
|
49
|
+
onCompleteSearch,
|
|
50
|
+
children,
|
|
51
|
+
}) => {
|
|
52
|
+
// const envContext = useEnvContext()
|
|
53
|
+
|
|
54
|
+
const items = useCombinedResultsActions({query, results})
|
|
55
|
+
|
|
56
|
+
// runs when a search result is selected by pressing enter
|
|
57
|
+
const applyTargetItem = async(item) => {
|
|
58
|
+
// user pressed enter with no results / nothing selected
|
|
59
|
+
if (!item) {
|
|
60
|
+
console.log("user pressed enter but there are no results")
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (item.type === "action") {
|
|
65
|
+
if (item.hashState) {
|
|
66
|
+
serializeHashState(item.hashState)
|
|
67
|
+
} else {
|
|
68
|
+
console.error("unknown action", item)
|
|
69
|
+
throw new Error("unknown action")
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
const targetUrl = resolveTargetUrl(encodeHashLink, item)
|
|
73
|
+
|
|
74
|
+
const res = await save_search({
|
|
75
|
+
group_id: envContext.groupId,
|
|
76
|
+
data: {
|
|
77
|
+
query,
|
|
78
|
+
search_context: _pick(searchContext, ["id", "col"]),
|
|
79
|
+
result_url: targetUrl,
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
assert(res.status === "ok")
|
|
83
|
+
|
|
84
|
+
page(targetUrl)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const onSelectItem = (selectedIndex) => {
|
|
89
|
+
onCompleteSearch()
|
|
90
|
+
// go to selected item
|
|
91
|
+
const targetItem = items[selectedIndex]
|
|
92
|
+
applyTargetItem(targetItem)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const {activeItemIndex} = useActiveListItemIndex({items, onSelectItem})
|
|
96
|
+
|
|
97
|
+
useScrollSelectorIntoView(`#${getKey(activeItemIndex)}`)
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div>
|
|
101
|
+
<div className="list-group list-group-flush">
|
|
102
|
+
{items.map((item, i) => {
|
|
103
|
+
const key = getKey(i)
|
|
104
|
+
// Render action
|
|
105
|
+
if (item.type === "action") {
|
|
106
|
+
let highlighted = fuzzysort.highlight(fuzzysort.single(query, item.key), "<b>", "</b>")
|
|
107
|
+
|
|
108
|
+
if (!highlighted) highlighted = item.key
|
|
109
|
+
|
|
110
|
+
const onClick = () => {
|
|
111
|
+
onSelectItem(i)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<a
|
|
116
|
+
id={key}
|
|
117
|
+
key={key}
|
|
118
|
+
className={cx(
|
|
119
|
+
"list-group-item list-group-item-action d-flex justify-content-between align-items-start",
|
|
120
|
+
{active: activeItemIndex === i},
|
|
121
|
+
)}
|
|
122
|
+
style={{cursor: "pointer"}}
|
|
123
|
+
onClick={onClick}
|
|
124
|
+
>
|
|
125
|
+
<div className="ms-2 me-auto">
|
|
126
|
+
<img
|
|
127
|
+
style={{width: 20, height: 20, display: "inline"}}
|
|
128
|
+
className="d-inline-flex me-2"
|
|
129
|
+
src={`/static/icons/${item.icon}.svg`}
|
|
130
|
+
/>
|
|
131
|
+
<div
|
|
132
|
+
className="item-highlight"
|
|
133
|
+
style={{display: "inline"}}
|
|
134
|
+
dangerouslySetInnerHTML={{__html: highlighted}}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
<span className="badge text-bg-light rounded-pill">
|
|
138
|
+
{item.fuzzy_score?.toFixed(2)}
|
|
139
|
+
</span>
|
|
140
|
+
</a>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
// Render search result
|
|
144
|
+
else {
|
|
145
|
+
const onClick = () => {
|
|
146
|
+
onCompleteSearch()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const targetUrl = resolveTargetUrl(encodeHashLink, item)
|
|
150
|
+
// TODO: when message, displaySub should be the name of the recipient / sender
|
|
151
|
+
const displaySub = item._source.type === "message" ? "message" : item.meta.name
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<a
|
|
155
|
+
id={key}
|
|
156
|
+
key={key}
|
|
157
|
+
className={cx(
|
|
158
|
+
"list-group-item list-group-item-action d-flex justify-content-between align-items-start",
|
|
159
|
+
{active: activeItemIndex === i},
|
|
160
|
+
)}
|
|
161
|
+
href={targetUrl}
|
|
162
|
+
onClick={onClick}
|
|
163
|
+
>
|
|
164
|
+
<div className="ms-2 me-auto">
|
|
165
|
+
{Object.keys(item.highlight).map((highlightKey, j) => (
|
|
166
|
+
<div
|
|
167
|
+
key={`highlight-${j}`}
|
|
168
|
+
className="item-highlight"
|
|
169
|
+
dangerouslySetInnerHTML={{__html: item.highlight[highlightKey][0]}}
|
|
170
|
+
/>
|
|
171
|
+
))}
|
|
172
|
+
<small className="fst-italic">{displaySub}</small>
|
|
173
|
+
</div>
|
|
174
|
+
<span className="badge text-bg-light rounded-pill">
|
|
175
|
+
{item.fuzzy_score?.toFixed(2)}
|
|
176
|
+
</span>
|
|
177
|
+
<span className="badge bg-primary rounded-pill">{item._score.toFixed(2)}</span>
|
|
178
|
+
</a>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
})}
|
|
182
|
+
|
|
183
|
+
{/* children can be components like search history, etc */}
|
|
184
|
+
{children}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
import {useMemo} from "react"
|
|
3
|
+
import useLocation from "react-use/lib/useLocation"
|
|
4
|
+
import _isNil from "lodash/isNil"
|
|
5
|
+
import fuzzysort from "fuzzysort"
|
|
6
|
+
|
|
7
|
+
import getActions from "./getActions"
|
|
8
|
+
|
|
9
|
+
// uses fuzzysort to match and sort results https://github.com/farzher/fuzzysort
|
|
10
|
+
const getResultsWithActions = (query, results, actions) => {
|
|
11
|
+
// we "fuzzy match sort" all actions and results (with both our fuzzy + elastic key)
|
|
12
|
+
const rawItems = [...actions, ...results].map((item) => {
|
|
13
|
+
let fuzzy_score
|
|
14
|
+
|
|
15
|
+
// action item
|
|
16
|
+
if (item.key) {
|
|
17
|
+
fuzzy_score = fuzzysort.single(query, item.key)?.score
|
|
18
|
+
}
|
|
19
|
+
// search result
|
|
20
|
+
else {
|
|
21
|
+
// for each highlight compute score, take the highest one
|
|
22
|
+
const scores = Object
|
|
23
|
+
// TODO: why do some elastic search results not have a highlight?
|
|
24
|
+
.keys(item?.highlight || {})
|
|
25
|
+
.map((key) => fuzzysort.single(query, item.highlight[key][0])?.score)
|
|
26
|
+
.filter((s) => !_isNil(s))
|
|
27
|
+
.sort((a, b) => b - a)
|
|
28
|
+
|
|
29
|
+
fuzzy_score = scores[0]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...item,
|
|
34
|
+
fuzzy_score,
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const sortedItems = rawItems
|
|
39
|
+
.filter((item) => !_isNil(item.fuzzy_score))
|
|
40
|
+
.sort((a, b) => b.fuzzy_score - a.fuzzy_score)
|
|
41
|
+
|
|
42
|
+
return sortedItems
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const useCombinedResultsActions = ({query, results}) => {
|
|
46
|
+
const location = useLocation()
|
|
47
|
+
|
|
48
|
+
const items = useMemo(() => {
|
|
49
|
+
const actions = getActions(location.pathname)
|
|
50
|
+
|
|
51
|
+
const newItems = getResultsWithActions(query, results, actions)
|
|
52
|
+
|
|
53
|
+
return newItems
|
|
54
|
+
}, [results, location.pathname])
|
|
55
|
+
|
|
56
|
+
return items
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default useCombinedResultsActions
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import assert from "assert"
|
|
2
|
+
import {useEffect, useState, useContext, useRef, useCallback} from "react"
|
|
3
|
+
import {Typeahead} from "react-bootstrap-typeahead"
|
|
4
|
+
import Overlay from "react-bootstrap/Overlay"
|
|
5
|
+
import _throttle from "lodash/throttle"
|
|
6
|
+
|
|
7
|
+
import ActivityIndicator from "../ActivityIndicator"
|
|
8
|
+
|
|
9
|
+
// import ItemContext from "helpers/ItemContext"
|
|
10
|
+
// import {useEnvContext} from "helpers/EnvContext"
|
|
11
|
+
|
|
12
|
+
import {SearchResults} from "./SearchResults"
|
|
13
|
+
import {SearchHistory} from "./SearchHistory"
|
|
14
|
+
|
|
15
|
+
// import search_anything from "rpc!server/search-indexer/search_anything"
|
|
16
|
+
// import search_advanced from "rpc!server/search-indexer/advanced/search_advanced"
|
|
17
|
+
// import get_item_info from "rpc!server/items/get_item_info"
|
|
18
|
+
|
|
19
|
+
import "./search.scss"
|
|
20
|
+
|
|
21
|
+
// import stripDiacritics from "react-bootstrap-typeahead/cjs/utils/stripDiacritics"
|
|
22
|
+
// TODO: at this point we don't use any rbt features, it's best to remove it from here
|
|
23
|
+
// and reimplement the token + input rendering ourselves
|
|
24
|
+
|
|
25
|
+
const MIN_SEARCH_LENGTH = 2
|
|
26
|
+
// TODO: i18n placeholder
|
|
27
|
+
const PLACEHOLDER = "Search..."
|
|
28
|
+
|
|
29
|
+
export const Search = ({
|
|
30
|
+
minLength = MIN_SEARCH_LENGTH,
|
|
31
|
+
onSearch,
|
|
32
|
+
}: {
|
|
33
|
+
minLength?: number
|
|
34
|
+
onSearch: (queryStr: string) => Array<any>
|
|
35
|
+
}) => {
|
|
36
|
+
// const envContext = useEnvContext()
|
|
37
|
+
// const itemContext = useContext(ItemContext)
|
|
38
|
+
|
|
39
|
+
const typeaheadRef = useRef()
|
|
40
|
+
const wrapperRef = useRef()
|
|
41
|
+
|
|
42
|
+
const [results, setResults] = useState([])
|
|
43
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
44
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
45
|
+
const [showResults, setShowResults] = useState(false)
|
|
46
|
+
const [currentInputValue, setCurrentInputValue] = useState("")
|
|
47
|
+
|
|
48
|
+
const [selected, setSelected] = useState([{id: null, name: ""}])
|
|
49
|
+
|
|
50
|
+
const onShortcutToggle = () => {
|
|
51
|
+
if (!showResults) {
|
|
52
|
+
typeaheadRef.current?.focus()
|
|
53
|
+
}
|
|
54
|
+
if (showResults) {
|
|
55
|
+
setShowResults(false)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
// this event triggerred from hot keys in router as it must be a global listener
|
|
61
|
+
document.body.addEventListener("toggle-search-anything", onShortcutToggle)
|
|
62
|
+
|
|
63
|
+
return () => document.body.removeEventListener("toggle-search-anything", onShortcutToggle)
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
// TODO: do not load if name in cache?
|
|
68
|
+
// TODO: reapply selection when navigation changes
|
|
69
|
+
// if (itemContext.id && selected.length > 0 && itemContext.id !== selected[0]?.id) {
|
|
70
|
+
// const load = async() => {
|
|
71
|
+
// const res = await get_item_info({id: itemContext.id, col: itemContext.col})
|
|
72
|
+
// assert(res.status === "ok")
|
|
73
|
+
// setSelected([{id: itemContext.id, col: itemContext.col, name: res.name}])
|
|
74
|
+
// }
|
|
75
|
+
// load()
|
|
76
|
+
// }
|
|
77
|
+
// }, [itemContext, selected, setSelected])
|
|
78
|
+
}, [])
|
|
79
|
+
|
|
80
|
+
const onFocus = (e: SyntheticFocusEvent<HTMLElement>) => {
|
|
81
|
+
setShowResults(true)
|
|
82
|
+
setIsFocused(true)
|
|
83
|
+
|
|
84
|
+
// TODO: we should only select it when we are sure the user didn't lose focus
|
|
85
|
+
// by focusing out of the tab for instance
|
|
86
|
+
// Select the input text when focusing
|
|
87
|
+
typeaheadRef.current.getInput().select()
|
|
88
|
+
// if (e.type === "click") {
|
|
89
|
+
// return
|
|
90
|
+
// }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const onBlur = () => {
|
|
94
|
+
console.log("onBlur: disabling menu")
|
|
95
|
+
// TODO: we should hide the menu in most blur cases
|
|
96
|
+
setIsFocused(false)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const onChange = (selection) => {
|
|
100
|
+
assert([0, 1].includes(selection.length), "unknown selection length")
|
|
101
|
+
|
|
102
|
+
console.log("selection change", selection)
|
|
103
|
+
|
|
104
|
+
if (selection.length === 0) {
|
|
105
|
+
console.log("apply selection change")
|
|
106
|
+
setSelected([])
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const onSearchCallback = useRef(
|
|
112
|
+
_throttle(
|
|
113
|
+
async(queryStr: string) => {
|
|
114
|
+
// used to not set loader before 100ms delay (if search is too fast)
|
|
115
|
+
const tm = setTimeout(() => {
|
|
116
|
+
setIsLoading(true)
|
|
117
|
+
}, 100)
|
|
118
|
+
|
|
119
|
+
// const res = await search_anything({
|
|
120
|
+
// group_id: envContext.groupId,
|
|
121
|
+
// str,
|
|
122
|
+
// })
|
|
123
|
+
|
|
124
|
+
const results = await onSearch(queryStr)
|
|
125
|
+
|
|
126
|
+
// assert(res.status === "ok")
|
|
127
|
+
// console.log("search res", res)
|
|
128
|
+
|
|
129
|
+
setResults(results)
|
|
130
|
+
clearTimeout(tm)
|
|
131
|
+
setIsLoading(false)
|
|
132
|
+
},
|
|
133
|
+
200,
|
|
134
|
+
{leading: true, trailing: true},
|
|
135
|
+
),
|
|
136
|
+
).current
|
|
137
|
+
|
|
138
|
+
// const onSearchAdvanced = useRef(
|
|
139
|
+
// _throttle(
|
|
140
|
+
// async(str: string) => {
|
|
141
|
+
// const search_context = selected[0]
|
|
142
|
+
// let res
|
|
143
|
+
// // TMP
|
|
144
|
+
// try {
|
|
145
|
+
// // res = await search_advanced({
|
|
146
|
+
// // group_id: envContext.groupId,
|
|
147
|
+
// // query: str,
|
|
148
|
+
// // search_context,
|
|
149
|
+
// // })
|
|
150
|
+
// console.log("TMP: search advanced is disabled", search_context, search_advanced)
|
|
151
|
+
// } catch (err) {
|
|
152
|
+
// console.log("advanced search got error", err)
|
|
153
|
+
// return
|
|
154
|
+
// }
|
|
155
|
+
// console.log("onSearchAdvanced res", res)
|
|
156
|
+
// },
|
|
157
|
+
// 1000,
|
|
158
|
+
// {leading: false},
|
|
159
|
+
// ),
|
|
160
|
+
// ).current
|
|
161
|
+
|
|
162
|
+
// perform search when input changes
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const val = currentInputValue
|
|
165
|
+
if (val.length >= minLength) {
|
|
166
|
+
onSearchCallback(val)
|
|
167
|
+
} else {
|
|
168
|
+
console.log("TODO: search input < minLength, clear results")
|
|
169
|
+
}
|
|
170
|
+
}, [currentInputValue])
|
|
171
|
+
|
|
172
|
+
const onInputChange = (v) => {
|
|
173
|
+
setCurrentInputValue(v)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const onHideResults = (e) => {
|
|
177
|
+
// console.log("hide results wow", e)
|
|
178
|
+
// when clicking on anything inside the input, we do not close menu
|
|
179
|
+
if (e instanceof MouseEvent) {
|
|
180
|
+
const {target} = e
|
|
181
|
+
if (wrapperRef.current?.contains(target)) {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// also blur the input when closing the menu
|
|
186
|
+
// typeaheadRef.current?.clear()
|
|
187
|
+
typeaheadRef.current?.blur()
|
|
188
|
+
setShowResults(false)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// clears the input (but preserves the token "scope" if there is one)
|
|
192
|
+
const getClearHandler = (onClear) => () => {
|
|
193
|
+
typeaheadRef.current?.clear()
|
|
194
|
+
typeaheadRef.current?.focus()
|
|
195
|
+
|
|
196
|
+
// requestAnimationFrame(() => {
|
|
197
|
+
// // this.typeaheadRef.current?.focus()
|
|
198
|
+
// })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const setSearchInput = (v) => {
|
|
202
|
+
setCurrentInputValue(v)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const onCompleteSearch = useCallback(() => {
|
|
206
|
+
typeaheadRef.current?.blur()
|
|
207
|
+
typeaheadRef.current?.clear()
|
|
208
|
+
setCurrentInputValue("")
|
|
209
|
+
setShowResults(false)
|
|
210
|
+
}, [])
|
|
211
|
+
|
|
212
|
+
const showClearButton = () => !isLoading && currentInputValue !== ""
|
|
213
|
+
|
|
214
|
+
const hasAdvancedResults = false
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<>
|
|
218
|
+
<div
|
|
219
|
+
id="search-anything-wrapper"
|
|
220
|
+
ref={wrapperRef}
|
|
221
|
+
className="d-flex mx-auto w-100"
|
|
222
|
+
// style={{height: 32, position: "relative", maxWidth: 804}}
|
|
223
|
+
style={{height: 32, position: "relative"}}
|
|
224
|
+
>
|
|
225
|
+
<Typeahead
|
|
226
|
+
id="search-anything"
|
|
227
|
+
ref={typeaheadRef}
|
|
228
|
+
className={cx("search-anything-typeahead w-100", {"is-focused": isFocused})}
|
|
229
|
+
placeholder={PLACEHOLDER}
|
|
230
|
+
inputProps={{
|
|
231
|
+
placeholder: PLACEHOLDER,
|
|
232
|
+
}}
|
|
233
|
+
labelKey={"name"}
|
|
234
|
+
renderMenu={() => null}
|
|
235
|
+
onInputChange={onInputChange}
|
|
236
|
+
multiple
|
|
237
|
+
allowNew={true} // is this prop doing anything in our case?
|
|
238
|
+
onFocus={onFocus}
|
|
239
|
+
onBlur={onBlur}
|
|
240
|
+
onChange={onChange}
|
|
241
|
+
selected={selected}
|
|
242
|
+
options={selected}
|
|
243
|
+
>
|
|
244
|
+
{({onClear}) => (
|
|
245
|
+
<div className="rbt-aux">
|
|
246
|
+
{showClearButton() && (
|
|
247
|
+
<button
|
|
248
|
+
className="btn btn-link link-secondary me-2"
|
|
249
|
+
onClick={getClearHandler(onClear)}
|
|
250
|
+
style={{display: "block", zIndex: 1, pointerEvents: "auto"}}
|
|
251
|
+
>
|
|
252
|
+
clear
|
|
253
|
+
</button>
|
|
254
|
+
)}
|
|
255
|
+
{isLoading && <ActivityIndicator />}
|
|
256
|
+
{hasAdvancedResults && (
|
|
257
|
+
<div>
|
|
258
|
+
<kbd>⌘+↵</kbd>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
</Typeahead>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<Overlay
|
|
267
|
+
target={wrapperRef.current}
|
|
268
|
+
container={wrapperRef.current}
|
|
269
|
+
placement="bottom"
|
|
270
|
+
show={showResults}
|
|
271
|
+
transition={false}
|
|
272
|
+
onHide={onHideResults}
|
|
273
|
+
rootClose={true}
|
|
274
|
+
rootCloseEvent={"mousedown"}
|
|
275
|
+
>
|
|
276
|
+
{({style, ...overlayProps}) => (
|
|
277
|
+
<div
|
|
278
|
+
id="search-anything-overlay"
|
|
279
|
+
{...overlayProps}
|
|
280
|
+
className="shadow-lg"
|
|
281
|
+
style={{
|
|
282
|
+
...style,
|
|
283
|
+
top: 7,
|
|
284
|
+
left: -1,
|
|
285
|
+
right: -3,
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<SearchResults
|
|
289
|
+
query={currentInputValue.trim()}
|
|
290
|
+
results={results}
|
|
291
|
+
onCompleteSearch={onCompleteSearch}
|
|
292
|
+
searchContext={selected[0]}
|
|
293
|
+
>
|
|
294
|
+
<SearchHistory setSearchInput={setSearchInput} />
|
|
295
|
+
</SearchResults>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</Overlay>
|
|
299
|
+
</>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
File without changes
|
package/ui/View/index.web.js
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {useState, useEffect} from "react"
|
|
2
|
+
|
|
3
|
+
export const useActiveListItemIndex = ({items, activeItem, onSelectItem}) => {
|
|
4
|
+
const [activeItemIndex, setActiveItemIndex] = useState(() => {
|
|
5
|
+
let initialIndex = items.findIndex((item) => item.obj?._id === activeItem?._id)
|
|
6
|
+
if (!initialIndex) initialIndex = 0
|
|
7
|
+
return initialIndex
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const onKeyDown = (e) => {
|
|
12
|
+
if (e.key === "ArrowDown") {
|
|
13
|
+
// increment
|
|
14
|
+
if (activeItemIndex < items.length - 1) setActiveItemIndex(activeItemIndex + 1)
|
|
15
|
+
// cycle back to 0
|
|
16
|
+
else setActiveItemIndex(0)
|
|
17
|
+
} else if (e.key === "ArrowUp") {
|
|
18
|
+
// decrement
|
|
19
|
+
if (activeItemIndex > 0) setActiveItemIndex(activeItemIndex - 1)
|
|
20
|
+
// cycle back to almost last item
|
|
21
|
+
else setActiveItemIndex(items.length - 1)
|
|
22
|
+
} else if (e.key === "Enter") {
|
|
23
|
+
onSelectItem(activeItemIndex)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
document.addEventListener("keydown", onKeyDown)
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
document.removeEventListener("keydown", onKeyDown)
|
|
31
|
+
}
|
|
32
|
+
}, [activeItemIndex, items, onSelectItem])
|
|
33
|
+
|
|
34
|
+
// reset when out of bounds (or items changed)
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (activeItemIndex > items.length) {
|
|
37
|
+
setActiveItemIndex(0)
|
|
38
|
+
}
|
|
39
|
+
}, [activeItemIndex, items])
|
|
40
|
+
|
|
41
|
+
return {activeItemIndex}
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {useEffect} from "react"
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export const useScrollSelectorIntoView = (selector) => {
|
|
5
|
+
// scroll element into view if necessary
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const el = document.querySelector(selector)
|
|
8
|
+
if (!el) return
|
|
9
|
+
// TODO: check if element is visible and only scroll if it's not visible
|
|
10
|
+
// https://htmldom.dev/check-if-an-element-is-visible-in-a-scrollable-container/
|
|
11
|
+
|
|
12
|
+
el?.scrollIntoView({behavior: "smooth", block: "end", inline: "nearest"})
|
|
13
|
+
}, [selector])
|
|
14
|
+
}
|