Skip to content

Commit 0d1ddaf

Browse files
committed
feature #613 Add an instant post search (voronkovich, javiereguiluz)
This PR was merged into the master branch. Discussion ---------- Add an instant post search I've taken the code commitedd by @voronkovich in #173 and made some changes to it. Let's see if you like it. I propose to implement it like Google search results instead of displaying it in the main menu: ![search-results](https://user-images.githubusercontent.com/73419/28628630-783fd8e4-7225-11e7-89f3-c5941e92a4df.gif) The reason is that the main menu is crowded when the user is logged in and the search bar looks bad. Commits ------- fe2e2ab Fixed CS issues 51ab96a Added missing search.js file f219db9 Changes requested by reviewers 0c236ed Refactored the feature da43022 Fix cs issues f45c31c Add an instant post search
2 parents b33739c + fe2e2ab commit 0d1ddaf

File tree

16 files changed

+640
-405
lines changed

16 files changed

+640
-405
lines changed

app/Resources/translations/messages.en.xlf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,10 @@
255255
<source>menu.rss</source>
256256
<target>Blog Posts RSS</target>
257257
</trans-unit>
258+
<trans-unit id="menu.search">
259+
<source>menu.search</source>
260+
<target>Search</target>
261+
</trans-unit>
258262

259263
<trans-unit id="post.to_publish_a_comment">
260264
<source>post.to_publish_a_comment</source>
@@ -288,6 +292,10 @@
288292
<source>post.deleted_successfully</source>
289293
<target>Post deleted successfully!</target>
290294
</trans-unit>
295+
<trans-unit id="post.search_for">
296+
<source>post.search_for</source>
297+
<target>Search for...</target>
298+
</trans-unit>
291299

292300
<trans-unit id="notification.comment_created">
293301
<source>notification.comment_created</source>

app/Resources/translations/messages.ru.xlf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,10 @@
288288
<source>post.deleted_successfully</source>
289289
<target>Запись успешно удалена!</target>
290290
</trans-unit>
291+
<trans-unit id="post.search_for">
292+
<source>post.search_for</source>
293+
<target>Искать запись...</target>
294+
</trans-unit>
291295

292296
<trans-unit id="notification.comment_created">
293297
<source>notification.comment_created</source>

app/Resources/views/base.html.twig

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<header>
2424
<div class="navbar navbar-default navbar-static-top" role="navigation">
2525
<div class="container">
26-
<div class="navbar-header">
26+
<div class="navbar-header col-md-3 col-lg-2">
2727
<a class="navbar-brand" href="{{ path('homepage') }}">
2828
Symfony Demo
2929
</a>
@@ -64,6 +64,10 @@
6464
</li>
6565
{% endif %}
6666

67+
<li>
68+
<a href="{{ path('blog_search') }}"> <i class="fa fa-search"></i> {{ 'menu.search'|trans }}</a>
69+
</li>
70+
6771
<li class="dropdown">
6872
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false" id="locales">
6973
<i class="fa fa-globe" aria-hidden="true"></i>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{% extends 'base.html.twig' %}
2+
3+
{% block javascripts %}
4+
{{ parent() }}
5+
<script src="{{ asset('build/js/search.js') }}"></script>
6+
{% endblock %}
7+
8+
{% block main %}
9+
<form action="{{ path('blog_search') }}" method="get">
10+
<div class="form-group">
11+
<input name="q" type="text" class="form-control search-field" placeholder="{{ 'post.search_for'|trans }}" autocomplete="off" autofocus>
12+
</div>
13+
</form>
14+
15+
<div id="results">
16+
</div>
17+
{% endblock %}
18+
19+
{% block sidebar %}
20+
{{ parent() }}
21+
22+
{{ show_source_code(_self) }}
23+
{% endblock %}

assets/js/jquery.instantSearch.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* jQuery plugin for an instant searching.
3+
*
4+
* @author Oleg Voronkovich <[email protected]>
5+
*/
6+
(function($) {
7+
$.fn.instantSearch = function(config) {
8+
return this.each(function() {
9+
initInstantSearch(this, $.extend(true, defaultConfig, config || {}));
10+
});
11+
};
12+
13+
var defaultConfig = {
14+
minQueryLength: 2,
15+
maxPreviewItems: 10,
16+
previewDelay: 500,
17+
noItemsFoundMessage: 'No results found.'
18+
};
19+
20+
function debounce(fn, delay) {
21+
var timer = null;
22+
return function () {
23+
var context = this, args = arguments;
24+
clearTimeout(timer);
25+
timer = setTimeout(function () {
26+
fn.apply(context, args);
27+
}, delay);
28+
};
29+
}
30+
31+
var initInstantSearch = function(el, config) {
32+
var $input = $(el);
33+
var $form = $input.closest('form');
34+
var $preview = $('<ul class="search-preview list-group">').appendTo($form);
35+
36+
var setPreviewItems = function(items) {
37+
$preview.empty();
38+
39+
$.each(items, function(index, item) {
40+
if (index > config.maxPreviewItems) {
41+
return;
42+
}
43+
44+
addItemToPreview(item);
45+
});
46+
}
47+
48+
var addItemToPreview = function(item) {
49+
var $link = $('<a>').attr('href', item.url).text(item.title);
50+
var $title = $('<h3>').attr('class', 'm-b-0').append($link);
51+
var $summary = $('<p>').text(item.summary);
52+
var $result = $('<div>').append($title).append($summary);
53+
54+
$preview.append($result);
55+
}
56+
57+
var noItemsFound = function() {
58+
var $result = $('<div>').text(config.noItemsFoundMessage);
59+
60+
$preview.empty();
61+
$preview.append($result);
62+
}
63+
64+
var updatePreview = function() {
65+
var query = $.trim($input.val()).replace(/\s{2,}/g, ' ');
66+
67+
if (query.length < config.minQueryLength) {
68+
$preview.empty();
69+
return;
70+
}
71+
72+
$.getJSON($form.attr('action') + '?' + $form.serialize(), function(items) {
73+
if (items.length === 0) {
74+
noItemsFound();
75+
return;
76+
}
77+
78+
setPreviewItems(items);
79+
});
80+
}
81+
82+
$input.focusout(function(e) {
83+
$preview.fadeOut();
84+
});
85+
86+
$input.focusin(function(e) {
87+
$preview.fadeIn();
88+
updatePreview();
89+
});
90+
91+
$input.keyup(debounce(updatePreview, config.previewDelay));
92+
}
93+
})(window.jQuery);

assets/js/search.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import './jquery.instantSearch.js';
2+
3+
$(function() {
4+
$('.search-field').instantSearch({
5+
previewDelay: 100,
6+
});
7+
});

src/AppBundle/Controller/BlogController.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
2424
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
2525
use Symfony\Component\EventDispatcher\GenericEvent;
26+
use Symfony\Component\HttpFoundation\JsonResponse;
2627
use Symfony\Component\HttpFoundation\Request;
2728
use Symfony\Component\HttpFoundation\Response;
2829

@@ -150,4 +151,31 @@ public function commentFormAction(Post $post)
150151
'form' => $form->createView(),
151152
]);
152153
}
154+
155+
/**
156+
* @Route("/search", name="blog_search")
157+
* @Method("GET")
158+
*
159+
* @return Response|JsonResponse
160+
*/
161+
public function searchAction(Request $request)
162+
{
163+
if (!$request->isXmlHttpRequest()) {
164+
return $this->render('blog/search.html.twig');
165+
}
166+
167+
$query = $request->query->get('q', '');
168+
$posts = $this->getDoctrine()->getRepository(Post::class)->findBySearchQuery($query);
169+
170+
$results = [];
171+
foreach ($posts as $post) {
172+
$results[] = [
173+
'title' => htmlspecialchars($post->getTitle()),
174+
'summary' => htmlspecialchars($post->getSummary()),
175+
'url' => $this->generateUrl('blog_post', ['slug' => $post->getSlug()]),
176+
];
177+
}
178+
179+
return $this->json($results);
180+
}
153181
}

src/AppBundle/Repository/PostRepository.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,63 @@ private function createPaginator(Query $query, $page)
5959

6060
return $paginator;
6161
}
62+
63+
/**
64+
* @param string $rawQuery The search query as input by the user
65+
* @param int $limit The maximum number of results returned
66+
*
67+
* @return array
68+
*/
69+
public function findBySearchQuery($rawQuery, $limit = Post::NUM_ITEMS)
70+
{
71+
$query = $this->sanitizeSearchQuery($rawQuery);
72+
$searchTerms = $this->extractSearchTerms($query);
73+
74+
if (0 === count($searchTerms)) {
75+
return [];
76+
}
77+
78+
$queryBuilder = $this->createQueryBuilder('p');
79+
80+
foreach ($searchTerms as $key => $term) {
81+
$queryBuilder
82+
->orWhere('p.title LIKE :t_'.$key)
83+
->setParameter('t_'.$key, '%'.$term.'%')
84+
;
85+
}
86+
87+
return $queryBuilder
88+
->orderBy('p.publishedAt', 'DESC')
89+
->setMaxResults($limit)
90+
->getQuery()
91+
->getResult();
92+
}
93+
94+
/**
95+
* Removes all non-alphanumeric characters except whitespaces.
96+
*
97+
* @param string $query
98+
*
99+
* @return string
100+
*/
101+
private function sanitizeSearchQuery($query)
102+
{
103+
return preg_replace('/[^[:alnum:] ]/', '', trim(preg_replace('/[[:space:]]+/', ' ', $query)));
104+
}
105+
106+
/**
107+
* Splits the search query into terms and removes the ones which are irrelevant.
108+
*
109+
* @param string $searchQuery
110+
*
111+
* @return array
112+
*/
113+
private function extractSearchTerms($searchQuery)
114+
{
115+
$terms = array_unique(explode(' ', mb_strtolower($searchQuery)));
116+
117+
return array_filter($terms, function ($term) {
118+
return 2 <= mb_strlen($term);
119+
});
120+
}
62121
}

web/build/js/admin.js

Lines changed: 379 additions & 379 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)