Skip to content

Commit 9f602f3

Browse files
authored
Query Suggestions on typing (#21)
Query Suggestions and Fuzzy search logic integration with the following commits: * Add autocomplete endpoint and query suggestion logic when we typing something in the search input * Fix issue with incorrect logic handling recalculation of embed vectors for existing dataset * Enable fuzzy search, fix issue with search form and query suggestions, update manticore to the latest version * Update dependencies versions * Apply fuziness to facet count queries * Implement arrow up/down movements in query suggestions and fix bug with incorrect query replacements when we choose it from this panel * Combine response from issue and comment together on autocomplete endpoint * Implement query suggestion settings and add new types for fuzzy and fuzzy+ search * Set fuzzy search as default mode and add titles with fixes in configuration popu * Add dropdown arrow for select-menu * Fix issue with empty suggestions in response and update manticore * Update composer deps and change how we fix issue with undefined merged * Set proper manticore version * fix config * Disable bounce for autocomplete suggestions * Optimize autocomplete speed with moving to GET request with query string
1 parent 2e0c366 commit 9f602f3

File tree

14 files changed

+629
-92
lines changed

14 files changed

+629
-92
lines changed

app/actions/api/autocomplete.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* @route api/autocomplete/([^/]+): org
5+
* @route api/autocomplete/([^/]+)/([^/]+): org, name
6+
* @var string $org
7+
* @var string $name
8+
* @var string $query
9+
* @var array $config
10+
*/
11+
12+
use App\Component\Search;
13+
14+
/** @var App\Model\Repo $repo */
15+
[$org, $repo] = result(Search::getOrgAndRepo($org, $name));
16+
return Search::autocomplete($org, $repo, $query, $config);

app/actions/search.php

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @var string $query
1010
* @var array $filters
1111
* @var string $sort
12-
* @var string $search keyword-search
12+
* @var string $search keyword-search-fuzzy-layouts
1313
* @var int $offset
1414
*/
1515

@@ -32,18 +32,23 @@
3232
}
3333
$multiple_repos = sizeof($filters['repos']) > 1;
3434
$filters = Search::prepareFilters($filters);
35-
3635
// Add vector search embeddings if we have query
3736
if ($search_query) {
38-
if ($search !== 'keyword-search') {
37+
if (str_starts_with($search, 'keyword-search')) {
38+
if (str_contains($search, 'fuzzy')) {
39+
$filters['use_fuzzy'] = true;
40+
}
41+
if (str_contains($search, 'layouts')) {
42+
$filters['use_layouts'] = true;
43+
}
44+
} else {
3945
$embeddings = result(TextEmbeddings::get($query));
4046
$filters['embeddings'] = $embeddings;
4147
}
4248
if ($search === 'semantic-search') {
4349
$filters['semantic_search_only'] = true;
4450
}
4551
}
46-
4752
$list = result(Search::process($search_query, $filters, $sort, $offset));
4853

4954
$search_in = $filters['index'] ?? 'everywhere';
@@ -79,6 +84,14 @@
7984
];
8085

8186
$search_list = [
87+
[
88+
'value' => 'keyword-search-fuzzy-layouts',
89+
'name' => 'Fuzzy+Keyboard',
90+
],
91+
[
92+
'value' => 'keyword-search-fuzzy',
93+
'name' => 'Fuzzy Search',
94+
],
8295
[
8396
'value' => 'keyword-search',
8497
'name' => 'Keyword Search',
@@ -218,7 +231,7 @@ function ($key, $value) {
218231
$authors = result(Search::getAuthors($repo_ids, $query, $filters));
219232
$assignees = result(Search::getAssignees($repo_ids, $query, $filters));
220233
$labels = result(Search::getLabels($repo_ids, $query, $filters));
221-
$comment_ranges = result(Search::getCommentRanges($repo_ids));
234+
$comment_ranges = result(Search::getCommentRanges($repo_ids, $query, $filters));
222235

223236
$author_counters = result(Search::getCounterMap('author', $repo_ids, $query, $filters));
224237
$assignee_counters = result(Search::getCounterMap('assignee', $repo_ids, $query, $filters));

app/client/component/search-form/index.js

Lines changed: 253 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,266 @@ import dispatcher from 'edispatcher'
44

55
export default element => {
66
const d = domd(element)
7+
const api_url = element.getAttribute('data-api-url')
78
const slug = element.getAttribute('action')
89

10+
const inputEl = element.querySelector('[name="query"]')
11+
const autocompleteEl = element.querySelector('#autocomplete')
12+
const configureEl = element.querySelector('#configure')
13+
14+
// Define default values
15+
const defaultConfig = {
16+
fuzziness: '2',
17+
expansion_limit: '6',
18+
append: true,
19+
prepend: false,
20+
layouts: ['ru', 'us', 'ua'],
21+
};
22+
23+
// Function to load values from localStorage or use defaults
24+
function loadConfig() {
25+
const storedConfig = JSON.parse(localStorage.getItem('autocompleteConfig')) || {};
26+
return { ...defaultConfig, ...storedConfig };
27+
}
28+
29+
// Function to save config to localStorage
30+
function saveConfig(config) {
31+
localStorage.setItem('autocompleteConfig', JSON.stringify(config));
32+
}
33+
34+
// Load the initial configuration
35+
let currentConfig = loadConfig();
36+
37+
// Set up input elements
38+
const configureInputEls = configureEl.querySelectorAll('input');
39+
for (const configureInputEl of configureInputEls) {
40+
const inputName = configureInputEl.name;
41+
42+
// Set initial value from loaded config
43+
if (configureInputEl.type === 'checkbox') {
44+
const configValue = currentConfig[inputName];
45+
if (inputName.endsWith('[]')) {
46+
const baseInputName = inputName.slice(0, -2);
47+
if (Array.isArray(currentConfig[baseInputName])) {
48+
configureInputEl.checked = currentConfig[baseInputName].includes(configureInputEl.value);
49+
} else {
50+
configureInputEl.checked = false;
51+
}
52+
} else {
53+
configureInputEl.checked = configValue || false;
54+
}
55+
} else if (configureInputEl.type === 'radio') {
56+
configureInputEl.checked = configureInputEl.value === (currentConfig[inputName] || '');
57+
} else {
58+
configureInputEl.value = currentConfig[inputName] || '';
59+
}
60+
61+
// Add event listener to save changes
62+
configureInputEl.addEventListener('change', (event) => {
63+
if (inputName.endsWith('[]')) {
64+
const cleanInputName = inputName.replace(/\[\]$/, '');
65+
if (!currentConfig[cleanInputName]) {
66+
currentConfig[cleanInputName] = [];
67+
}
68+
if (event.target.checked) {
69+
if (!currentConfig[cleanInputName].includes(event.target.value)) {
70+
currentConfig[cleanInputName].push(event.target.value);
71+
}
72+
} else {
73+
currentConfig[cleanInputName] = currentConfig[cleanInputName].filter(value => value !== event.target.value);
74+
}
75+
} else if (event.target.type === 'radio') {
76+
currentConfig[inputName] = event.target.value;
77+
} else {
78+
currentConfig[inputName] = event.target.checked ? true : false
79+
}
80+
81+
saveConfig(currentConfig)
82+
})
83+
}
84+
85+
let hasActiveSuggestion = false
86+
let currentActiveIndex = -1
87+
88+
d.on('click', '.icon-configure', (ev) => {
89+
ev.preventDefault()
90+
autocompleteEl.classList.remove('visible')
91+
configureEl.classList.toggle('visible')
92+
})
93+
994
element.addEventListener('submit', (ev) => {
1095
ev.preventDefault()
11-
const value = element.querySelector('[name="query"]').value
96+
const value = inputEl.value
1297
const query = nav.removeParam(location.search, 'query')
1398
nav.load(slug + '?' + query + ';query=' + value)
1499
dispatcher.send('search', {query: value})
15100
})
101+
102+
const reset_fn = (ev) => {
103+
if (hasActiveSuggestion && currentActiveIndex !== -1) {
104+
suggestions[currentActiveIndex].element.classList.remove('active')
105+
}
106+
hasActiveSuggestion = false
107+
currentActiveIndex = -1
108+
}
109+
110+
autocompleteEl.addEventListener('mouseenter', reset_fn)
111+
autocompleteEl.addEventListener('mouseleave', reset_fn)
112+
autocompleteEl.addEventListener('mousemove', (ev) => {
113+
reset_fn(ev)
114+
const hoveredElement = ev.target
115+
if (hoveredElement) {
116+
hoveredElement.classList.add('active')
117+
hasActiveSuggestion = true
118+
const liElements = autocompleteEl.querySelectorAll('li');
119+
currentActiveIndex = Array.from(liElements).findIndex(li => li === hoveredElement);
120+
}
121+
})
122+
123+
d.on('click', 'li', (ev, el) => {
124+
inputEl.value = el.textContent
125+
element.submit()
126+
})
127+
128+
inputEl.addEventListener('focus', (ev) => {
129+
autocompleteEl.classList.add('visible')
130+
configureEl.classList.remove('visible')
131+
})
132+
133+
inputEl.addEventListener('blur', (ev) => {
134+
setTimeout(() => {
135+
autocompleteEl.classList.remove('visible')
136+
}, 150)
137+
})
138+
139+
let suggestions = []
140+
const iconSVG = element.querySelector('.icon').innerHTML
141+
const updateSuggestions = result => {
142+
suggestions = []
143+
const listEl = document.createElement('ul')
144+
for (const item of result) {
145+
const li = document.createElement('li')
146+
li.innerHTML = `<span class="icon">${iconSVG}</span><span class="text">${item.query}</span>`
147+
listEl.appendChild(li)
148+
suggestions.push({query: item.query, element: li})
149+
}
150+
151+
while (autocompleteEl.firstChild) {
152+
autocompleteEl.removeChild(autocompleteEl.firstChild)
153+
}
154+
autocompleteEl.appendChild(listEl)
155+
}
156+
157+
let debounceTimer
158+
let activeRequest
159+
let previousQuery = ''
160+
161+
function debounce(func, delay) {
162+
let timer
163+
return function(...args) {
164+
clearTimeout(timer)
165+
timer = setTimeout(() => func.apply(this, args), delay)
166+
}
167+
}
168+
169+
const fetchAutocomplete = async (query) => {
170+
if (query === previousQuery) return
171+
previousQuery = query
172+
173+
if (activeRequest) {
174+
activeRequest.abort()
175+
}
176+
177+
const controller = new AbortController()
178+
activeRequest = controller
179+
180+
try {
181+
function buildHttpBody(query, config) {
182+
let body = new URLSearchParams();
183+
184+
body.append('query', query);
185+
186+
for (const [key, value] of Object.entries(config)) {
187+
if (Array.isArray(value)) {
188+
value.forEach((item, index) => {
189+
body.append(`config[${key}][${index}]`, item);
190+
});
191+
} else {
192+
body.append(`config[${key}]`, value);
193+
}
194+
}
195+
196+
return body.toString();
197+
}
198+
199+
const httpBody = buildHttpBody(query, currentConfig);
200+
const response = await fetch(`${api_url}?${httpBody}`, {
201+
method: 'GET',
202+
headers: {},
203+
priority: 'high',
204+
signal: controller.signal
205+
});
206+
207+
const [err, result] = await response.json();
208+
209+
updateSuggestions(result)
210+
211+
} catch (error) {
212+
if (error.name === 'AbortError') {
213+
console.log('Fetch aborted')
214+
} else {
215+
console.error('Fetch error:', error)
216+
}
217+
} finally {
218+
activeRequest = null }
219+
}
220+
221+
const debouncedFetchAutocomplete = debounce((query) => {
222+
if (query !== previousQuery) {
223+
fetchAutocomplete(query)
224+
}
225+
}, 0)
226+
227+
inputEl.addEventListener('input', (ev) => {
228+
const query = ev.target.value.trim()
229+
if (query.length > 0) {
230+
autocompleteEl.classList.add('visible')
231+
debouncedFetchAutocomplete(query)
232+
} else {
233+
autocompleteEl.classList.remove('visible')
234+
clearTimeout(debounceTimer)
235+
}
236+
})
237+
238+
inputEl.addEventListener('keydown', (ev) => {
239+
autocompleteEl.classList.add('keyboard-active')
240+
if (ev.key === 'Escape' || ev.key === 'Enter') {
241+
autocompleteEl.classList.remove('visible')
242+
hasActiveSuggestion = false
243+
} else if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
244+
ev.preventDefault()
245+
const currentValue = inputEl.value
246+
const currentIndex = currentActiveIndex > -1
247+
? currentActiveIndex
248+
: suggestions.findIndex(suggestion => suggestion.query === currentValue)
249+
let newIndex
250+
251+
if (ev.key === 'ArrowUp') {
252+
newIndex = currentIndex > 0 ? currentIndex - 1 : suggestions.length - 1
253+
} else {
254+
newIndex = currentIndex < suggestions.length - 1 ? currentIndex + 1 : 0
255+
}
256+
257+
inputEl.value = suggestions[newIndex].query
258+
259+
if (hasActiveSuggestion && currentActiveIndex !== -1) {
260+
suggestions[currentActiveIndex].element.classList.remove('active')
261+
}
262+
263+
suggestions[newIndex].element.classList.add('active')
264+
currentActiveIndex = newIndex
265+
hasActiveSuggestion = true
266+
}
267+
})
16268
return () => {}
17269
}

0 commit comments

Comments
 (0)