Creating CRUD Web App with Laravel 8 & React [Part II]

19 minutes, 49 seconds

Previously, we built a simple Blog Post web app, applying CRUD operations with Laravel 8, Bootstrap 5, and React. This is the extended part where we improve the previous app by adding image to the post and implementing toggling views for all the posts, whether in card form or group list.


laravel-react.png

Getting Prepared

This project is a continuation of a previous project and most of the information can be referred from Creating CRUD Web App with Laravel 8 & React.

Installing Additional Package

Bootstrap Icons

Bootstrap Icons is a collection of free, high-quality SVG icons that are designed to work seamlessly with Bootstrap.

Install bootstrap-icons using below command:


npm install bootstrap-icons

To use the bootstrap-icons, we need to import it to the app.scss which can be found in resources/sass. Copy and paste below code into app.scss :


// Bootstrap Icons @import "~bootstrap-icons/font/bootstrap-icons";

Updating React Components

AllPostPage.js

First we update the AllPostPage.js with some few changes:

  • New option for user to choose between grid or list view of the posts. Below code only shown the new implemented group list button to toggle between grid or list view.

<div className="d-flex flex-row-reverse"> <div className="btn-group btn-group-lg mb-3"> <a className={`btn btn-outline-secondary btn-sm ${ this.state.displayMode === "card" ? "active" : "" }`} onClick={this.toggleDisplayMode} > <i className="bi bi-grid-fill"></i> </a> <a className={`btn btn-outline-secondary btn-sm ${ this.state.displayMode === "list" ? "active" : "" }`} onClick={this.toggleDisplayMode} > <i className="bi bi-list"></i> </a> </div> </div>

Below code is ternary operator used to toggle between grid (card) or list view:


{displayMode === "card" ? ( <div className="row g-4 mt-1"> {...Rest of the card view code} </div> ) : ( <div> {...Rest of the grid view code} </div> )}
  • Next we added img tag of image of the post

{post.image != undefined && ( <img src={`storage/images/${post.image}`} className="card-img-top img-fluid" /> )}

Full code can be referred below:


import React, { Component } from "react"; import axios from "axios"; import moment from "moment"; import { Link } from "react-router-dom"; class AllPostPage extends Component { constructor(props) { super(props); this.state = { posts: undefined, displayMode: "card", // Default mode is card view }; } componentDidMount() { this.callAPI(); } callAPI() { let url = `/api/posts`; axios .get(url) .then((res) => { this.setState({ posts: res.data.posts, }); }) .catch((e) => {}); } // Function to toggle between card and list mode toggleDisplayMode = () => { const newMode = this.state.displayMode === "card" ? "list" : "card"; this.setState({ displayMode: newMode }); }; render() { const { posts, displayMode } = this.state; return ( <div className="container mt-4"> <div className="d-flex flex-direction-row justify-content-between align-items-center"> <h1 className="mb-4">Post List</h1> <div className="d-flex flex-direction-row justify-content-between"> <Link className="btn btn-primary" to={"/create"}> Add a new one? </Link> </div> </div> <hr /> <div className="d-flex flex-row-reverse"> <div className="btn-group btn-group-lg mb-3"> <a className={`btn btn-outline-secondary btn-sm ${ this.state.displayMode === "card" ? "active" : "" }`} onClick={this.toggleDisplayMode} > <i className="bi bi-grid-fill"></i> </a> <a className={`btn btn-outline-secondary btn-sm ${ this.state.displayMode === "list" ? "active" : "" }`} onClick={this.toggleDisplayMode} > <i className="bi bi-list"></i> </a> </div> </div> {displayMode === "card" ? ( <div className="row g-4 mt-1"> {posts !== undefined && posts.length > 0 ? ( posts.map((post, index) => { return ( <div className="col-lg-4" key={post.id} id={index} > <div className="card shadow"> {post.image != undefined && ( <img src={`storage/images/${post.image}`} className="card-img-top img-fluid" /> )} <div className="card-body"> <p className="btn btn-success rounded-pill btn-sm"> {post.category} </p> <Link to={`/posts/${post.id}`} style={{ textDecoration: "none", }} > <div className="card-title fw-bold text-primary h4"> {post.title} </div> </Link> <p className="text-secondary"> {`${post.content.substring( 0, 30 )}${ post.content.length > 30 ? "... " : "" }`} {post.content.length > 30 && ( <Link to={`/posts/${post.id}`} style={{ textDecoration: "none", }} > Read More </Link> )} </p> </div> </div> </div> ); }) ) : ( <h2 className="text-center text-secondary p-4"> No post found in the database! </h2> )} </div> ) : ( <div> {posts !== undefined && posts.length > 0 ? ( <ul className="list-group"> {posts.map((post, index) => { return ( <li key={post.id} className="list-group-item list-group-item-action" > <Link to={`/posts/${post.id}`} style={{ textDecoration: "none", }} > <div className="d-flex w-100 justify-content-between align-items-center"> <h5 className="mb-1"> {post.title} </h5> <span className="badge bg-success rounded-pill"> {post.category} </span> </div> </Link> </li> ); })} </ul> ) : ( <p className="text-center text-secondary mt-4"> No posts found in the database! </p> )} </div> )} </div> ); } } export default AllPostPage;

PostPage.js

For this page we only need to add the img tag PostPage.js since we will have image on our new post:


{post.image != undefined && ( <img src={`storage/images/${post.image}`} className="card-img-top img-fluid" /> )}

Full code is shown as below:


import React, { Component } from "react"; import moment from "moment"; import axios from "axios"; import { Link, withRouter } from "react-router-dom"; class PostPage extends Component { constructor(props) { super(props); this.state = { post: undefined, }; } componentDidMount() { this.callAPI(); } callAPI() { const { id } = this.props.match.params; let url = `/api/posts/${id}`; console.log(`url`, url); axios .get(url) .then((res) => { console.log(url, res.data.post); this.setState({ post: res.data.post, }); }) .catch((e) => {}); } handleDelete = () => { const { id } = this.props.match.params; let result = confirm("Are you want to delete "); if (result == false) { return; } axios .delete(`/api/posts/${id}`) .then((res) => { // Handle the successful deletion, e.g., redirect to another page. // You may want to add error handling here as well. this.props.history.push("/posts"); // Redirect to the home page or another appropriate location. }) .catch((error) => { console.error("Delete request error: ", error); }); }; render() { const { post } = this.state; if (!post) { // Render a loading message or spinner while waiting for the API response. return <div>Loading...</div>; } return ( <div className="row my-4"> <div className="col-lg-8 mx-auto"> <div className="card shadow"> {post.image != undefined && ( <img src={`storage/images/${post.image}`} className="card-img-top img-fluid" /> )} <div className="card-body p-5"> <div className="d-flex justify-content-between align-items-center"> <p className="btn btn-dark rounded-pill"> {post.category} </p> <p className="lead"> {moment(post.created_at).fromNow()} </p> </div> <hr /> <h3 className="fw-bold text-primary"> {post.title} </h3> <p>{post.content}</p> </div> <div className="card-footer px-5 py-3 d-flex justify-content-end"> <Link to={`/posts/${post.id}/edit`} className="btn btn-success rounded-pill me-2" > Edit </Link> <button onClick={this.handleDelete} className="btn btn-danger rounded-pill" > Delete </button> </div> </div> </div> </div> ); } } export default withRouter(PostPage);

CreateUpdatePage.js

There are some changes made for creating and updating a post. Those changes are:

  • Send data through FormData() instead of params.

const { title, category, content, image, isEdit } = this.state; const formData = new FormData(); formData.set("title", title); formData.set("category", category); formData.set("content", content); if (typeof image !== "string") { formData.set("image", image); } else { formData.set("image", ""); }
  • Added new input form for the image

<div className="my-2"> <input type="file" name="image" onChange={this.handleFileChange} className={`form-control ${ errors.image ? "is-invalid" : "" }`} /> {isEdit && ( <img src={`storage/images/${this.state.savedImage}`} className="img-fluid img-thumbnail" width={150} /> )} {errors.file && ( <div className="invalid-feedback"> {errors.image} </div> )} </div>

Full code is shown as below:


import React, { Component } from "react"; import axios from "axios"; import { withRouter } from "react-router-dom"; class CreateUpdatePage extends Component { constructor(props) { super(props); this.state = { title: "", category: "", content: "", savedImage: "", image: "", errors: {}, isEdit: props.match.params.id && props.location.pathname.includes("edit"), }; } componentDidMount() { if (this.state.isEdit) { this.fetchPostData(); } } fetchPostData = () => { const { match } = this.props; axios .get(`/api/posts/${match.params.id}`) .then((response) => { const { title, category, content, image } = response.data.post; this.setState({ title: title, category: category, content: content, savedImage: image, }); }) .catch((error) => { console.error("Error fetching post data:", error); }); }; handleInputChange = (e) => { // e.persist(); const { name, value } = e.target; this.setState({ [name]: value }); }; handleFileChange = (e) => { this.setState({ image: e.target.files[0] }); }; handleSubmit = (e) => { e.preventDefault(); const { title, category, content, image, isEdit } = this.state; const formData = new FormData(); formData.set("title", title); formData.set("category", category); formData.set("content", content); if (typeof image !== "string") { formData.set("image", image); } else { formData.set("image", ""); } let params = { title: title, category: category, content: content, }; const { match, history } = this.props; const apiUrl = isEdit ? `/api/posts/${match.params.id}` : "/api/posts"; if (isEdit) { formData.set("_method", "PUT"); axios .post(apiUrl, formData) .then((response) => { console.log( "Post created/updated successfully:", response.data ); // After a successful post creation/update, navigate to the /posts page history.push("/posts"); }) .catch((error) => { if (error.response && error.response.data) { this.setState({ errors: error.response.data.errors, }); } console.error("Error creating/updating post:", error); }); } else { axios .post(apiUrl, formData) .then((response) => { console.log( "Post created/updated successfully:", response.data ); // After a successful post creation/update, navigate to the /posts page history.push("/posts"); }) .catch((error) => { if (error.response && error.response.data) { this.setState({ errors: error.response.data.errors, }); } console.error("Error creating/updating post:", error); }); } }; render() { const { errors, isEdit } = this.state; const buttonText = isEdit ? "Update Post" : "Add Post"; return ( <div className="row my-3"> <div className="col-lg-8 mx-auto"> <div className="card shadow"> <div className="card-header text-white bg-primary fw-bold fs-3"> {isEdit ? "Update Post" : "Add New Post"} </div> <div className="card-body p-4"> <form onSubmit={this.handleSubmit} encType="multipart/form-data" > <div className="my-2"> <input type="text" name="title" value={this.state.title} onChange={this.handleInputChange} className={`form-control ${ errors.title ? "is-invalid" : "" }`} placeholder="Title" /> {errors.title && ( <div className="invalid-feedback"> {errors.title} </div> )} </div> <div className="my-2"> <input type="text" name="category" value={this.state.category} onChange={this.handleInputChange} className={`form-control ${ errors.category ? "is-invalid" : "" }`} placeholder="Category" /> {errors.category && ( <div className="invalid-feedback"> {errors.category} </div> )} </div> <div className="my-2"> <input type="file" name="image" onChange={this.handleFileChange} className={`form-control ${ errors.image ? "is-invalid" : "" }`} /> {isEdit && ( <img src={`storage/images/${this.state.savedImage}`} className="img-fluid img-thumbnail" width={150} /> )} {errors.file && ( <div className="invalid-feedback"> {errors.image} </div> )} </div> <div className="my-2"> <textarea name="content" value={this.state.content} onChange={this.handleInputChange} rows="6" className={`form-control ${ errors.content ? "is-invalid" : "" }`} placeholder="Post Content" /> {errors.content && ( <div className="invalid-feedback"> {errors.content} </div> )} </div> <div className="my-2"> <input type="submit" value={buttonText} className={`btn ${ isEdit ? "btn-success" : "btn-primary" }`} /> </div> </form> </div> </div> </div> </div> ); } } export default withRouter(CreateUpdatePage);

Configuring PostController.php

There are 2 method need to be updated since we added image to out blog post.

  • POST store(Request $request):
    • Added new validator for image file
    • Added image file type handling/storing to the database.

Full code is shown as below:


public function store(Request $request) { $validator = Validator::make($request->all(),[ 'title' => 'required|string|max:255', 'category' => 'required|string|max:255', 'content' => 'required|string', 'image' => 'required|image|mimes:jpeg,png,jpg,gif,svg|max:5000', ]); if ($validator->fails()){ return response()->json(['errors'=>$validator->errors()], 422); } $post = new Post(); $post->title = $request->title; $post->category = $request->category; $post->content = $request->content; if ($request->hasFile('image')) { $imageName = time() . '.' . $request->file('image')->getClientOriginalExtension(); $request->file('image')->storeAs('public/images', $imageName); $post->image = $imageName; } $post->save(); // Return a JSON response indicating success return response()->json(['message' => 'Post added successfully', 'status' => 'success']); }
  • PUT/PATCH update(Request $request, $id):
    • Added new validator for image file but the image can also be null.
    • Added image file type handling/storing and deleting old image if the post have an old image stored on database.

Full code is shown as below:


public function update(Request $request, $id) { $post = Post::find($id); $validator = Validator::make($request->all(),[ 'title' => 'required|string|max:255', 'category' => 'required|string|max:255', 'content' => 'required|string', 'image' => 'sometimes|nullable|image|mimes:jpeg,png,jpg,gif,svg|max:5000', ]); if ($validator->fails()){ return response()->json(['errors'=>$validator->errors()], 422); } if ($request->hasFile('image')) { $imageName = time() . '.' . $request->file('image')->getClientOriginalExtension(); $request->file('image')->storeAs('public/images', $imageName); if ($post->image) { Storage::delete('public/images/' . $post->image); } $post->image = $imageName; } // Update the post data $post->title = $request->title; $post->category = $request->category; $post->content = $request->content; // Save the updated post $post->save(); return response()->json(['message' => 'Post updated successfully'], 200); }

Before we creating a new a new post, we have to store all the images in the storage directory so to access those images from the publicdirectory we have to run a simple command:


php artisan storage:link

App Demonstration

Creating a Post

create.png

Viewing All Post

Grid View

all-post=grid.png

List View

all-post-list.png

Show Single Post

single-post-part2.png

Updating a Post

update-post-part2.png

Summary

We just learn on how to add image to our post blog app with FormData() and adding icon via bootstrap-icons to our blog post app. We also learn on how to handle storing and deleting image data on our backend via laravel.

If you have any questions or concerns, please don’t hesitate to leave a comment below.

Resources

Laravel Official
Creating CRUD App with React & Laravel 8
Bootstrap Icons