Skip to content

Commit f017bb2

Browse files
committed
Add an instant post search
1 parent ea93c52 commit f017bb2

File tree

9 files changed

+205
-2
lines changed

9 files changed

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

app/Resources/assets/scss/main.scss

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,21 @@ header {
6363
.locales a {
6464
padding-right: 10px;
6565
}
66+
67+
// Search bar
68+
.search-bar {
69+
padding: 0.8em 0;
70+
71+
form {
72+
position: relative;
73+
}
74+
75+
.search-preview {
76+
position: absolute;
77+
width: 100%;
78+
top: 100%;
79+
}
80+
}
6681
}
6782

6883
// Body contents

app/Resources/translations/messages.en.xliff

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@
239239
<source>post.deleted_successfully</source>
240240
<target>Post deleted successfully!</target>
241241
</trans-unit>
242+
<trans-unit id="post.search_for">
243+
<source>post.search_for</source>
244+
<target>Search for...</target>
245+
</trans-unit>
242246

243247
<trans-unit id="help.app_description">
244248
<source>help.app_description</source>

app/Resources/translations/messages.ru.xliff

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@
239239
<source>post.deleted_successfully</source>
240240
<target>Запись успешно удалена!</target>
241241
</trans-unit>
242+
<trans-unit id="post.search_for">
243+
<source>post.search_for</source>
244+
<target>Искать запись...</target>
245+
</trans-unit>
242246

243247
<trans-unit id="help.app_description">
244248
<source>help.app_description</source>

app/Resources/views/base.html.twig

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
<header>
3434
<div class="navbar navbar-default navbar-static-top" role="navigation">
3535
<div class="container">
36-
<div class="navbar-header">
36+
<div class="navbar-header col-md-3 col-lg-2">
3737
<a class="navbar-brand" href="{{ path('homepage') }}">
3838
Symfony Demo
3939
</a>
@@ -47,6 +47,13 @@
4747
<span class="icon-bar"></span>
4848
</button>
4949
</div>
50+
<div class="search-bar col-sm-5">
51+
<form action="{{ path('blog_search') }}" method="get">
52+
<div class="input-group-sm">
53+
<input name="q" type="text" class="form-control" placeholder="{{ 'post.search_for'|trans }}" autocomplete="off">
54+
</div>
55+
</form>
56+
</div>
5057
<div class="navbar-collapse collapse">
5158
<ul class="nav navbar-nav navbar-right">
5259

@@ -149,12 +156,21 @@
149156
"%kernel.root_dir%/Resources/assets/js/bootstrap-3.3.4.js"
150157
"%kernel.root_dir%/Resources/assets/js/highlight.pack.js"
151158
"%kernel.root_dir%/Resources/assets/js/bootstrap-datetimepicker.min.js"
159+
"%kernel.root_dir%/Resources/assets/js/jquery.instantSearch.js"
152160
"%kernel.root_dir%/Resources/assets/js/main.js" %}
153161
<script src="{{ asset_url }}"></script>
154162
{% endjavascripts %}
155163
#}
156164

157165
<script src="{{ asset('js/app.js') }}"></script>
166+
167+
<script>
168+
$(function() {
169+
$('.search-bar input[name="q"]').instantSearch({
170+
noItemsFoundMessage: '{{ 'post.no_posts_found'|trans }}'
171+
});
172+
});
173+
</script>
158174
{% endblock %}
159175
</body>
160176
</html>

src/AppBundle/Controller/BlogController.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
2222
use Symfony\Component\HttpFoundation\Request;
2323
use Symfony\Component\HttpFoundation\Response;
24+
use Symfony\Component\HttpFoundation\JsonResponse;
2425

2526
/**
2627
* Controller used to manage blog contents in the public part of the site.
@@ -117,4 +118,47 @@ public function commentFormAction(Post $post)
117118
'form' => $form->createView(),
118119
));
119120
}
121+
122+
/**
123+
* @Route("/search", name="blog_search")
124+
* @Method("GET")
125+
*
126+
* @return JsonResponse
127+
*/
128+
public function searchAction(Request $request)
129+
{
130+
$query = $request->query->get('q', '');
131+
132+
// Sanitizing the query: removes all non-alphanumeric characters except whitespaces
133+
$query = preg_replace('/[^[:alnum:] ]/', '', trim(preg_replace('/[[:space:]]+/', ' ', $query)));
134+
135+
// Splits the query into terms and removes all terms which
136+
// length is less than 2
137+
$terms = array_unique(explode(' ', strtolower($query)));
138+
$terms = array_filter($terms, function($term) {
139+
return 2 <= strlen($term);
140+
});
141+
142+
$posts = array();
143+
144+
if (!empty($terms)) {
145+
$posts = $this
146+
->getDoctrine()
147+
->getManager()
148+
->getRepository('AppBundle:Post')
149+
->findByTerms($terms)
150+
;
151+
}
152+
153+
$results = array();
154+
155+
foreach ($posts as $post) {
156+
array_push($results, array(
157+
'result' => htmlspecialchars($post->getTitle()),
158+
'url' => $this->generateUrl('blog_post', array('slug' => $post->getSlug())),
159+
));
160+
}
161+
162+
return new JsonResponse($results);
163+
}
120164
}

src/AppBundle/Repository/PostRepository.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace AppBundle\Repository;
1313

1414
use Doctrine\ORM\EntityRepository;
15+
use AppBundle\Entity\Post;
1516

1617
/**
1718
* This custom Doctrine repository contains some methods which are useful when
@@ -40,4 +41,22 @@ public function findLatest()
4041
{
4142
$this->queryLatest()->getResult();
4243
}
44+
45+
public function findByTerms(array $terms, $limit = Post::NUM_ITEMS)
46+
{
47+
$queryBuilder = $this->createQueryBuilder('p');
48+
49+
foreach ($terms as $key => $term) {
50+
$queryBuilder
51+
->orWhere('p.title LIKE :t_' . $key)
52+
->setParameter('t_' . $key , '%' . $term . '%')
53+
;
54+
}
55+
56+
return $queryBuilder
57+
->orderBy('p.publishedAt', 'DESC')
58+
->setMaxResults($limit)
59+
->getQuery()
60+
->getResult();
61+
}
4362
}

web/css/app.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/js/app.js

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

0 commit comments

Comments
 (0)