In this article we will finish the user interface for the blog app and that will set us up to deploy in part 6 with Azure DevOps Pipelines and Azure services.
We will start with displaying a blog, then work backwards to the landing page where the user can select a blog to read.
In your code editor, open your App.js and add a new route.
<Route path='/blog/:id' component={Blog} />
This sets up the route ‘blog’ and receives a parameter called id. Import the Blog component, then save.
import Blog from './components/Blog'
In the components folder, add Blog.js and add the following imports.
import React, { useState, useEffect } from 'react'
import { Container, Row, Col } from 'reactstrap'
import { useHistory, useParams } from 'react-router'
import './Blog.css'
Now let’s stub in the control.
const Blog = () => {
return (
<h1>Blog!</h1>
)
}
export default Blog
We now have enough code to run the app and we can watch the UI as we progress on the code. We will take advantage of useParams to pull the id from the :id parameter in the route. We will also declare the blog variable. To get the id, we just pull the id from the return value of the useParams() call.
const {id} = useParams()
const history = useHistory()
const [blog, setBlog] = useState()
Next, we will write a function to retrieve the blog from the API, it will route the user back to the home directory if the id is missing or the blog is not found.
const getBlog = async () => {
if (!id) {
history.push('/')
}
const response = await fetch(`/api/blog/${id}`)
if (response.ok) {
const json = await response.json()
setBlog(json)
} else {
history.push('/')
}
}
With some basic logic coded, we can update the markup to use the data. Of course, we could add some exception handling, and that would be needed for a more robust production environment, but for our purposes, we will use this. Update the return value of the control to display the title and blog content. Since we are storing raw HTML in the database, we have to dangerously set the HTML in the control. This is not something we would do if we were accepting input from users, but our input is strictly from us, so we will trust it.
<Container fluid>
{!!blog &&
<>
<Row>
<Col xs={0} sm={0} lg={1} />
<Col xs={12} sm={12} lg={10}>
<h1>{blog.title}</h1>
</Col>
<Col xs={0} sm={0} lg={1} />
</Row>
<Row>
<Col xs={0} sm={0} lg={1} />
<Col xs={12} sm={12} lg={10}>
<div dangerouslySetInnerHTML={{ __html: blog.article }} />
</Col>
<Col xs={0} sm={0} lg={1} />
</Row>
</>
}
</Container>
You’ll notice we don’t render the rows until the blog has been loaded. This is a good place for a loading GIF, if your system is not fast enough. Navigate to Blog.css and add some styling.
.blog-display {
}
.blog-display pre {
margin-bottom: 0rem;
background-color: black;
color: white;
}
.blog-display p {
font-size: 1.75em !important;
}
.blog-display span {
font-size: 1.75em !important;
}
.blog-display h1 {
font-size: 4em !important;
}
@media (min-width: 768px) {
.blog-display h1 {
font-size: 2.75em !important;
}
.blog-display p {
font-size: 1.1em !important;
}
.blog-display span {
font-size: 1.1em !important;
}
}
Save that and pull an id out of your database using the Cosmos DB explorer. Then navigate to /blog/yourid and see how it looks.
And I got the following error result:
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-a35966196a2963961b744f559568a20e-486d9ef840c8b302-00","errors":{"id":["The id field is required."]}}
Going back to the BlogController I can see my mistake. I declared the Get as follows:
public IActionResult Get([FromQuery] string id)
But the [FromQuery] modifier tells the engine that id is coming in as a query string parameter. We are using a URL parameter, so we need to remove that modifier, then run again.
public IActionResult Get(string id)
One of the benefits of that mistake is that we saw that the redirect logic works in the control. Run the app again and you should see your blog entry.
Let’s go back to NewBlog.js and add in the code to redirect to the newly input blog. First add the import.
import { useHistory } from 'react-router'
Add the history variable to the beginning of the function.
const history = useHistory()
Then added the redirect to handleSubmit, removing the unnecessary code.
if (response.ok) {
const json = await response.json()
history.push(`/blog/${json.id}`)
}
Login and add a new blog and verify it all works.
Navigate to Home.js and replace the entire contents of the file with this code.
import React, { useEffect, useState } from 'react'
const Home = () => {
return (
<h1>Landing!</h1>
)
}
export default Home
Next, go to App.js and fix the export for the new Home declaration.
import Home from './components/Home'
In your browser and navigate to /
If you see your amazing new landing page, you are ready to continue. Open Home.js and add the following imports.
import { Container } from 'reactstrap'
import BlogItem from './BlogItem'
Then to get it building again, add BlogItem.js to the components folder with the following code.
import React from 'react'
import {Row, Col} from 'reactstrap'
const BlogItem = ({blog}) => {
return (
<h1>
Item!
</h1>
)
}
export default BlogItem
Back to Home.js, time to get the blog titles from the API. We coded the API to sort the blogs from newest to oldest, so we don’t have to do that in the UI. We will fetch the list and useEffect to cause the fetch to happen on load.
const [blogs, setBlogs] = useState([])
const getBlogTitles = () => {
fetch('/api/blog')
.then(response => {
if (response.ok) {
response.json()
.then(json => {
setBlogs(json)
})
}
})
}
I had three blogs entered into the test system, so I now see this. You should see something similar.
Let’s fill in the BlogItem container and wrap this UI up. We’ll start with displaying the title as a link. Clicking the link brings us to the blog.
import {Link} from 'react-router-dom'
...
<Row className='landing-item'>
<Col>
<Link to={`/blog/${blog.id}`}>{blog.title}</Link>
</Col>
</Row>
No? “What about the preview your site has?” you ask. To be honest, my first UI was very ugly, enough that all my peers told me so. So, when I started this series, I had not yet refactored my site to do the preview and display the blogs in a more appealing manner. But let’s do that for some bonus material.
To start, we need to backfill the existing blogs with a fragment of the content. I’ve opted to parse the fragment once and store it for better performance. Stop your debugging session and navigate to blog.data project. Add a new folder called Extensions and add a class called BlogExtensions. Now add the NuGet package called Microsoft.Xml.SgmlReader. Insure you get the .Net core package and not the .Net 4.7 package.
Once you have the package added, make the class BlogExtensions a static class and add an extension method called ToFragment.
internal static class BlogExtensions
{
public static string ToFragment(this string content)
{
}
}
Now we’ll parse the article HTML, grab only the text, and truncate it so somewhere around 256 characters, but could get as big as 512 characters with this logic. One of the issues we’ll overcome is that there is no root document for the stored HTML, so we will fudge it. Since we are not running this code on every render, I’m not worried about the performance of this.
public static string ToFragment(this string? content)
{
if (string.IsNullOrWhiteSpace(content))
{
return content ?? string.Empty;
}
var frag = new StringBuilder(512);
using (var sgml = new Sgml.SgmlReader())
{
sgml.DocType = "HTML";
using (var reader = new StringReader($"<body>{content}</body>"))
{
sgml.InputStream = reader;
var doc = new XmlDocument();
doc.Load(sgml);
if (doc.DocumentElement == null) return content;
var node = doc.DocumentElement?.FirstChild?.FirstChild ?? doc.DocumentElement?.FirstChild;
while (frag.Length < 256 && node != null)
{
var text = node.InnerText
.Substring(0, node.InnerText.Length > 256 ? 256 : node.InnerText.Length)
.Replace(" ", " ");
frag.Append(text);
frag.Append(" ");
var prev = node;
node = node.NextSibling;
if (node == null)
{
node = prev.ParentNode?.NextSibling?.FirstChild;
}
}
}
}
return frag.ToString().Trim();
}
public static Title ToTitle(this Blog blog)
{
return new Title
{
fragment = blog.fragment ?? string.Empty,
id = blog.id ?? string.Empty,
title = blog.title ?? string.Empty,
publishedAt = blog.publishedAt
};
}
Now, we will do three tasks. First, we will add the fragment to the Title class. Second, we will update the getter to detect if the fragment already exists, if it does not, we will create it and write it back to the database. Third, we will update the create method to generate the fragment.
public record Title
{
public string id { get; init; } = string.Empty;
public string title { get; init; } = string.Empty;
public DateTime publishedAt { get; init; } = DateTime.MinValue;
public string fragment { get; init; } = string.Empty;
}
public record BlogEntry
{
public string title { get; init; } = string.Empty;
public string article { get; init; } = string.Empty;
public string fragment { get; init; } = string.Empty;
}
public class Blog
{
public string? id { get; set; }
public string? title { get; set; }
public string? article { get; set; }
public string? fragment { get; set; }
public DateTime publishedAt { get; set; }
}
Now, when we get the title record, we need to check for an empty fragment. In BlogData, update BlogData to check for the empty and update the blog if it is empty.
public string Add(BlogEntry entry)
{
var blog = entry.ToBlog();
blog.fragment = entry.article.ToFragment();
blog.publishedAt = DateTime.UtcNow;
blog.id = ShortId.NewId();
using (var client = new CosmosClient(uri, key))
{
var container = client.GetContainer(databaseId, collection);
container.Database.CreateContainerIfNotExistsAsync(collection, "/id").Wait();
container.CreateItemAsync(blog, new PartitionKey(blog.id)).Wait();
}
return blog.id;
}
public IList<Title> GetTitles()
{
using (var client = new CosmosClient(uri, key))
{
var container = client.GetContainer(databaseId, collection);
var response = new List<Title>();
var query = container.GetItemQueryIterator<Title>();
while (query.HasMoreResults)
{
var result = query.ReadNextAsync().Result;
response.AddRange(result.Select(r => WithFragment(r)).ToList());
}
return response;
}
}
private Title WithFragment(Title input)
{
if (string.IsNullOrWhiteSpace(input.fragment))
{
var blog = Get(input.id);
if (blog == null)
{
throw new NullReferenceException(nameof(input));
}
blog.fragment = blog.article.ToFragment();
UpdateEntry(blog);
return blog.ToTitle();
}
return input;
}
public void UpdateEntry(Blog entry)
{
using (var client = new CosmosClient(uri, key))
{
var container = client.GetContainer(databaseId, collection);
container.UpsertItemAsync(entry, new PartitionKey(entry.id)).Wait();
}
}
With that, let’s run the system and the update our client side code to use the fragments. Add a new css file called BlogItem.css
.landing-item {
background-color: rgba(0,0,0,0.1);
margin-bottom: 5px;
}
.landing-item-button {
padding-top: 8px;
}
Then update the output of BlogItem as follows.
const BlogItem = ({blog}) => {
const history = useHistory()
const onClick = () => {
history.push(`/blog/${blog.id}`)
}
return (
<Row className='landing-item'>
<Col sm={12} xs={12} lg={10}>
<Link to={`/blog/${blog.id}`}>{blog.title}</Link>
<p>{blog.fragment}...</p>
</Col>
<Col sm={12} xs={12} lg={2} className='landing-item-button'>
<Button onClick={onClick} color='primary'>See More...</Button>
</Col>
</Row>
)
}
Now you have a plain, but workable UI for the site. I’ll leave skinning the site to you. Next, and final, article we will build our pipeline, configure Azure, and deploy.
As always, the code can be found here.
Please drop comments below.