In one of our previous tutorials, we implemented a table-based interface to display our posts, organizing content across different columns including an 'Actions' column that enabled basic CRUD operations. Now it's time to enhance our CMS with more advanced features to improve user experience: search capabilities, filtering options, and pagination. These features must be implemented at both the backend and frontend levels to ensure smooth, efficient operation. Let's begin by setting up the server-side infrastructure needed to support these new functionalities.
1. Building the Backend Foundation: Search, Filter, and Pagination Infrastructure
Okay, for pagination we will need to get from the frontend page number and rows per page; for search, we will get a string (we will search in titles and subtitles); for filters, we will wait for status, language, and date range (you can add any filters you need). Great, now we can start with the posts controller.
- we will wait for additional data from the URL query, and in this case, we need to modify the "getPostsList" function. Get all the data that we mentioned earlier from the request value, setting default values where it is possible. Send those values as params into our model, and return as a response all the data to the client;
async function getPostsList(req, res) { try { const { page = 1, rowsPerPage = 10, status, language, startRangeFilter, endRangeFilter, search } = req.query; const data = await postsModel.getPostsList({ page, rowsPerPage, status, language, startRangeFilter, endRangeFilter, search }); return res.status(200).json({ status: 200, ...data }); } catch (error) { console.error('Error getting posts list:', error); return res.status(500).json({ status: 500, message: 'Internal server error' }); } }
open our "posts.model.js" file and find the "getPostsList" function;
we need to add new checkers and filters, and modify our main query;
async function getPostsList({ page = 1, rowsPerPage = 10, status, language, startRangeFilter, endRangeFilter, search }) { try { // Converts rowsPerPage to a number (limit), which determines how many posts to fetch per request. // Converts page to a number and calculates skip, which determines how many posts to skip before retrieving data. const limit = parseInt(rowsPerPage, 10); const skip = (parseInt(page, 10) - 1) * limit; const query = {}; // If status is provided and not 'all', it is added to the query, status.trim() removes extra spaces. if (status && status !== 'all' && status.trim()) { query.status = status.trim(); } // If language is provided, it is added to the query. if (language && language.trim()) { query.language = language.trim(); } // If both startRangeFilter and endRangeFilter are provided, it filters posts where the created.date falls within the range ($gte means greater than or equal to, $lte means less than or equal to). if (startRangeFilter && startRangeFilter.trim() && endRangeFilter && endRangeFilter.trim()) { const [startDay, startMonth, startYear] = startRangeFilter.trim().split('-').map(Number); const [endDay, endMonth, endYear] = endRangeFilter.trim().split('-').map(Number); query['created.date'] = { $gte: { day: startDay, month: startMonth, year: startYear, }, $lte: { day: endDay, month: endMonth, year: endYear, } }; // If only startRangeFilter is provided, it filters posts from that date onward. } else if (startRangeFilter && startRangeFilter.trim()) { const [startDay, startMonth, startYear] = startRangeFilter.trim().split('-').map(Number); query['created.date'] = { $gte: { day: startDay, month: startMonth, year: startYear, } }; // If only endRangeFilter is provided, it filters posts up to that date. } else if (endRangeFilter && endRangeFilter.trim()) { const [endDay, endMonth, endYear] = endRangeFilter.trim().split('-').map(Number); query['created.date'] = { $lte: { day: endDay, month: endMonth, year: endYear, } }; } // If search is provided, it performs a case-insensitive search ('i' flag) on title or subTitle. if (search && search.trim()) { const searchRegex = new RegExp(search.trim(), 'i'); query.$or = [ { title: searchRegex }, { subTitle: searchRegex } ]; } // The function fetches: // - postsList: Retrieves paginated posts from the database based on the query. // - totalCount: Gets the total number of posts matching the filters. // Both queries run in parallel using Promise.all(), improving performance. const [postsList, totalCount] = await Promise.all([ posts.find(query).skip(skip).limit(limit), posts.countDocuments(query) ]); // The function returns an object containing: // - posts: The list of fetched posts. // - totalCount: The total number of matching posts. return { posts: postsList, totalCount }; } catch (error) { console.error('Error getting posts list:', error); throw error; } }
And that's it, we prepared our server for additional functionality, and now can move to the frontend part.
2. Crafting the User Interface: Implementing Interactive Search and Navigation Components
It was fast with the backend and now let's jump into the frontend part, but previously, please, create a few more posts for testing purposes.
- modify the "getPostsList" function from the "posts.services.js" file that we use to call the "posts" endpoint;
export const getPostsList = (data, query) => { const params = new URLSearchParams(); // URLSearchParams is a built-in JavaScript object used to build a URL query string. params.set('page', query?.page || 1); params.set('rowsPerPage', query?.rowsPerPage || 10); // If query.page is provided, it's set; otherwise, it defaults to 1. // If query.rowsPerPage is provided, it's set; otherwise, it defaults to 10. if (query?.status) params.set('status', query.status); if (query?.language) params.set('language', query.language); if (query?.search) params.set('search', query.search); if (query?.endRangeFilter) params.set('endRangeFilter', query.endRangeFilter); if (query?.startRangeFilter) params.set('startRangeFilter', query.startRangeFilter); // If these filters exist in query, they are added to params. const queryString = params.toString(); //Converts params into a URL-encoded query string return HTTP.get(`/posts?${queryString}`, data).then(({ data }) => data); // Sends a GET request to posts?{query parameters}. // Uses HTTP.get, which is an Axios instance. // .then(({ data }) => data) extracts data from the response. };
- we will store all the filters and search fields data in the "Redux" storage, in that case, we need to add additional functionality and state values to the "posts" storage;
// new state values totalPostsCount: 0, postsPage: 0, postsPerPage: 10, statusFilter: 'all', languageFilter: null, startRangeFilter: null, endRangeFilter: null, searchFilter: '', // new reducer cases case POST_ACTION_TYPES.SET_TOTAL_POSTS_COUNT: return { ...state, totalPostsCount: payload }; case POST_ACTION_TYPES.SET_POSTS_PAGE: return { ...state, postsPage: payload }; case POST_ACTION_TYPES.SET_POSTS_PER_PAGE: return { ...state, postsPerPage: payload }; case POST_ACTION_TYPES.SET_STATUS_FILTER: return { ...state, statusFilter: payload }; case POST_ACTION_TYPES.SET_LANGUAGE_FILTER: return { ...state, languageFilter: payload }; case POST_ACTION_TYPES.SET_START_RANGE_FILTER: return { ...state, startRangeFilter: payload }; case POST_ACTION_TYPES.SET_END_RANGE_FILTER: return { ...state, endRangeFilter: payload }; case POST_ACTION_TYPES.SET_SEARCH_FILTER: return { ...state, searchFilter: payload }; //new selectors export const sTotalPostsCount = (state) => state.post.totalPostsCount; export const sPostsPage = (state) => state.post.postsPage; export const sPostsPerPage = (state) => state.post.postsPerPage; export const sStatusFilter = (state) => state.post.statusFilter; export const sLanguageFilter = (state) => state.post.languageFilter; export const sStartRangeFilter = (state) => state.post.startRangeFilter; export const sEndRangeFilter = (state) => state.post.endRangeFilter; export const sSearchFilter = (state) => state.post.searchFilter; //new types SET_TOTAL_POSTS_COUNT: 'post/SET_TOTAL_POSTS_COUNT', SET_POSTS_PAGE: 'post/SET_POSTS_PAGE', SET_POSTS_PER_PAGE: 'post/SET_POSTS_PER_PAGE', SET_STATUS_FILTER: 'post/SET_STATUS_FILTER', SET_LANGUAGE_FILTER: 'post/SET_LANGUAGE_FILTER', SET_START_RANGE_FILTER: 'post/SET_START_RANGE_FILTER', SET_END_RANGE_FILTER: 'post/SET_END_RANGE_FILTER', SET_SEARCH_FILTER: 'post/SET_SEARCH_FILTER',
- apply "Search" functionality to our "Search" field inside the "PostsAction.component.jsx" file. Add the "onClick" event to the "Search" button, create a new "applySearch" function that will send all necessary params to the endpoint, and update our "posts list" with data from the response;
const applySearch = async () => { try { const response = await getPostsList({}, { page: 1, rowsPerPage: postsPerPage, status: statusFilter, language: languageFilter, search: searchFilter, endRangeFilter: endRangeFilter, startRangeFilter: startRangeFilter, }); dispatch(aSetPostsList(response.posts)); dispatch(aSetTotalPostsCount(response.totalCount)); } catch (error) { console.error("Error fetching Posts:", error); dispatch(aPushNewNotification({ type: 'error', text: 'Failed to fetch Posts' })); } }
- create new "filters" modal type with date pickers, status and language dropdowns, also we will add "Apply" and "Clear Filters" buttons, all these fields will modify data from storage, and the "Apply" button will call the same function that we are using on the search button (I will not copy-paste this component because that will be more than 200 lines of code, you can develop this feature by your own or check in my repo). In my case, it will look like this:
Nice, and the last feature that we need to finish, is our pagination. We will use the "TablePagination" component from the MUI library, it's an awesome solution with minimum effort.
- import the "TablePagination" component from MUI;
import TablePagination from '@mui/material/TablePagination';
- add pagination component at the bottom of the table, and define necessary values like "total posts amount", "page number", "rows per page", and events that will call predefined functions on some values change;
<TablePagination className="table-container--pagination" component="div" count={totalPostsCount} page={postsPage} onPageChange={handleChangePage} rowsPerPage={postsPerPage} onRowsPerPageChange={handleChangeRowsPerPage} />
- add two functions that will update "page" and "rowsPerPage" values;
const handleChangePage = (event, newPage) => { dispatch(aSetPostsPage(newPage)); }; const handleChangeRowsPerPage = (event) => { dispatch(aSetPostsPerPage(parseInt(event.target.value, 10))); dispatch(aSetPostsPage(0)); };
- add set "useEffect" hook, that will fetch posts data if page or ros amount were updated;
useEffect(() => { fetchData(); }, [postsPage, postsPerPage]);
Nice, we finished with pagination, now we can relaunch our app, and check the results.
In this tutorial, we've enhanced our Content Management System by implementing advanced search, filtering, and pagination features. By carefully designing both backend and frontend components, we've created a more dynamic and user-friendly content management experience. These improvements transform our CMS from a basic listing tool into a powerful platform that allows users to efficiently navigate and discover content. The implementation demonstrates the importance integration between server-side logic and client-side interfaces, showcasing how modern web technologies like React and Node.js can work together to create content management solutions. As developers continue to build more complex applications, techniques like these become crucial in delivering intuitive and performant user experiences.
The complete code for this tutorial is available in the repository.
Found this post useful? ☕ A coffee-sized contribution goes a long way in keeping me inspired! Thank you)
Next step: "React and Node.js CMS Series: Finish Line"
Top comments (0)