Skip to content

Commit 5389908

Browse files
authored
Merge pull request #16 from linkdotnet/feature/search-bar
Feature/search bar
2 parents ba94a1c + 6be4cc9 commit 5389908

File tree

9 files changed

+277
-13
lines changed

9 files changed

+277
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System.Linq;
2+
using System.Threading.Tasks;
3+
using Bunit;
4+
using FluentAssertions;
5+
using LinkDotNet.Blog.TestUtilities;
6+
using LinkDotNet.Blog.Web.Pages;
7+
using LinkDotNet.Blog.Web.Shared;
8+
using LinkDotNet.Infrastructure.Persistence;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using Xunit;
11+
12+
namespace LinkDotNet.Blog.IntegrationTests.Web.Pages
13+
{
14+
public class SearchTests : SqlDatabaseTestBase
15+
{
16+
[Fact]
17+
public async Task ShouldFindBlogPostWhenTitleMatches()
18+
{
19+
var blogPost1 = new BlogPostBuilder().WithTitle("Title 1").Build();
20+
var blogPost2 = new BlogPostBuilder().WithTitle("Title 2").Build();
21+
await BlogPostRepository.StoreAsync(blogPost1);
22+
await BlogPostRepository.StoreAsync(blogPost2);
23+
using var ctx = new TestContext();
24+
ctx.Services.AddScoped<IRepository>(_ => BlogPostRepository);
25+
26+
var cut = ctx.RenderComponent<Search>(p => p.Add(s => s.SearchTerm, "Title 1"));
27+
28+
cut.WaitForState(() => cut.FindComponents<ShortBlogPost>().Any());
29+
var blogPosts = cut.FindComponents<ShortBlogPost>();
30+
blogPosts.Should().HaveCount(1);
31+
blogPosts.Single().Find(".description h1").TextContent.Should().Be("Title 1");
32+
}
33+
34+
[Fact]
35+
public async Task ShouldFindBlogPostWhenTagMatches()
36+
{
37+
var blogPost1 = new BlogPostBuilder().WithTitle("Title 1").WithTags("Cat").Build();
38+
var blogPost2 = new BlogPostBuilder().WithTitle("Title 2").WithTags("Dog").Build();
39+
await BlogPostRepository.StoreAsync(blogPost1);
40+
await BlogPostRepository.StoreAsync(blogPost2);
41+
using var ctx = new TestContext();
42+
ctx.Services.AddScoped<IRepository>(_ => BlogPostRepository);
43+
44+
var cut = ctx.RenderComponent<Search>(p => p.Add(s => s.SearchTerm, "Cat"));
45+
46+
cut.WaitForState(() => cut.FindComponents<ShortBlogPost>().Any());
47+
var blogPosts = cut.FindComponents<ShortBlogPost>();
48+
blogPosts.Should().HaveCount(1);
49+
blogPosts.Single().Find(".description h1").TextContent.Should().Be("Title 1");
50+
}
51+
52+
[Fact]
53+
public async Task ShouldUnescapeQuery()
54+
{
55+
var blogPost1 = new BlogPostBuilder().WithTitle("Title 1").Build();
56+
await BlogPostRepository.StoreAsync(blogPost1);
57+
using var ctx = new TestContext();
58+
ctx.Services.AddScoped<IRepository>(_ => BlogPostRepository);
59+
60+
var cut = ctx.RenderComponent<Search>(p => p.Add(s => s.SearchTerm, "Title%201"));
61+
62+
cut.WaitForState(() => cut.FindComponents<ShortBlogPost>().Any());
63+
var blogPosts = cut.FindComponents<ShortBlogPost>();
64+
blogPosts.Should().HaveCount(1);
65+
blogPosts.Single().Find(".description h1").TextContent.Should().Be("Title 1");
66+
}
67+
}
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Bunit;
2+
using Bunit.TestDoubles;
3+
using FluentAssertions;
4+
using LinkDotNet.Blog.Web;
5+
using LinkDotNet.Blog.Web.Shared;
6+
using Microsoft.AspNetCore.Components;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Xunit;
9+
10+
namespace LinkDotNet.Blog.IntegrationTests.Web.Shared
11+
{
12+
public class NavMenuTests : TestContext
13+
{
14+
[Fact]
15+
public void ShouldNavigateToSearchPage()
16+
{
17+
Services.AddScoped(_ => new AppConfiguration());
18+
this.AddTestAuthorization();
19+
var navigationManager = Services.GetRequiredService<NavigationManager>();
20+
var cut = RenderComponent<NavMenu>();
21+
cut.FindComponent<SearchInput>().Find("input").Change("Text");
22+
23+
cut.FindComponent<SearchInput>().Find("button").Click();
24+
25+
navigationManager.Uri.Should().EndWith("search/Text");
26+
}
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using Bunit;
2+
using FluentAssertions;
3+
using LinkDotNet.Blog.Web.Shared;
4+
using Xunit;
5+
6+
namespace LinkDotNet.Blog.UnitTests.Web.Shared
7+
{
8+
public class SearchInputTests : TestContext
9+
{
10+
[Fact]
11+
public void ShouldReturnEnteredText()
12+
{
13+
var enteredString = string.Empty;
14+
var cut = RenderComponent<SearchInput>(p => p.Add(s => s.SearchEntered, s => enteredString = s));
15+
cut.Find("input").Change("Test");
16+
17+
cut.Find("button").Click();
18+
19+
enteredString.Should().Be("Test");
20+
}
21+
22+
[Theory]
23+
[InlineData("")]
24+
[InlineData(" ")]
25+
[InlineData("\t")]
26+
public void ShouldNotReturnValueWhenOnlyWhitespaces(string input)
27+
{
28+
var wasInvoked = false;
29+
var cut = RenderComponent<SearchInput>(p => p.Add(s => s.SearchEntered, _ => wasInvoked = true));
30+
cut.Find("input").Change(input);
31+
32+
cut.Find("button").Click();
33+
34+
wasInvoked.Should().BeFalse();
35+
}
36+
37+
[Fact]
38+
public void ShouldTrimData()
39+
{
40+
var enteredString = string.Empty;
41+
var cut = RenderComponent<SearchInput>(p => p.Add(s => s.SearchEntered, s => enteredString = s));
42+
cut.Find("input").Change(" Test 1 ");
43+
44+
cut.Find("button").Click();
45+
46+
enteredString.Should().Be("Test 1");
47+
}
48+
}
49+
}

LinkDotNet.Blog.Web/Pages/Admin/DraftBlogPosts.razor

+5-5
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,22 @@
22
@using LinkDotNet.Infrastructure.Persistence
33
@using LinkDotNet.Domain
44
@attribute [Authorize]
5-
@inject IRepository _repository
5+
@inject IRepository repository
66
<h3>Draft Blog Posts</h3>
77

88
<div class="content px-4">
9-
@for (var i = 0; i < _blogPosts.Count; i++)
9+
@for (var i = 0; i < blogPosts.Count; i++)
1010
{
11-
<ShortBlogPost BlogPost="_blogPosts[i]" UseAlternativeStyle="@(i % 2 != 0)"></ShortBlogPost>
11+
<ShortBlogPost BlogPost="blogPosts[i]" UseAlternativeStyle="@(i % 2 != 0)"></ShortBlogPost>
1212
}
1313
</div>
1414

1515
@code {
16-
private IList<BlogPost> _blogPosts = new List<BlogPost>();
16+
private IList<BlogPost> blogPosts = new List<BlogPost>();
1717

1818
protected override async Task OnInitializedAsync()
1919
{
20-
_blogPosts = (await _repository.GetAllAsync(p => !p.IsPublished, b => b.UpdatedDate)).ToList();
20+
blogPosts = (await repository.GetAllAsync(p => !p.IsPublished, b => b.UpdatedDate)).ToList();
2121
}
2222

2323
}

LinkDotNet.Blog.Web/Pages/BlogPostPage.razor

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ else
2424
<header>
2525
<h1>@BlogPost.Title</h1></header>
2626
<div class="blogpost-date">
27-
@BlogPost.UpdatedDate
27+
@BlogPost.UpdatedDate.ToString("dd/MM/yyyy")
2828
</div>
2929

3030
<div class="admin-action">
+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
@page "/search/{searchTerm}"
2+
@using LinkDotNet.Infrastructure.Persistence
3+
@using LinkDotNet.Domain
4+
@inject IRepository repository
5+
<h3>Results for @SearchTerm</h3>
6+
7+
<div class="content px-4">
8+
@for (var i = 0; i < blogPosts.Count; i++)
9+
{
10+
<ShortBlogPost BlogPost="blogPosts[i]" UseAlternativeStyle="@(i % 2 != 0)"></ShortBlogPost>
11+
}
12+
</div>
13+
14+
@code {
15+
[Parameter]
16+
public string SearchTerm { get; set; }
17+
18+
private IList<BlogPost> blogPosts = new List<BlogPost>();
19+
20+
protected override async Task OnInitializedAsync()
21+
{
22+
var term = Uri.UnescapeDataString(SearchTerm);
23+
blogPosts = (await repository.GetAllAsync(t => t.IsPublished && (t.Title.Contains(term)
24+
|| t.Tags.Any(tag => tag.Content == term)),
25+
b => b.UpdatedDate)).ToList();
26+
}
27+
}
+19-7
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
1-
@inject AppConfiguration _configuration
1+
@inject AppConfiguration configuration
2+
@inject NavigationManager navigationManager
23

34
<header class="nav-header">
45
<nav class="nav">
56
<div class="container">
67
<div class="logo">
7-
<a href="#">@_configuration.BlogName</a>
8+
<a href="#">@configuration.BlogName</a>
89
</div>
910
<div id="mainListDiv" class="main_list">
1011
<ul class="navlinks">
1112
<li><a href="#">Home</a></li>
12-
@if (_configuration.HasLinkedinAccount)
13+
@if (configuration.HasLinkedinAccount)
1314
{
14-
<li><a target="_blank" href="@_configuration.LinkedinAccountUrl"><i class="fab fa-linkedin"></i> LinkedIn</a></li>
15+
<li><a target="_blank" href="@configuration.LinkedinAccountUrl"><i class="fab fa-linkedin"></i> LinkedIn</a></li>
1516
}
16-
@if (_configuration.HasGithubAccount)
17+
@if (configuration.HasGithubAccount)
1718
{
18-
<li><a target="_blank" href="@_configuration.GithubAccountUrl"><i class="fab fa-github"></i> Github</a></li>
19+
<li><a target="_blank" href="@configuration.GithubAccountUrl"><i class="fab fa-github"></i> Github</a></li>
1920
}
2021
<AccessControl></AccessControl>
22+
<li>
23+
<SearchInput SearchEntered="NavigateToSearchPage"></SearchInput>
24+
</li>
2125
</ul>
2226
</div>
2327
</div>
2428
</nav>
25-
</header>
29+
</header>
30+
31+
@code {
32+
private void NavigateToSearchPage(string searchTerm)
33+
{
34+
var escapeDataString = Uri.EscapeDataString(searchTerm);
35+
navigationManager.NavigateTo($"search/{escapeDataString}");
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<div class="search-bar">
2+
<input type="text" class="search-bar-input" placeholder="search" aria-label="search" @bind-value="searchTerm"/>
3+
<button class="search-bar-button" aria-label="search submit" @onclick="CallSearchEntered"><i class="fas fa-search"></i></button>
4+
</div>
5+
6+
@code {
7+
private string searchTerm = string.Empty;
8+
9+
[Parameter]
10+
public EventCallback<string> SearchEntered { get; set; }
11+
12+
private async Task CallSearchEntered()
13+
{
14+
if (string.IsNullOrWhiteSpace(searchTerm))
15+
{
16+
return;
17+
}
18+
19+
var trimmed = searchTerm.Trim();
20+
if (trimmed == string.Empty)
21+
{
22+
return;
23+
}
24+
25+
await SearchEntered.InvokeAsync(trimmed);
26+
}
27+
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
.search-bar {
2+
border: 2px solid white;
3+
display: flex;
4+
border-radius: 100vh;
5+
overflow: hidden;
6+
height: 45px;
7+
padding: 3px;
8+
width: 45px;
9+
position: relative;
10+
transition: width 300ms ease-in-out;
11+
}
12+
13+
.search-bar-input {
14+
flex-grow: 1;
15+
padding: 0 .5em;
16+
border: 0;
17+
opacity: 0;
18+
background: transparent;
19+
position: absolute;
20+
top: 0;
21+
bottom: 0;
22+
left: 0;
23+
z-index: 2;
24+
cursor: pointer;
25+
color:white;
26+
}
27+
28+
.search-bar-input:focus {
29+
outline: 0;
30+
}
31+
32+
.search-bar-button {
33+
color: white;
34+
border: 0;
35+
border-radius: 100vh;
36+
margin-left: auto;
37+
width: calc(45px - 10px);
38+
height: calc(45px - 10px);
39+
cursor: pointer;
40+
background: transparent;
41+
}
42+
43+
.search-bar:focus-within {
44+
width: 170px;
45+
}
46+
47+
.search-bar:focus-within .search-bar-input {
48+
cursor: initial;
49+
opacity: 1;
50+
z-index: initial;
51+
max-width: 130px;
52+
}

0 commit comments

Comments
 (0)