There gonna be many JS code sections, and the best way to learn something is to go through this knowledge by yourself but you always can get the whole code here.
In our ongoing journey of building a Content Management System (CMS), we've already established the foundation for creating and listing posts. Now, it's time to tackle one of the most critical aspects of content management: post-editing. In this tutorial, we'll dive deep into creating a post-editing system that combines React's dynamic frontend capabilities with Node.js's backend infrastructure.
To transform our vision into reality, we'll first decide on the post-update workflow. I would like to have a functionality that would allow us to open existing posts inside the "Post Form" by "id" or by URL "slug", we would modify post data and press the "save" button, and then our server would modify our post in the backend. Okay, I think in this case it would be better to start from the backend and prepare server infrastructure for this new feature.
1. Create New Routes for Post Editing in Node.js
We will need a few new routes, like the route for getting posts by "slug" (in web development, a slug is the user-friendly, URL-safe, and human-readable part of a URL that identifies a specific page or resource, typically derived from the title), route for update post itself, and probably that would be nice to have a remove post route and functionality in the future. Okay let's jump into coding:
- open the "posts.router.js" file inside the "server" directory and add three more routes;
postsRouter.get('/:slug', postsController.getPostBySlug);
postsRouter.post('/update', postsController.updatePost);
postsRouter.post('/remove', postsController.removePost);
Yes, I know that for the "remove" functionality I should use "DELETE", but I like to use "POST" because of better control, and the possibility to return some modified data back to the client.
- create new three controllers: the first one will get "slug" from params, then make a model call, and send back the post; the second one will get an updated post from the request body, send it to the model, and then send back to the client updated post; and the third one that will get the post from request body, then call the model and return removed post to the client;
async function getPostBySlug(req, res) {
const { slug } = req.params;
try {
const post = await postsModel.getPostBySlug(slug);
return res.status(200).json({
status: 200,
post
});
} catch (error) {
return res.status(500).json({
status: 500,
message: 'Internal server error'
});
}
}
async function updatePost(req, res) {
const { post } = req.body;
try {
const updatedPost = await postsModel.updatePost(post);
return res.status(200).json({
status: 200,
message: 'Post updated successfully',
post: updatedPost
});
} catch (error) {
return res.status(500).json({
status: 500,
message: 'Internal server error'
});
}
}
async function removePost(req, res) {
const { post } = req.body;
try {
const removedPost = await postsModel.removePost(post);
return res.status(200).json({
status: 200,
message: 'Post removed successfully',
post: removedPost
});
} catch (error) {
return res.status(500).json({
status: 500,
message: 'Internal server error'
});
}
}
- open the "posts.model.js" file (which stores functions that querying our "MongoDB" with the help of "Mongoose"), and we will use the "findOne" method with the "slug" as a parameter (In Mongoose, findOne is a query method that retrieves the first document matching the specified condition (e.g., { slug }) from the MongoDB collection.), "findOneAndUpdate" (In Mongoose, findOneAndUpdate is a query method that finds a single document matching the specified condition (e.g., { _id: payload._id }), updates it with the given data, and optionally returns the modified document when { new: true } is set.), and "deleteOne" (In Mongoose, deleteOne is a query method that removes the first document matching the specified condition (e.g., { _id: payload._id }) from the MongoDB collection.);
async function getPostBySlug(slug) {
try {
const post = await posts.findOne({ slug });
return post;
} catch (error) {
console.error('Error getting post by slug:', error);
throw error;
}
}
async function updatePost(payload) {
try {
const updatedPost = await posts.findOneAndUpdate({ _id: payload._id }, payload, { new: true });
return updatedPost;
} catch (error) {
console.error('Error updating post:', error);
throw error;
}
}
async function removePost(payload) {
try {
const removedPost = await posts.deleteOne({"_id": payload._id});
return removedPost;
} catch (error) {
console.error('Error removing post:', error);
throw error;
}
}
And that's it, we finished with our backend part, we can call created new routes and update our Mongo database from the server, and now we can move to the client side.
2. Frontend Editing Interface Modernization
As was mentioned earlier we will add a new click event to the "Edit" button in the "Action" menu list in the posts table. That event will redirect the client to the "Post Form" page and add a post "slug" to the main URL. After the "Post Form" page is entered, we will check if the URL has a "slug" and send a request to the backend with that string, just to get post data, and populate our form with that data. The client will have a chance to update that data and press the "Save" button to send an updated post to the server. Looks complicated, so let's do all this modernization step-by-step:
- first, we need to add three new endpoints (that we created in the previous stage) into our "posts.service.js" file;
export const getPostBySlug = (slug) =>
HTTP.get(`/posts/${slug}`).then(({data}) => data );
export const updatePost = (data) =>
HTTP.post('/posts/update', data).then(({data}) => data );
export const deletePost = (data) => {
HTTP.post('/posts/remove', data).then(({data}) => data );
}
- open our "TableComponent" file, import the "useNavigate" hook, and set the "onClick" event on the "Edit" menu button that will redirect to the "Post Form" page;
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const handleEdit = (item, event) => {
event.preventDefault();
navigate(`/post-form/${item.slug}`);
};
<MenuItem onClick={(event) => handleEdit(selectedItem, event)}>Edit</MenuItem>
- we will need to have a first, not modified version of our post (to check if the post was updated, and what exactly was updated), so for that purpose, we will use our Redux store. Types: create a new "SET_POST_CLONE" type, add a new "updatedPostClone" reducer, don't forget to add new action and selector also;
SET_UPDATE_POST_CLONE: 'post/SET_UPDATE_POST_CLONE', // new type
export const aSetUpdatePostClone = (post) => { // new action
return createAction(POST_ACTION_TYPES.SET_UPDATE_POST_CLONE, post);
};
updatePostClone: {}, // initial state
case POST_ACTION_TYPES.SET_UPDATE_POST_CLONE:
return { ...state, updatePostClone: payload }; // new reducer
export const sUpdatePostClone = (state) => state.post.updatePostClone; // selector
- now we can go to the "PostForm" component. We need to import the newly created "action", "selector", and "useEffect" hooks from React, and the "useParams" hook from Router;
import React, { useState, useEffect } from "react";
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import { aSetUpdatePostClone } from '../../store/posts/post.action';
import { sUpdatePostClone } from '../../store/posts/post.selector';
const dispatch = useDispatch();
const updatePostClone = useSelector(sUpdatePostClone);
const navigate = useNavigate();
const { slug } = useParams();
- let's create a "fetchPostData" function, that will send a request to the server and get back the post data, then save that post data into the storage, and into our "postForm" state. This function should be called from the "useEffect" hook, and only if the "slug" exists, because if we use this form for creating new posts then we simply can not get such data;
useEffect(() => {
if (slug) {
fetchPostData(slug);
}
}, [slug]);
const fetchPostData = async (slug) => {
const response = await getPostBySlug(slug);
if (response.status === 200) {
setpostForm(prev => ({
...prev,
...response.post
}));
let postDeepClone = _.cloneDeep(response.post);
dispatch(aSetUpdatePostClone(postDeepClone));
} else {
dispatch(aPushNewNotification({ type: 'error', message: response.message }));
}
};
- we prepared our "ImageUploader" and "QuillEditor" components for the edit functionality, and now we should simply update the properties values in the "renderSectionComponent" so that they can receive data (images, and text) and render that data in the form;
const renderSectionComponent = (type, index) => {
switch (slug && typeof type !== "string" ? type.type : type) {
case 'image':
return (
<div className="post-form__container--header">
<div className="post-form--item">
<ImageUploader
key={index}
index={index}
onEditPostImage={slug && typeof type !== "string" ? type.file : null}
onImageUpload={handleImageUpload}/>
</div>
<div className="post-form--item">
<TextField
className="post-form--item-input"
label="Image Description"
id="outlined-size-normal"
value={slug ? type.description : ''}
onChange={(e) => handleImageDescritionChange(e, index)}/>
</div>
<div className="post-form--item">
<TextField
className="post-form--item-input"
label="Image Alt Text"
id="outlined-size-normal"
value={slug ? type.alt : ''}
onChange={(e) => handleImageAltTextChange(e, index)}/>
</div>
</div>
);
case 'text':
return (
<div className="flex-column">
<QuillEditor
key={index}
index={index}
content={slug && typeof type !== "string" ? type.content : null}
onChange={handleQuillTextChange} />
</div>
);
default:
return null;
}
};
Nice. Now, we can relaunch our app, and use the "Edit" button, as a result, we will be redirected to the "Post Form" page, and data will be prepopulated in 1-2 seconds.
- we have a "handleSubmit" function that sends a new post to the backend, and we will simply modify it with an additional check of "slug", if "slug" exists, then we will use the update feature and not create. Also, we need to check if the main image is the same as was, if not we will remove it and send another, and the same thing we should do to all body images. After that we will push the "update" value to the post body and send the updated post to the server;
const handleSubmit = async (e) => {
e.preventDefault();
if (slug) {
setLoading(true);
if (!postForm.mainImage.fileData && !postForm.mainImage.file.name) {
console.log("Please upload a main image");
dispatch(aPushNewNotification({type: 'error', text: 'Please upload a main image'}));
setLoading(false);
return;
}
// remove images from the storage, if they were removed from the form
for (let i = 0; i < updatePostClone.body.length; i++) {
const bodyElement = postForm.body.find((elem) => elem._id === updatePostClone.body[i]._id);
if (!bodyElement) {
if (updatePostClone.body[i].type === 'image') {
await removePostImage({imageName:updatePostClone.body[i].file.name});
}
}
}
// update main image
if (postForm.mainImage.fileData) {
let resp1 = await removePostImage({imageName:postForm.mainImage.file.name});
if (resp1.status === 200) {
const fileNameParts = postForm.mainImage.fileData.name.split('.');
const extension = fileNameParts.pop();
const newImageName = `${fileNameParts.join('.')}_${uuid()}.${extension}`;
postForm.mainImage.file.name = newImageName;
const newFile = new File([postForm.mainImage.fileData], newImageName, {
type: postForm.mainImage.fileData.type,
});
const formData = new FormData();
formData.append('file', newFile);
const response = await uploadPostImage(formData);
postForm.mainImage.file.url = response.imageUrl;
delete postForm.mainImage.fileData;
}
}
// update or add new images functionality
for (let i = 0; i < postForm.body.length; i++) {
if (postForm.body[i].type === 'image') {
if (postForm.body[i].file instanceof File) {
if (postForm.body[i].file.name !== postForm.body[i].name) {
let resp2 = await removePostImage({imageName:postForm.body[i].name});
if (resp2.status === 200) {
const fileNameParts = postForm.body[i].file.name.split('.');
const extension = fileNameParts.pop();
const newImageName = `${fileNameParts.join('.')}_${uuid()}.${extension}`;
const newFile = new File([postForm.body[i].file], newImageName, {
type: postForm.body[i].file.type,
});
const formData = new FormData();
formData.append('file', newFile);
const response = await uploadPostImage(formData);
delete postForm.body[i].file;
postForm.body[i].name = newImageName;
postForm.body[i].file = {};
postForm.body[i].file.url = response.imageUrl;
postForm.body[i].file.name = newImageName;
}
} else {
// upload new image
}
}
}
}
postForm.updated = {
date: {
day: String(new Date().getDate()).padStart(2, '0'),
month: String(new Date().getMonth() + 1).padStart(2, '0'),
year: String(new Date().getFullYear()),
time: new Date().toTimeString().split(' ')[0],
}
};
try {
const response = await updatePost({ post: postForm });
if (response.status === 200) {
dispatch(aPushNewNotification({
type: 'success',
text: response.message,
}));
setpostForm({}); // set default post value
setLoading(false);
navigate('/posts');
}
} catch (error) {
dispatch(aPushNewNotification({
type: 'error',
text: response.message,
}));
setLoading(false);
console.log("Error:", error);
}
} else {
// create new post functionality
}
};
Looks like we did it, let's relaunch our app and run some tests.
3. Removing Posts
This is also an important feature, that takes part in the base "CRUD" operations of every app. We already prepared the backend functionality and part of the frontend, so let's continue...
- add "postToRemove" value into our "Redux" store with all settings (reducer, action, selector, type);
postToRemove: null, // post initial state
case POST_ACTION_TYPES.SET_POST_TO_REMOVE: //post reducer
return { ...state, postToRemove: payload };
export const aSetPostToRemove = (post) => { // post action
return createAction(POST_ACTION_TYPES.SET_POST_TO_REMOVE, post);
}
SET_POST_TO_REMOVE: 'post/SET_POST_TO_REMOVE', // post type
export const sPostToRemove = (state) => state.post.postToRemove; //post selector
- modify the "Remove" button from the "TableComponent" actions list, with the event listener;
<MenuItem onClick={(event) => handleRemove(selectedItem, event)}>Remove</MenuItem>
- create a new "handleRemove" function, that will show the modal window (we already added the "Modal" feature into our app, in one of our previous articles) with the "Remove Post" modal type, and save post data that we are removing into the storage;
const handleRemove = (item, event) => {
event.preventDefault();
dispatch(aUpdateModalType('removePost'));
dispatch(aUpdateModalStatus(true));
dispatch(aSetPostToRemove(item));
};
create a new "RemovePost.component.jsx" file in the modal folder;
import all necessary hooks, actions, and selectors;
import React, { useState } from "react";
import { useDispatch, useSelector } from 'react-redux';
import { deletePost, removePostImage, getPostsList } from "../../../../http/services/posts.services";
import { aUpdateModalStatus, aUpdateModalType, aPushNewNotification } from '../../../../store/base/base.action';
import { sPostToRemove } from '../../../../store/posts/post.selector';
import { aSetPostsList } from '../../../../store/posts/post.action';
create a "RemovePost" functional component that returns some text with two (remove, cancel) buttons;
add the "hanfleRemovePost" function that will first remove all the images from the storage, and then send a request to remove a post by itself;
const RemovePost = () => {
const [loading, setLoading] = useState(false);
const dispatch = useDispatch();
const postToRemove = useSelector(sPostToRemove);
const fetchPosts = async () => {
try {
const data = await getPostsList();
dispatch(aSetPostsList(data.posts));
} catch (error) {
console.error("Error fetching Posts:", error);
}
};
const handleRemovePost = async () => {
setLoading(true);
try {
if (postToRemove.mainImage.file.name) {
await removePostImage({imageName:postToRemove.mainImage.file.name});
}
if (postToRemove.body.length) {
for (let i = 0; i < postToRemove.body.length; i++) {
if (postToRemove.body[i].type === 'image') {
await removePostImage({imageName:postToRemove.body[i].name});
}
}
}
await deletePost({post:postToRemove});
dispatch(aPushNewNotification({
type: 'success',
text: 'Post removed successfully',
}));
setLoading(false);
dispatch(aUpdateModalType(null));
dispatch(aUpdateModalStatus(false));
fetchPosts();
} catch (error) {
dispatch(aPushNewNotification({
type: 'error',
text: error.message,
}));
setLoading(false);
}
};
const cancel = () => {
dispatch(aUpdateModalType(null));
dispatch(aUpdateModalStatus(false));
};
return (
<div className="remove-post-modal">
{
loading
? <Loader />
: <p>Are you sure you want to remove this Post?</p>
}
<div className="remove-post-modal--buttons">
<Button variant="outlined" color="success"
onClick={handleRemovePost}>
Yes, remove!
</Button>
<Button variant="outlined" color="error"
onClick={cancel}>
Cancel
</Button>
</div>
</div>
);
};
Awesome. You did a great job, and we need to summarize all we were working on and move on.
In this tutorial, we've significantly enhanced our Content Management System by implementing post-editing and removal capabilities. We've successfully:
Developed backend routes and controllers for retrieving, updating, and deleting posts
Created frontend mechanisms to interact with these new server-side functionalities
Implemented complex image handling during post-updates
Added a complete post-removal workflow with image cleanup
These improvements transform our CMS from a basic posting platform to a more dynamic and flexible content management tool. By integrating editing, image management, and deletion features, we've created a more professional and user-friendly content management experience.
The complete code for this tutorial is available in the repository. If you have any questions or run into issues, feel free to leave them in the comments section below.
Found this post useful? ☕ A coffee-sized contribution goes a long way in keeping me inspired! Thank you)
Next step: "CMS Development: Adding Post Preview and Status Management in React and Node.js"
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.