Ch. 47 – Adding Tooltips
When you hover over an icon a tiny label appears explaining what the button does. We could write this from scratch but in a way that misses the point of using a library as popular as React. The React ecosystem is full of all sorts of plugins or packages (we should call them pre-existing components). No point in re-inventing the wheel when we can just grab a package from NPM that will make it very easy to create tool tips.
Install “react-tooltip” that gets 300,000+ downloads a week and import “ReactToolTip” at the top of “ViewSinglePost.js” file.
On the opening anchor tag that is wrapped around the edit icon add a “data-tip” attribute (which is what the user will see when they hover over it).
Also add a “data-for” attribute of “edit.” Just after the closing anchor tag add a self-closing component tag of “<ReactTootip />” and give it an “id” that must match the “data-for” value and a “className” of “custom-tooltip.”
Now when we hover over the edit button we should see the tool tip.
Note: If you see the browsers built-in hover then remove the “title” attribute from the element.
Do the same thing for the delete button.
We will need to add a little horizontal spacing using the trick mentioned a few chapters earlier in which you add white space by putting a space inside quotes inside curly brackets.
Ch. 48 – Edit (Update) Post Component
Create a new “EditPost.js” component.
In the “Main.js” file, import the “EditPost.js” file, right below the “<ViewSinglePost />” route, set up a “<Route />” with a “path” prop of “/post/ :id/edit.”
For this route, and the “post/:id” route, add the “exact” keyword to make sure there is no confusion.
In the “ViewSinglePost.js” file, scroll down to the JSX area and convert anchor elements to a “<Link>” elements and convert “hrefs” to be “to.”
Copy all the content (literally everything) from the “ViewSinglePost.js” file and paste it into the new “EditPost.js” file.
The only thing that’s different is the JSX once the loading is completed. It should look very similar to the JSX inside the “CreatePost.js” file so copy and paste the JSX code from there and paste into the “EditPost.js” file and make a few adjustments.
These adjustments include updating the “Page” “title” prop to be “Edit Post” and removing the “onSubmit” listener for the “form” element and the “onChange” listeners for both of the “input” elements.
Now we pre-populate the two fields with the post data you’ve already loaded by giving each input a “value” of “{post.title}” and “{post.body}.”
This pre-populates the field but a user can’t type into the fields. This is because, by giving the inputs a “value,” React considers them a “controlled input” and to actually be able to type in them again you have to give them “onChange” props. We just removed these because we want to handle things a little differently from when we the created “CreatePost” component. We want to set up in a more robust fashion, so that we can add client-side validation, so if a field is left blank we don’t even want to send a request to the server. We will set things up with a “reducer” and “dispatch.” The state our “reducer” manages is what will be sent to the server when the user tries to submit the update.
You don’t need to use “useReducer” to do more complex things in React, but it’s much easier, and this feature qualifies as handling complex data.
Update the button text to say “save updates” instead of “save new post.”
Set up the “immer” package so it’s easier to deal with the complex object within our state. At the top of the “EditPost.js” file, import “{useImmerReducer}” from “use-immer.”
Get rid of the “markdown” and “react-tooltip” imports since we’re not using those packages in this component.
At the beginning of the function at the top of the “EditPost.js” file, set up the constant variable of “state” and “dispatch” extracted from a “useImmerReducer” with two arguments. The first argument is a function that will serve as our reducer. The second argument is our initial state.
const [state, dispatch] = useImmerReducer(ourReducer, originalState)
Create the “ourReducer()” function (first parameter) just above.
Above the reducer function create a constant variable named “originalState” set equal to an object. In this “originalState” object set a few properties.
const originalState = {
title = {
value = "",
hasErrors = false,
message = ""
},
body = {
value = "",
hasErrors = false,
message = ""
},
isFetching: true,
isSaving: false,
id: useParams().id
sendCount: 0
}
So we’re actually going to need two “axios” requests, the first request to pull in or load the existing data (title and body values from the existing state) and a second request when you actually want to update the data.
Before working on the first request, since we are using the “useReducer,” you can delete the constant variable “{id}” equals “useParams()” line and the two lines leveraging “useState.”
So in the “axios” request that loads the existing content you can get rid of the “setPost” and “setIsLoading” lines and instead have a dispatch with a “type” of “fetchComplete” and a “value” set to “response.data.”
dispatch({type: "fetchComplete", value: response.data})
Note: this is the first time we are seeing a reducer being used in a file other than the “Main.js” file. This is why we use “dispatch” instead of a “appDispatch.”
Inside your reducer function create a case for what happens to the state when the “fetchComplete” action occurs. Start by giving your reducer function two parameters, “draft” and “action” and inside the body of the function, create a switch statement based on “(action.type)” and define different cases starting with “fetchComplete.”
function ourReducer(state, dispatch) {
switch (action.value) {
case "fetchComplete":
draft.title.value = action.value.title
draft.body.value = action.value.body
draft.isFetching = false
return
}
}
Update the code below to pull data from our new reducer setup. In the “axios.get()” request, the URL is no longer just “${id}” but “${state.id}.” Down in the JSX, where it says “if (isLoading)” show the “LoadingDotsIcon,” change this code to be “if (state.is fetching).” Underneath that “if statement” we no longer need the date-related lines so remove them.
Update the existing JSX update the input “value” prop from “{post.title}” to be “{state.title.value}” and in the “textarea,” update the “value” prop to be “{state.body.value}.”
Ch. 49 – Edit Post Continued
In your “EditPost.js” file, go down to the JSX and on the input for the “title” field give it an “onChange” prop and set it to an arrow function inside of curly brackets and call your dispatch and give it an object with a property of “type” set to “titleChange” and another property of “value” set to “e.target.value,” making sure “e” is passed as a parameter into the arrow function.
In the reducer function you want to store the value of “e.target.value” in state so set up another “case” for “titleChange” and have its “draft.title.value” property set equal to “action.value” and then “return.”
In the JSX for in the “textarea” give it an “onChange” prop and inside curly brackets give it an arrow function with “e” as a parameter that runs dispatch, which gets an object that has a property of “type” set to “bodyChange” and another property of “value” set to “e.target.value.”
In the switch account for this new action by creating a new “case” of “bodyChange” and modify state accordingly by having “draft. body.value” equal to “action.value” and then “return.”
Set things up so when the user clicks the “save updates” button it actually sends the update to the server to save new values in the database.
In the JSX find the opening “form” tag and give it an “onSubmit” prop equal to “{submitHandler}.”
Up above in the code, just above the “useEffect,” create this “submitHandler” function and pass it a parameter “e.” In the function block, prevent the default and then give it a dispatch that receives and object that has property of “type” of “submitRequest.”
In the reducer function create a “case” for “submitRequest” and have it increment the value of the “sendCount” by having “draft.sendCount++” and “return.”
That reason that we increment “sendCount” is we don’t want to have code within the reducer that sends an “axios” request. A network request is considered a “side-effect” and doesn’t have anything to do with React inherently, it’s just a web browser-ish task. So leverage “useEffect” to watch for when “sendCount” changes since “useEffect” is the appropriate place to send a network request.
In order to save a bunch of typing, duplicate the entire “useEffect” where we’re using “axios” to send a “get” request. On the duplicate copy, instead of having empty square brackets as the dependency, have “state.sendCount.”
We don’t want this to run, and have the request sent, when the page first loads and “sendCount” is set to zero. To handle this take everything in the beginning of your “useEffect” and cut it into your clipboard and create an if statement that says “if (state.sendCount), which means “state.sendCount” is not equal to zero, and then paste back into clipboard. This will ensure the request is only sent when the “sendCount” state is greater than zero.
Now modify your request from “get” to “post” and update the URL to add “/edit” to the path and you need to add a data object to send to the server as a second parameter after your URL, bumping the cancel token to become the third parameter.
The data is an object with a property of “title” set to “state.title.value” and a “body” property set to “state.body.value” and a “token” property that pulls from “app-wide state.” In order to do this, import “StateContext,” and gain easy access to “{useContext}” from “react.”
Note we could have pulled the token data directly from the browser’s local storage but remember the tip that all local storage work should happen in the “Main.js” file to prevent bugs.
Just inside the component’s “ViewSinglePost” function, create a constant variable named “appState” and set it to “useContext(StateContext).” Back in the “axios” request, for the “token” property give it a value of “appState.user.token.”
Just under the “Axios.post” line, temporarily, let’s get rid of the dispatch line and have it be an “alert” that says “Congrats! Post updated.”
The edit functionality should now be working and we can now focus on the user experience part of the edit feature.
We want the “save updates” button to be disabled while the post request is happening but has not yet finished. We also want to show a flash message, when the process finishes, to show a success message.
In the “useEffect” with the “post” request, just inside the “if (state.sendCount)” block, have a dispatch that gets an object with a “type” property set to “saveRequestStarted.” Also remove the alert line you just created and have a dispatch that gets and object with a property of “type” set to “saveRequestFinished.”
Set up the corresponding “actions” in the reducer by adding a “case” for “saveRequestStarted” and that sets “draft.saveRequestStarted” to true and “returns”
Also add a “case” of “saveRequestFinished” that sets “draft.isSaving” to false and “returns.”
To disable the button go down to the JSX for the button and give it a new attribute/prop called “disabled “and set it to “{state.isSaving}” so that if “isSaving” is set to true it will be disabled.
We’re not going to do it here but for extra credit, we could also conditionally change the text in the button to be “Saving…” while the process is attempting.
We can test the “disabling of the button” by throttling your network speed.
To display a flash message we’re going to want to use our “app-wide dispatch” and so we’re going to need to import “DispatchContext” and just inside the component’s function ‘ViewSinglePost,” (This will soon be changed to “EditPost”), set a constant variable of “appDispatch” to “useContext(DispatchContext).”
Now we can leverage this in our “Axios.post” block, just under the “dispatch({type: “saveRequestFinished”})” line, we can say “appDispatch” give it a object which has a property of “type” set to “flashMessage” and a “value” of “Post was updated.”
Ch. 50 – Client-Side Form Validation
We don’t want the users to be able to submit an edit-post request if the fields are left blank. INstead we ant to show and error.
Before we set up the logic for that in your “EditPost.js” file look within the JSX for the title’s “input” field and just below line, create a new “div” element and on the opening tag, give it a “className” set to “alert alert-danger small liveValidateMessage.” Inside of this newly created “div” element, give it “Example error message should go here text.”
We want this “div” element to be displayed conditionally so to make this happen we’ll take a look at our state and inside our “title” and “body” properties we have “hasErrors” set to false and we’ll want to adjust our JSX to have the error message only display if this “hasErrors” state is set to true.
Wrap this entire newly-created “div” element inside some conditional logic to only show if there are errors.
{state.title.hasErrors &&
<div></div }
You can test this feature by manually changing the “hasErrors state back to true.
Now we would want to make sure when a user clicks off a field or it loses focus, if the field is empty, it would change your “hasError” state to true and the error would be shown.
Down in the JSX on the title field’s input, where we already have an “onChange” prop, add an “onBlur” prop that is set to an arrow function, inside of curly brackets, where “e” is the parameter and the function will call dispatch with an object that has a property of “type” set to “titleRules” and a property of “value” set to “e. target.value.”
In your reducer add a “case” for “titleRules” that includes an “if statement:”
case "titleRules":
if (!action.value.trim()) {
draft.title.hasErrors = true
draft.title.message = "You must provide a title."
}
return
Note that this is the first time we are seeing an “if statement” in a “case” of the switch in the reducer function.
Also note that the trim() method is being used to remove any white space so hitting the space bar still counts as an empty field.
Also note that if you wanted more complex validation logic (only alphanumeric characters for example), you could have more “if statements” with corresponding messages.
Now if you were on this edit page and delete the title field so it’s empty and then click off of the field, you’ll get a flash message with the default value we just set.
Adjust the JSX to not show hard-coded error message, but instead pull from state, by adding “{state.title.message}” inside of the conditional “div” element.
Add a “submitRequest” “case” by cutting “draft.sendCount++” line into your clipboard to wrap in an “if statement” in which the condition “(!draft.title.hasErrors && !Draft.body.hasErrors) then paste back in your clipboard. This way the form only submits if both fields are not empty.
At this point you will get a flash message if the button is clicked but if the user just hits enter they’ll still be able to send the post request. It will fail on the back-end server but we still want to handle for this situation so it doesn’t even appear to be submitted. So we need to run the validation logic, not only when you blur off of a field, but also when the form is submitted.
In the “submitHandler” function, just under the “e.preventDefault()” line, add a dispatch that gets an object with a property of “type” set to “titleRules” and a property of “value” set to “state.title.value.”
This way we are not duplicating the logic and it runs before the form is submitted.
Set up the same validation for the “body” content field using dispatch to trigger “bodyRules.” On the “textarea” tag in the JSX, add an “onBlur” prop:
<textarea onBlur={e => dispatch({type: "bodyRules", value: e.target.value })} />
Duplicate the”titleRules” “case” to create a “bodyRules” “case.”
Also add a dispatch to the “submitHandler” function that triggers the “bodyRules” check “case” before the form submits. Now we just need to show an error if there is an issue with the body field. Duplicate the existing “div” element being shown conditionally for the title field and tweak.
Tweak the logic to make sure if an error is fixed (for example, the user goes from blank to content we then remove the error message). In the reducer function, in the “titleChange” and “bodyChange” “cases,” add “draft.title.hasErrors = false” and “draft.body.hasErrors = false” to now get a fresh start when a field is updated.
Ch. 51 – Quick Attention to Detail Features
Add a link to the “EditPost.js” file so a user can navigate “back to the post’s permalink.”
<Link to={`/post/${state.id}`} className={small font-weight-bold}> « Back to post permalink</Link>
Add some spacing by giving the “form” element that follows a little bit of top margin using “mt-3” class.
If someone visits the URL for a post that has an id that doesn’t even match what’s in the database show a 404 page. Add another property to our originalState in the “EditPost.js” file, named “notFound.”
In the “useEffect” that initially loads the data for the screen, cut the “fetchComplete” dispatch line into our clipboard and wrap in an “if/else statement,” that runs this code if there is data (past back in code) or else updates the “notFound” state from false to true via a dispatched action named “notFound.”
// in the useEffect just under Axios.get line
if (response.data) {
dispatch({type: "fetchComplete", value: response.data })
} else {
dispatch({type: "hasErrors"})
}
// in reducer function switch statement
case "notFound":
draft.notFound = true
return
Now down in JSX, just before the “if (state.isFetching)” check, create another “if statement”
if (state.notFound) {
return (
<Page title="Not Found">
<div className="text-center">
<h2>Whoops, we cannot find that page</h2>
<p>You can always visit the homepage to get a fresh start</p>
</div>
</Page>
)
}
We should make this bit of JSX live in its own component so it can be used elsewhere. Create a new “NotFound.js” file and paste in code and be sure the “Page” and “{Link}” is being imported.
Now back in the “EditPost.js” file we can delete the returned JSX code, swapping it for the new component, (be sure to import).
if (state.notFound) {
return (
<NotFound />
)
}
Leverage this new component for a single post as well by jumping into “ViewSinglePost.js” file and importing. Implementing will be a little differetn because we are not using “useReducer.” We could use “useState” to register a piece of state but instead, jus above the “if (isLoading)” line,
if (!isLoading && !post ) {
return <NotFound />
}
Note that “!post” means evaluate to false or undefined.
Also leverage this “NotFound” component in the “Main.js” for handling any bogus URLs. Import the “NotFound” component and, create a new “Route” that should be the last “Route” and used as a fallback “Route” since we don’t give it a “path” prop. Inside of the “Route” tags, render the “NotFound” component.
Visitors to an edit-post URL, in which they are not the owner or author, should be shown an error message and redirected back to the homepage.
Add some code in the “useEffect” with your “Axios.get()” request just underneath the dispatch for “fetchComplete” but still in the “if block,” add another “if block” to see if the visitor is the owner.
if (appState.user.username != response.data.author.username) {
appDispatch({type: "flashmessage", value: "You do not have permission to edit that post"})
// redirect to homepage
props.history.push("/")
}
Accomplish the redirect with the help of “withRouter,” by importing from “react-router-dom” and go down to the very very bottom of the “EditPost.js” file and update the “export default blank” line to have component name passed into “withRouter().”
export default withRouter(EditPost)
Note: In this situation we forgot to rename this component’s function to “EditPost” earlier when we copied code from the “ViewSinglePost.js” file. While we’re here renaming ensure “EditPost” is being passed “props” as well.
Back in the “useEffect” for the “Axios.get(),” underneath the “appDispatch({type: “flashMessage”…}) line, add “props.history.push(“/”)”
Ch. 52 – Delete Post
Conditionally show the edit and delete buttons only if the visitor is the owner.
In the “ViewSinglePost.js” file create a function named “isOwner” just above the JSX.
function isOwner() {
}
Realize that it will be the job of this function to return either true or false. Come to this conclusion by thinking about how you would leverage in the JSX. Just under the “h2” of “{post.title}”
{isOwner() && (
// include conditionally JSX here if isOwner returns true
)}
Cut the span containing the edit and delete button code.
To actually make the “isOwner” function “return” true or false start with an “if statement” with a condition to see if the user is even logged in by looking at app-wide state.
Make sure you have quick access to “{useContext}” from “react” and import “State Context” from your “StateContext.js.” Just inside the component’s function, create a constant variable named “appState” set to “usedContext(StateContext).”
Now back in the “isOwner” function, in the “if statement” condition:
function isOwner() {
if (appState.loggedIn) {
return appState.user.username == post.author.username
}
return false
}
Note: the idea behind this logic is that “if you’re not even logged in you’re definitely no the owner of this post.” So don’t even bother checking unless the user is logged in.
Also Note: We could also check local storage to see if the user is logged in but, again, any local storage work should happen in the “Main.js” file.
We want to show an “Are you sure you want to delete this post?” message using the browser’s built-in “confirm” method. Add an “onClick” prop to the anchor element that holds the delete icon and have it trigger a “deleteHandler” function. Create the “deleteHandler” function, by setting a constant variable named “areYouSure” set to “window.confirm(“Do you really want to delete this post?”)”
async function deleteHandler() {
if (areYouSure) {
try {
const response = await Axios.delete(`/post/${id}`, {data: {token: appState.user.token}})
if (response.data == "Success") {
// 1.) display a flesh message
dispatch({type: "flashMessage", value: "Post was successfully deleted."})
// 2.) redirect back to the current user's profile
}
} catch (e) {
console.log(e)
}
}
}
Note that we can use “response.data == ‘success'” code because the back-end has been written that way.
We will want to display a flash message that shows the “post was successfully deleted.” but to do this, we will need to leverage “app-wide dispatch.” Bring this in the same way we brought in “app-wide” state earlier.
In order to do the redirect we need to bring in “withRouter” from “react-router-dom” and at the very bottom where it says “export default” put “ViewSinglePost” inside the “withRouter()” parentheses.
Note: when using “withRouter” you need to make sure your component’s function is receiving “props” as a parameter.
Now you can have “props.history.push()” and inside of “push()” pass the URL to send to the current logged-in user’s profile:
props.history.push(`/profile/${appState.user.username}`)
And we’re all set to delete posts.