Skip to content

Commit 4c641d4

Browse files
committed
Search by tag
1 parent 5f88816 commit 4c641d4

11 files changed

Lines changed: 260 additions & 112 deletions

File tree

src/main/java/alexp/blog/controller/PostsController.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
import org.springframework.ui.ModelMap;
1313
import org.springframework.validation.BindingResult;
1414
import org.springframework.web.bind.annotation.*;
15+
16+
import javax.servlet.http.HttpServletRequest;
1517
import javax.validation.Valid;
18+
import java.util.Arrays;
19+
import java.util.List;
20+
import java.util.stream.Collectors;
1621

1722
@Controller
1823
public class PostsController {
@@ -32,10 +37,25 @@ public String showPostsList(@RequestParam(value = "page", defaultValue = "0") In
3237
return "posts";
3338
}
3439

35-
@RequestMapping(value = "/tag", method = RequestMethod.GET)
36-
public @ResponseBody String searchByTag(@RequestParam("name") String tagName, ModelMap model) {
40+
@RequestMapping(value = "/posts", method = RequestMethod.GET, params = {"tagged"})
41+
public String searchByTag(@RequestParam("tagged") String tagsStr, @RequestParam(value = "page", defaultValue = "0") Integer pageNumber,
42+
ModelMap model, HttpServletRequest request) {
43+
List<String> tagNames = Arrays.stream(tagsStr.split(",")).map(String::trim).distinct().collect(Collectors.toList());
44+
45+
if (tagNames.isEmpty()) {
46+
return "redirect:/posts";
47+
}
3748

38-
return "search by tag: TODO";
49+
Page<Post> postsPage = postService.findPostsByTag(tagNames, pageNumber, 10);
50+
51+
model.addAttribute("postsPage", postsPage);
52+
53+
model.addAttribute("searchTags", tagNames);
54+
55+
String query = "tagged=" + request.getParameter("tagged");
56+
model.addAttribute("searchQuery", query);
57+
58+
return "posts";
3959
}
4060

4161
@RequestMapping(value = "/posts/{postId}", method = RequestMethod.GET)

src/main/java/alexp/blog/repository/PostRepository.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,17 @@
44
import org.springframework.data.domain.Page;
55
import org.springframework.data.domain.Pageable;
66
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
9+
import java.util.Collection;
710

811
public interface PostRepository extends JpaRepository<Post, Long> {
912

10-
Page<Post> findByHiddenFalseOrHiddenIsNull(Pageable pageable);
13+
Page<Post> findByHiddenFalse(Pageable pageable);
14+
15+
@Query("SELECT p FROM Post p WHERE :tagCount = (SELECT COUNT(DISTINCT t.id) FROM Post p2 JOIN p2.tags t WHERE LOWER(t.name) in (:tags) and p = p2)")
16+
Page<Post> findByTags(@Param("tags") Collection<String> tags, @Param("tagCount") Long tagCount, Pageable pageable);
17+
18+
@Query("SELECT p FROM Post p WHERE :tagCount = (SELECT COUNT(DISTINCT t.id) FROM Post p2 JOIN p2.tags t WHERE p.hidden = false and LOWER(t.name) in (:tags) and p = p2)")
19+
Page<Post> findByTagsAndNotHidden(@Param("tags") Collection<String> tags, @Param("tagCount") Long tagCount, Pageable pageable);
1120
}

src/main/java/alexp/blog/service/PostService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import alexp.blog.model.PostEditDto;
55
import org.springframework.data.domain.Page;
66

7+
import java.util.List;
8+
79
public interface PostService {
810

911
Page<Post> getPostsPage(int pageNumber, int pageSize);
@@ -12,6 +14,8 @@ public interface PostService {
1214

1315
PostEditDto getEditablePost(Long id);
1416

17+
Page<Post> findPostsByTag(List<String> tags, int pageNumber, int pageSize);
18+
1519
Post saveNewPost(PostEditDto postEditDto);
1620

1721
Post updatePost(PostEditDto postEditDto);

src/main/java/alexp/blog/service/PostServiceImpl.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public Page<Post> getPostsPage(int pageNumber, int pageSize) {
3636
if (userService.isAdmin())
3737
return postRepository.findAll(pageRequest);
3838

39-
return postRepository.findByHiddenFalseOrHiddenIsNull(pageRequest);
39+
return postRepository.findByHiddenFalse(pageRequest);
4040
}
4141

4242
@Override
@@ -54,6 +54,18 @@ public PostEditDto getEditablePost(Long id) {
5454
return convertToPostEditDto(post);
5555
}
5656

57+
@Override
58+
public Page<Post> findPostsByTag(List<String> tags, int pageNumber, int pageSize) {
59+
tags = tags.stream().map(String::toLowerCase).collect(Collectors.toList());
60+
61+
PageRequest pageRequest = new PageRequest(pageNumber, pageSize, Sort.Direction.DESC, "dateTime");
62+
63+
if (userService.isAdmin())
64+
return postRepository.findByTags(tags, (long) tags.size(), pageRequest);
65+
66+
return postRepository.findByTagsAndNotHidden(tags, (long) tags.size(), pageRequest);
67+
}
68+
5769
@Override
5870
public Post saveNewPost(PostEditDto postEditDto) {
5971
Post post = new Post();

src/main/resources/dummy-data.sql

Lines changed: 89 additions & 88 deletions
Large diffs are not rendered by default.

src/main/webapp/WEB-INF/templates/layouts/blog.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,14 @@ <h1>Blog</h1>
106106

107107
<section class="col-sm-2">
108108
<div class="col-padding">
109-
<!-- TODO: latest posts list, tags, search by tag, etc. -->
109+
<form th:action="@{/posts}" method="get">
110+
<div class="form-group">
111+
<label for="tagSearchInput">Search by tag</label>
112+
<input type="text" class="form-control" id="tagSearchInput" name="tagged" placeholder="php, programming"/>
113+
</div>
114+
115+
<button type="submit" class="btn btn-default">Search</button>
116+
</form>
110117
</div>
111118
</section>
112119
</div>

src/main/webapp/WEB-INF/templates/post.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ <h2 class="post-title" th:text="${post.title}"></h2>
3636
<p class="post-date" th:text="${#temporals.format(post.dateTime, 'MMM dd, yyyy HH:mm:ss')}"></p>
3737

3838
<div class="post-tags">
39-
<a class="post-tag" th:each="tag : ${post.getTags()}" th:text="${tag.name}" th:href="@{/tag(name=${tag.name})}"></a>
39+
<a class="post-tag" th:each="tag : ${post.getTags()}" th:text="${tag.name}" th:href="@{/posts(tagged=${tag.name})}"></a>
4040
</div>
4141

4242
<div class="post-maincontent" th:utext="${post.fullPostTextHtml()}"></div>

src/main/webapp/WEB-INF/templates/posts.html

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@
1414
<body>
1515

1616
<section layout:fragment="content">
17-
<div th:if="${tag != null}">
18-
<h2 th:text="${tag}"></h2>
17+
<div th:if="${searchTags != null}" class="search-header">
18+
<div>
19+
<h3>Posts tagged as</h3>
20+
<a class="post-tag" th:each="tag : ${searchTags}" th:text="${tag}" th:href="@{/posts(tagged=${tag})}"></a>
21+
</div>
22+
<div class="search-result">
23+
<span class="h4" th:text="${postsPage.getTotalElements() == 0} ? 'Tag not found' :(${postsPage.getTotalElements()} + ' post' + (${postsPage.getTotalElements() &gt; 1 ? 's' : ''}))"></span>
24+
</div>
1925
</div>
2026

2127
<div id="postsContainer">
@@ -25,7 +31,7 @@ <h2 th:text="${tag}"></h2>
2531
<p class="post-date" th:text="${#temporals.format(post.dateTime, 'MMM dd, yyyy HH:mm')}"></p>
2632

2733
<div class="post-tags">
28-
<a class="post-tag" th:each="tag : ${post.getTags()}" th:text="${tag.name}" th:href="@{/tag(name=${tag.name})}"></a>
34+
<a class="post-tag" th:each="tag : ${post.getTags()}" th:text="${tag.name}" th:href="@{/posts(tagged=${tag.name})}"></a>
2935
</div>
3036

3137
<div class="post-maincontent" th:utext="${post.hasShortTextPart()}? ${post.shortTextPartHtml()} : ${post.fullPostTextHtml()}"></div>
@@ -59,31 +65,31 @@ <h2 th:text="${tag}"></h2>
5965
</div>
6066
</div>
6167

62-
<div>
68+
<div th:with="searchQueryParam=${searchQuery == null} ? '' : '&amp;' + ${searchQuery}">
6369
<ul class="pager">
6470
<li th:unless="${postsPage.isFirst()}">
65-
<a th:href="@{/posts(page=${postsPage.getNumber() - 1})}">&larr; Newer</a>
71+
<a th:href="@{/posts(page=${postsPage.getNumber() - 1})} + ${searchQueryParam}">&larr; Newer</a>
6672
</li>
6773
<li th:unless="${postsPage.isLast()}">
68-
<a th:href="@{/posts(page=${postsPage.getNumber() + 1})}">Older &rarr;</a>
74+
<a th:href="@{/posts(page=${postsPage.getNumber() + 1})} + ${searchQueryParam}">Older &rarr;</a>
6975
</li>
7076
</ul>
7177

7278
<ul class="pagination">
7379
<li th:if="${postsPage.getNumber() - 2 ge 0}">
74-
<a th:href="@{/posts(page=${postsPage.getNumber() - 2})}" th:text="${postsPage.getNumber() - 1}"></a>
80+
<a th:href="@{/posts(page=${postsPage.getNumber() - 2})} + ${searchQueryParam}" th:text="${postsPage.getNumber() - 1}"></a>
7581
</li>
7682
<li th:if="${postsPage.getNumber() - 1 ge 0}">
77-
<a th:href="@{/posts(page=${postsPage.getNumber() - 1})}" th:text="${postsPage.getNumber()}"></a>
83+
<a th:href="@{/posts(page=${postsPage.getNumber() - 1})} + ${searchQueryParam}" th:text="${postsPage.getNumber()}"></a>
7884
</li>
7985
<li class="active">
8086
<a th:text="${postsPage.getNumber() + 1}"></a>
8187
</li>
8288
<li th:if="${postsPage.getNumber() + 1 &lt; postsPage.getTotalPages()}">
83-
<a th:href="@{/posts(page=${postsPage.getNumber() + 1})}" th:text="${postsPage.getNumber() + 2}"></a>
89+
<a th:href="@{/posts(page=${postsPage.getNumber() + 1})} + ${searchQueryParam}" th:text="${postsPage.getNumber() + 2}"></a>
8490
</li>
8591
<li th:if="${postsPage.getNumber() + 2 &lt; postsPage.getTotalPages()}">
86-
<a th:href="@{/posts(page=${postsPage.getNumber() + 2})}" th:text="${postsPage.getNumber() + 3}"></a>
92+
<a th:href="@{/posts(page=${postsPage.getNumber() + 2})} + ${searchQueryParam}" th:text="${postsPage.getNumber() + 3}"></a>
8793
</li>
8894
</ul>
8995
</div>

src/main/webapp/css/blog.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,16 @@ label.error {
211211

212212
#profileForm .wmd-input {
213213
height: 160px;
214+
}
215+
216+
.search-header {
217+
margin-bottom: 30px;
218+
}
219+
220+
.search-header h3 {
221+
display: inline;
222+
}
223+
224+
.search-result {
225+
margin-top: 10px;
214226
}

src/test/java/alexp/blog/controller/PostsControllerIT.java

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import static alexp.blog.utils.SecurityUtils.userAdmin;
1515
import static alexp.blog.utils.SecurityUtils.userBob;
1616
import static org.hamcrest.CoreMatchers.equalTo;
17+
import static org.hamcrest.CoreMatchers.hasItems;
1718
import static org.hamcrest.CoreMatchers.is;
1819
import static org.hamcrest.Matchers.*;
1920
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@@ -30,7 +31,8 @@ public class PostsControllerIT extends AbstractIntegrationTest {
3031
public void shouldShowPostsPage() throws Exception {
3132
mockMvc.perform(get("/"))
3233
.andExpect(status().isOk())
33-
.andExpect(view().name("posts"));
34+
.andExpect(view().name("posts"))
35+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(2L))));
3436
}
3537

3638
@Test
@@ -270,6 +272,31 @@ public void shouldUnhidePost() throws Exception {
270272
.andExpect(content().string("ok"));
271273
}
272274

275+
@Test
276+
@ExpectedDatabase("data-post-hidden.xml")
277+
@DatabaseSetup("data-post-hidden.xml")
278+
public void shouldNotShowHiddenPostIfNotAdmin() throws Exception {
279+
mockMvc.perform(get("/").with(userBob()))
280+
.andExpect(status().isOk())
281+
.andExpect(view().name("posts"))
282+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(1L))));
283+
284+
mockMvc.perform(get("/"))
285+
.andExpect(status().isOk())
286+
.andExpect(view().name("posts"))
287+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(1L))));
288+
}
289+
290+
@Test
291+
@ExpectedDatabase("data-post-hidden.xml")
292+
@DatabaseSetup("data-post-hidden.xml")
293+
public void shouldShowHiddenPostIfAdmin() throws Exception {
294+
mockMvc.perform(get("/").with(userAdmin()))
295+
.andExpect(status().isOk())
296+
.andExpect(view().name("posts"))
297+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(2L))));
298+
}
299+
273300
@Test
274301
@ExpectedDatabase("data.xml")
275302
public void shouldDenyDeletePostIfNotAdmin() throws Exception {
@@ -291,4 +318,54 @@ public void shouldDeletePost() throws Exception {
291318
.andExpect(status().isOk())
292319
.andExpect(content().string("ok"));
293320
}
321+
322+
@Test
323+
@ExpectedDatabase("data.xml")
324+
public void shouldShowPostsByTag() throws Exception {
325+
mockMvc.perform(get("/posts?tagged=c++"))
326+
.andExpect(status().isOk())
327+
.andExpect(view().name("posts"))
328+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(2L))));
329+
330+
mockMvc.perform(get("/posts?tagged=meow"))
331+
.andExpect(status().isOk())
332+
.andExpect(view().name("posts"))
333+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(1L))))
334+
.andExpect(model().attribute("postsPage", hasItems(hasProperty("id", equalTo(2L)))));
335+
336+
mockMvc.perform(get("/posts?tagged=c++, hello world"))
337+
.andExpect(status().isOk())
338+
.andExpect(view().name("posts"))
339+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(1L))))
340+
.andExpect(model().attribute("postsPage", hasItems(hasProperty("id", equalTo(1L)))));
341+
}
342+
343+
@Test
344+
@ExpectedDatabase("data.xml")
345+
public void shouldShowNoPostsWhenTagNotExists() throws Exception {
346+
mockMvc.perform(get("/posts?tagged=not exists"))
347+
.andExpect(status().isOk())
348+
.andExpect(view().name("posts"))
349+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(0L))));
350+
}
351+
352+
@Test
353+
@ExpectedDatabase("data-post-hidden.xml")
354+
@DatabaseSetup("data-post-hidden.xml")
355+
public void shouldNotShowHiddenPostsByTagIfNotAdmin() throws Exception {
356+
mockMvc.perform(get("/posts?tagged=c++"))
357+
.andExpect(status().isOk())
358+
.andExpect(view().name("posts"))
359+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(1L))));
360+
}
361+
362+
@Test
363+
@ExpectedDatabase("data-post-hidden.xml")
364+
@DatabaseSetup("data-post-hidden.xml")
365+
public void shouldShowHiddenPostsByTagIfAdmin() throws Exception {
366+
mockMvc.perform(get("/posts?tagged=c++").with(userAdmin()))
367+
.andExpect(status().isOk())
368+
.andExpect(view().name("posts"))
369+
.andExpect(model().attribute("postsPage", hasProperty("totalElements", equalTo(2L))));
370+
}
294371
}

0 commit comments

Comments
 (0)