Skip to content

Commit

Permalink
feat(tools): add search preview functionality (#12470)
Browse files Browse the repository at this point in the history
* feat(tools): add search preview functionality

This commit adds the functionality to display a search preview when typing in the search input field. The search preview shows a list of search results that match the user's input. It also highlights the matching text in the search results. The search preview is displayed below the search input field and disappears after a delay when the input field loses focus.

The commit includes the following changes:
- Added a new file `weblate/static/editor/tools/search.js` that contains the JavaScript code for the search preview functionality.
- Added CSS styles in `weblate/static/styles/main.css` to position and style the search preview element.

* refactor(tools): extract search result rendering logic into separate function

- Improved RegEx while Making the search result url relative. because the regular expression may cause exponential backtracking on strings containing many repetitions of '.'

- Extracted the logic for rendering search results into a separate function called "showResults". This improves code readability and maintainability by separating concerns and reducing the complexity of the main function.

* Move search preview to filters instead of search string

* Refactor search preview

- Instead of displaying the total number of search results, it now shows the number of matching strings found.
- Instead of getting project path from url get it from the Form data.

- Update the `showResults` function to accept the `count` parameter, which represents the number of search results.
- Modify the logic in the `showResults` function to use the `count` parameter instead of the length of the `results` array.
- Adjust the CSS style for the `#results-num` element to increase the font size to 16px.

* Remove console.log statement

* docs: changelog entry

* Add link to /search/?q= in search preview

---------

Co-authored-by: Michal Čihař <[email protected]>
  • Loading branch information
meel-hd and nijel authored Oct 3, 2024
1 parent d6a1d4c commit 89dd315
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Not yet released.
* :ref:`api` now has a preview OpenAPI specification.
* :kbd:`?` now displays available :ref:`keyboard`.
* Translation and language view in the project now include basic information about the language and plurals.
* :ref:`bulk-edit` shows a preview of matched strings.

**Bug fixes**

Expand Down
160 changes: 160 additions & 0 deletions weblate/static/editor/tools/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright © Michal Čihař <[email protected]>
//
// SPDX-License-Identifier: GPL-3.0-or-later

$(document).ready(() => {
searchPreview("#replace", "#id_replace_q");

/**
* Add preview to the search input search results.
*
* @param {string} searchForm The selector string of the parent element of the search input
* @param {string} searchElment The selector string of the search input or textarea element
*
*/
function searchPreview(searchForm, searchElment) {
const $searchForm = $(searchForm);
const $searchElment = $searchForm.find(searchElment);

// Create the preview element
const $searchPreview = $('<div id="search-preview"></div>');
$searchElment.parent().parent().after($searchPreview);

let debounceTimeout = null;

// Update the preview while typing with a debounce of 300ms
$searchElment.on("input", () => {
$searchPreview.show();
const userSearchInput = $searchElment.val();
const searchQuery = buildSearchQuery($searchElment);

// Clear the previous timeout to prevent the previous
// request since the user is still typing
clearTimeout(debounceTimeout);

// fetch search results but not too often
debounceTimeout = setTimeout(() => {
if (userSearchInput) {
$.ajax({
url: "/api/units/",
method: "GET",
data: { q: searchQuery },
success: (response) => {
// Clear previous search results
$searchPreview.html("");
$("#results-num").remove();
const results = response.results;
if (!results || results.length === 0) {
$searchPreview.text(gettext("No results found"));
} else {
showResults(results, response.count, searchQuery);
}
},
});
}
}, 300); // If the user stops typing for 300ms, the search results will be fetched
});

// Show the preview on focus
$searchElment.on("focus", () => {
if ($searchElment.val() !== "" && $searchPreview.html() !== "") {
$searchPreview.show();
$("#results-num").show();
}
});

// Close the preview on focusout, form submit, form reset, and form clear
$searchElment.on("focusout", () => {
// Hide after a delay to allow click on a result
setTimeout(() => {
$searchPreview.hide();
$("#results-num").hide();
}, 700);
});
$searchForm.on("submit", () => {
$searchPreview.html("");
$searchPreview.hide();
$("#results-num").remove();
});
$searchForm.on("reset", () => {
$searchPreview.html("");
$searchPreview.hide();
$("#results-num").remove();
});
$searchForm.on("clear", () => {
$searchPreview.html("");
$("#results-num").remove();
$searchPreview.hide();
});

/**
* Handles the search results and displays them in the preview element.
* @param {any} results fetched search results
* @param {number} count The number of search results
* @param {string} searchQuery The user typed search
* @returns void
*/
function showResults(results, count, searchQuery) {
// Show the number of results
if (count > 0) {
const t = interpolate(
ngettext("%s matching string", "%s matching strings", count),
[count],
);
const searchUrl = `/search/?q=${encodeURI(searchQuery)}`;
const resultsNumber = `<a href="${searchUrl}" target="_blank" rel="noopener noreferrer" id="results-num">${t}</a>`;
$searchPreview.append(resultsNumber);
} else {
$("#results-num").remove();
}

for (const result of results) {
const key = result.context;
const source = result.source;

// Make the URL relative
const url = result.web_url.replace(/^[a-zA-Z]+:\/\/[^/]+\//, "/");
const resultHtml = `
<a href="${url}" target="_blank" id="search-result" rel="noopener noreferrer">
<small>${key}</small>
<div>
${source.toString()}
</div>
</a>
`;

$searchPreview.append(resultHtml);
}
}
}

/**
* Builds a search query string from the user input filters.
* The search query is built by the user input filters.
* The path lookup is also added to the search query.
* Built in the following format: `path:proj/comp filters`.
*
* @param {jQuery} $searchElment - The user input.
* @returns {string} - The built search query string.
*
* */
function buildSearchQuery($searchElment) {
let builtSearchQuery = "";

// Add path lookup to the search query
const projectPath = $searchElment
.closest("form")
.find("input[name=path]")
.val();
if (projectPath) {
builtSearchQuery = `path:${projectPath}`;
}

// Add filters to the search query
const filters = $searchElment.val();
if (filters) {
builtSearchQuery = `${builtSearchQuery} ${filters}`;
}
return builtSearchQuery;
}
});
39 changes: 39 additions & 0 deletions weblate/static/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -2127,3 +2127,42 @@ tbody.warning {
#shortcuts-table-container kbd {
color: unset;
}

#search-preview {
display: none;
width: 100%;
min-height: 40px;
max-height: 200px;
margin-top: -10px;
background: white;
padding: 5px;
border-radius: 4px;
border: 1px solid #ccc;
border-top: none;
overflow-y: auto;
box-shadow: 1px 2px 4px #00000020;
}

#search-preview > #search-result {
display: block;
padding: 5px;
margin-bottom: 5px;
text-decoration: none;
color: inherit;
border-radius: 4px;
}

#search-preview > #search-result:hover {
background-color: #cccccc50;
}

#search-preview > #search-result > div {
margin-left: 10px;
}

#results-num {
font-size: 16px;
font-weight: bold;
color: #1fa385;
margin: 10px;
}
1 change: 1 addition & 0 deletions weblate/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
<script defer data-cfasync="false" src="{% static 'vendor/moment.js' %}{{ cache_param }}"></script>
<script defer data-cfasync="false" src="{% static 'vendor/daterangepicker.js' %}{{ cache_param }}"></script>
<script defer data-cfasync="false" src="{% static 'js/keyboard-shortcuts.js' %}{{ cache_param }}"></script>
<script defer data-cfasync="false" src="{% static 'editor/tools/search.js' %}{{ cache_param }}"></script>
{% endcompress %}

{% block extra_script %}
Expand Down

0 comments on commit 89dd315

Please sign in to comment.