Repository: shantanu471/blog-it
Pull request #10: Implemented create post and show post functionality
Author: login: shantanu471
Created: 2025-03-31T14:42:30Z | Merged: 2025-04-02T05:34:53Z

Closes #9 

Linked issue #9: Implement create post and show post functionality

Diff:
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 7944f9f..f178a5a 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,4 +1,64 @@
 # frozen_string_literal: true
 
 class ApplicationController < ActionController::Base
+  protect_from_forgery
+
+  rescue_from StandardError, with: :handle_api_exception
+
+  def handle_api_exception(exception)
+    case exception
+    when -> (e) { e.message.include?("PG::") || e.message.include?("SQLite3::") }
+      handle_database_level_exception(exception)
+
+    when ActionController::ParameterMissing
+      render_error(exception, :internal_server_error)
+
+    when ActiveRecord::RecordNotFound
+      render_error("Couldn't find #{exception.model}", :not_found)
+
+    when ActiveRecord::RecordNotUnique
+      render_error(exception.message)
+
+    when ActiveModel::ValidationError, ActiveRecord::RecordInvalid, ArgumentError
+      error_message = exception.message.gsub("Validation failed: ", "")
+      render_error(error_message, :unprocessable_entity)
+
+    else
+      handle_generic_exception(exception)
+    end
+  end
+
+  def handle_database_level_exception(exception)
+    handle_generic_exception(exception, :unprocessable_entity)
+  end
+
+  def handle_generic_exception(exception, status = :internal_server_error)
+    log_exception(exception) unless Rails.env.test?
+    error = Rails.env.production? ? t("generic_error") : exception
+    render_error(error, status)
+  end
+
+  def log_exception(exception)
+    Rails.logger.info exception.class.to_s
+    Rails.logger.info exception.to_s
+    Rails.logger.info exception.backtrace.join("\n")
+  end
+
+  def render_error(error, status = :unprocessable_entity, context = {})
+    error_message = error
+    is_exception = error.kind_of?(StandardError)
+    if is_exception
+      is_having_record = error.methods.include? "record"
+      error_message = is_having_record ? error.record.errors_to_sentence : error.message
+    end
+    render status:, json: { error: error_message }.merge(context)
+  end
+
+  def render_notice(message, status = :ok, context = {})
+    render status:, json: { notice: message }.merge(context)
+  end
+
+  def render_json(json = {}, status = :ok)
+    render status:, json:
+  end
 end
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index 92efc1c..cc96df4 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -1,8 +1,29 @@
 # frozen_string_literal: true
 
 class PostsController < ApplicationController
+  before_action :load_post!, only: %i[show]
+
   def index
     posts = Post.all
     render status: :ok, json: { posts: }
   end
+
+  def create
+    Post.create!(post_params)
+    render_notice(t("successfully_created"))
+  end
+
+  def show
+    render_json({ post: @post })
+  end
+
+  private
+
+    def load_post!
+      @post = Post.find_by!(slug: params[:slug])
+    end
+
+    def post_params
+      params.require(:post).permit(:title, :description)
+    end
 end
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index c3591ea..f6e7727 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -2,13 +2,14 @@ import "../stylesheets/application.scss";
 import ReactRailsUJS from "react_ujs";
 import App from "../src/App";
 
-import { setAuthHeaders } from "apis/axios";
+import { registerIntercepts, setAuthHeaders } from "apis/axios";
 import { initializeLogger } from "common/logger";
 
+registerIntercepts();
 initializeLogger();
 setAuthHeaders();
 
 const componentsContext = { App };
 ReactRailsUJS.getConstructor = (name) => {
   return componentsContext[name];
-};
\ No newline at end of file
+};
diff --git a/app/javascript/src/App.jsx b/app/javascript/src/App.jsx
index 551132c..d031f2a 100644
--- a/app/javascript/src/App.jsx
+++ b/app/javascript/src/App.jsx
@@ -2,13 +2,21 @@ import React from "react";
 
 import { Route, Switch, BrowserRouter as Router } from "react-router-dom";
 import { Redirect } from "react-router-dom/cjs/react-router-dom.min";
+import { ToastContainer } from "react-toastify";
+import CreatePost from "components/CreatePost";
+import ShowPost from "./components/ShowPost";
 
 import BlogPosts from "components/BlogPosts";
+import Sidebar from "./components/Sidebar";
 
 const App = () => {
   return (
     <Router>
+      <ToastContainer />
+      <Sidebar />
       <Switch>
+        <Route exact component={ShowPost} path="/posts/:slug/show" />
+        <Route exact component={CreatePost} path="/posts/create" />
         <Route exact path="/blogs" component={BlogPosts} />
         <Redirect path="*" to="/blogs" />
       </Switch>
diff --git a/app/javascript/src/apis/axios.js b/app/javascript/src/apis/axios.js
index 6a4f5b2..1985bd6 100644
--- a/app/javascript/src/apis/axios.js
+++ b/app/javascript/src/apis/axios.js
@@ -1,8 +1,12 @@
 import axios from "axios";
+import { Toastr } from "components/commons";
+import { setToLocalStorage, getFromLocalStorage } from "utils/storage";
+
+const DEFAULT_ERROR_NOTIFICATION = "Something went wrong!";
 
 axios.defaults.baseURL = "/";
 
-export const setAuthHeaders = () => {
+const setAuthHeaders = () => {
   axios.defaults.headers = {
     Accept: "application/json",
     "Content-Type": "application/json",
@@ -10,10 +14,47 @@ export const setAuthHeaders = () => {
       .querySelector('[name="csrf-token"]')
       .getAttribute("content"),
   };
-  const token = localStorage.getItem("authToken");
-  const email = localStorage.getItem("authEmail");
+  const token = getFromLocalStorage("authToken");
+  const email = getFromLocalStorage("authEmail");
   if (token && email) {
     axios.defaults.headers["X-Auth-Email"] = email;
     axios.defaults.headers["X-Auth-Token"] = token;
   }
-};
\ No newline at end of file
+};
+
+const handleSuccessResponse = response => {
+  if (response) {
+    response.success = response.status === 200;
+    if (response.data.notice) {
+      Toastr.success(response.data.notice, {
+        autoClose: 2000,
+      });
+    }
+  }
+  return response;
+};
+
+const handleErrorResponse = axiosErrorObject => {
+  if (axiosErrorObject.response?.status === 401) {
+    setToLocalStorage({ authToken: null, email: null, userId: null });
+    setTimeout(() => (window.location.href = "/"), 2000);
+  }
+  Toastr.error(
+    axiosErrorObject.response?.data?.error || DEFAULT_ERROR_NOTIFICATION,
+     {
+       autoClose: 2000,
+     }
+  );
+  if (axiosErrorObject.response?.status === 423) {
+    window.location.href = "/";
+  }
+  return Promise.reject(axiosErrorObject);
+};
+
+const registerIntercepts = () => {
+  axios.interceptors.response.use(handleSuccessResponse, error =>
+    handleErrorResponse(error)
+  );
+};
+
+export { setAuthHeaders, registerIntercepts };
diff --git a/app/javascript/src/apis/posts.js b/app/javascript/src/apis/posts.js
index 4ea66cd..86da47e 100644
--- a/app/javascript/src/apis/posts.js
+++ b/app/javascript/src/apis/posts.js
@@ -2,6 +2,13 @@ import axios from "axios";
 
 const fetch = () => axios.get("/posts");
 
-const postsApi = { fetch };
+const show = slug => axios.get(`/posts/${slug}`);
+
+const create = payload =>
+  axios.post("/posts/", {
+    post: payload,
+  });
+
+const postsApi = { fetch, show, create };
 
 export default postsApi;
diff --git a/app/javascript/src/components/CreatePost/Form.jsx b/app/javascript/src/components/CreatePost/Form.jsx
new file mode 100644
index 0000000..b1a999b
--- /dev/null
+++ b/app/javascript/src/components/CreatePost/Form.jsx
@@ -0,0 +1,70 @@
+import React from "react";
+
+import { Button, Typography } from "@bigbinary/neetoui";
+import {
+  Input,
+  Textarea,
+  Form as NeetoUIForm,
+} from "@bigbinary/neetoui/formik";
+import PropTypes from "prop-types";
+
+import { validationSchema } from "utils/validationSchema";
+
+const Form = ({
+  type = "create",
+  initialValues,
+  handleSubmit,
+  onCancel,
+  loading,
+}) => (
+  <NeetoUIForm
+    formikProps={{
+      initialValues,
+      validationSchema,
+      onSubmit: values => handleSubmit(values),
+    }}
+  >
+    <div className="w-full">
+      <div className="space-y-2">
+        <Typography>Title</Typography>
+        <Input
+          required
+          name="title"
+          placeholder="Enter title"
+          size="large"
+        />
+        <Typography>Description</Typography>
+        <Textarea
+          required
+          maxLength={10000}
+          name="description"
+          placeholder="Enter description"
+          rows={10}
+        />
+        <div className="flex justify-end space-x-2">
+          <Button
+            className="rounded-md bg-black px-4 py-3 text-white transition-colors hover:bg-gray-800"
+            label={type === "create" ? "Create Post" : "Update Post"}
+            loading={loading}
+            type="submit"
+          />
+          <Button
+            className="rounded-md px-4 py-2 text-gray-700 transition-colors hover:bg-gray-100"
+            label="Cancel"
+            style="text"
+            onClick={onCancel}
+          />
+        </div>
+      </div>
+    </div>
+  </NeetoUIForm>
+);
+
+Form.propTypes = {
+  type: PropTypes.oneOf(["create", "update"]),
+  initialValues: PropTypes.object.isRequired,
+  handleSubmit: PropTypes.func.isRequired,
+  onCancel: PropTypes.func.isRequired,
+  loading: PropTypes.bool,
+};
+export default Form;
diff --git a/app/javascript/src/components/CreatePost/index.jsx b/app/javascript/src/components/CreatePost/index.jsx
new file mode 100644
index 0000000..dc965ea
--- /dev/null
+++ b/app/javascript/src/components/CreatePost/index.jsx
@@ -0,0 +1,45 @@
+import React, { useState } from "react";
+
+import { Typography } from "@bigbinary/neetoui";
+import { useHistory } from "react-router-dom/cjs/react-router-dom.min";
+
+import postsApi from "apis/posts";
+
+import PostForm from "./Form";
+
+const CreatePost = () => {
+  const [loading, setLoading] = useState(false);
+
+  const history = useHistory();
+
+  const handleSubmit = async values => {
+    setLoading(true);
+    try {
+      await postsApi.create(values);
+      history.push("/blogs");
+    } catch (error) {
+      logger.error(error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="mx-24 w-auto gap-y-8 px-4 py-8">
+      <Typography className="text-5xl font-bold text-gray-800">
+        New blog post
+      </Typography>
+      <div className="flex-grow-1 mt-10 border-2 px-10 py-6">
+        <PostForm
+          handleSubmit={handleSubmit}
+          initialValues={{ title: "", description: "" }}
+          loading={loading}
+          type="create"
+          onCancel={() => history.push("/blogs")}
+        />
+      </div>
+    </div>
+  );
+};
+
+export default CreatePost;
diff --git a/app/javascript/src/components/Posts/index.jsx b/app/javascript/src/components/Posts/index.jsx
index 3569eb4..457a20e 100644
--- a/app/javascript/src/components/Posts/index.jsx
+++ b/app/javascript/src/components/Posts/index.jsx
@@ -1,11 +1,12 @@
 import React, { useState, useEffect } from "react";
 
-import { Typography } from "@bigbinary/neetoui";
+import { Typography, Button } from "@bigbinary/neetoui";
 import { isNil, isEmpty, either } from "ramda";
 import { format } from "date-fns";
 import Logger from "js-logger";
+import { Link } from "react-router-dom/cjs/react-router-dom.min";
 
-import PageLoader from "components/commons/PageLoader";
+import { PageLoader } from "../commons";
 import postsApi from "apis/posts";
 
 const Posts = () => {
@@ -25,6 +26,11 @@ const Posts = () => {
     }
   };
 
+  const truncateText = (text, maxLength = 200) => {
+    if (!text || text.length <= maxLength) return text;
+    return `${text.substring(0, maxLength)}...`;
+  };
+
   useEffect(() => {
     fetchPosts();
   }, []);
@@ -48,27 +54,41 @@ const Posts = () => {
   Logger.log(posts);
 
   return (
-    <div className="w-full px-4 py-8">
-      <Typography className="mb-8 text-5xl font-bold text-gray-800">
-        Blog posts
-      </Typography>
+    <div className="ml-24 w-auto px-4 py-8">
+       <div className="mb-8 flex items-center justify-between">
+         <Typography className="text-5xl font-bold text-gray-800">
+           Blog posts
+         </Typography>
+         <Link to="/posts/create">
+           <Button className="mr-10 rounded-lg bg-black px-4 py-3 text-white hover:bg-gray-800">
+             Create Post
+           </Button>
+         </Link>
+       </div>
       <div className="space-y-8">
-        {posts.map(post => (
-          <article className="group" key={post.id}>
-            <section className="block space-y-2 hover:no-underline">
-              <Typography className="text-xl font-bold text-gray-800 group-hover:text-blue-600">
-                {post.title}
-              </Typography>
-              <Typography className="text-gray-600">
-                {post.description}
-              </Typography>
-              <Typography className="text-gray-500">
-                {format(new Date(post.created_at), "dd MMMM yyyy")}
-              </Typography>
-            </section>
-            <hr className="my-4" />
-          </article>
-        ))}
+      {posts
+           .sort(
+             (post1, post2) =>
+               new Date(post2.created_at) - new Date(post1.created_at)
+           )
+           .map(post => (
+             <article className="group" key={post.id}>
+               <section className="block space-y-2 hover:no-underline">
+                 <Link to={`/posts/${post.slug}/show`}>
+                   <Typography className="text-xl font-bold text-gray-800 group-hover:text-blue-600">
+                     {post.title}
+                   </Typography>
+                 </Link>
+                 <Typography className="text-gray-600">
+                  {truncateText(post.description)}
+                 </Typography>
+                 <Typography className="text-gray-500">
+                   {format(new Date(post.created_at), "dd MMMM yyyy")}
+                 </Typography>
+               </section>
+               <hr className="my-4" />
+             </article>
+           ))}
       </div>
     </div>
   );
diff --git a/app/javascript/src/components/ShowPost/index.jsx b/app/javascript/src/components/ShowPost/index.jsx
new file mode 100644
index 0000000..32c022a
--- /dev/null
+++ b/app/javascript/src/components/ShowPost/index.jsx
@@ -0,0 +1,50 @@
+import React, { useEffect, useState } from "react";
+
+import { Typography } from "@bigbinary/neetoui";
+import { useHistory, useParams } from "react-router-dom";
+
+import postsApi from "apis/posts";
+import PageLoader from "components/commons/PageLoader";
+
+const Show = () => {
+  const [post, setPost] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const { slug } = useParams();
+  const history = useHistory();
+
+  const fetchPostDetails = async () => {
+    try {
+      const {
+        data: { post },
+      } = await postsApi.show(slug);
+      setPost(post);
+      setLoading(false);
+    } catch (error) {
+      logger.error(error);
+      history.push("/blogs");
+    }
+  };
+
+  useEffect(() => {
+    fetchPostDetails();
+  }, []);
+
+  if (loading) {
+    return <PageLoader />;
+  }
+
+  return (
+    <div className="ml-24 w-auto px-4 py-8">
+      <div className="mt-24 flex w-full items-start justify-between gap-x-6">
+        <div className="flex flex-col gap-y-2">
+          <Typography className="text-3xl font-semibold">
+            {post?.title}
+          </Typography>
+          <Typography className="ml-2 mt-5">{post?.description}</Typography>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default Show;
diff --git a/app/javascript/src/components/Sidebar/index.jsx b/app/javascript/src/components/Sidebar/index.jsx
index f9ddf84..14a52bb 100644
--- a/app/javascript/src/components/Sidebar/index.jsx
+++ b/app/javascript/src/components/Sidebar/index.jsx
@@ -1,7 +1,8 @@
 import React from "react";
 
-import { Book, List } from "@bigbinary/neeto-icons";
+import { Book, Edit, List } from "@bigbinary/neeto-icons";
 import { NavLink } from "react-router-dom";
+import { Avatar } from "@bigbinary/neetoui";
 
 const Sidebar = () => (
   <div className="fixed flex h-screen w-16 flex-col items-center border-r border-gray-200 bg-white py-4">
@@ -16,8 +17,16 @@ const Sidebar = () => (
       <List className="h-6 w-6" />
     </NavLink>
 
-    <div className="mt-4 flex h-full flex-col justify-end">
-      <div className="h-12 w-12 rounded-full bg-gray-300" />
+    <NavLink
+       activeClassName="bg-black text-white"
+       className="my-2 rounded-lg p-2 hover:bg-gray-400"
+       to="/posts/create"
+     >
+       <Edit className="h-7 w-7" />
+     </NavLink>
+
+     <div className="mb-6 mt-4 flex h-full flex-col justify-end">
+      <Avatar size="large" />
     </div>
   </div>
 );
diff --git a/app/javascript/src/components/commons/Toastr.jsx b/app/javascript/src/components/commons/Toastr.jsx
new file mode 100644
index 0000000..e4d737c
--- /dev/null
+++ b/app/javascript/src/components/commons/Toastr.jsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { toast } from "react-toastify";
+
+import { TOASTR_OPTIONS } from "constants";
+
+const ToastrComponent = ({ message }) => {
+  return (
+    <div className="flex flex-row items-start justify-start">
+      <p className="mx-4 font-medium leading-5 text-white">{message}</p>
+    </div>
+  );
+};
+
+const showToastr = message => {
+  toast.success(<ToastrComponent message={message} />, TOASTR_OPTIONS);
+};
+
+const isError = e => e && e.stack && e.message;
+
+const showErrorToastr = error => {
+  const errorMessage = isError(error) ? error.message : error;
+  toast.error(<ToastrComponent message={errorMessage} />, TOASTR_OPTIONS);
+};
+
+const Toastr = {
+  success: showToastr,
+  error: showErrorToastr,
+};
+
+export default Toastr;
diff --git a/app/javascript/src/components/commons/index.jsx b/app/javascript/src/components/commons/index.jsx
new file mode 100644
index 0000000..6a3b366
--- /dev/null
+++ b/app/javascript/src/components/commons/index.jsx
@@ -0,0 +1,4 @@
+import Toastr from "./Toastr";
+import PageLoader from "./PageLoader";
+
+export { PageLoader, Toastr };
diff --git a/app/javascript/src/constants/index.js b/app/javascript/src/constants/index.js
new file mode 100644
index 0000000..fcc8bf9
--- /dev/null
+++ b/app/javascript/src/constants/index.js
@@ -0,0 +1,9 @@
+import { toast, Slide } from "react-toastify";
+
+const TOASTR_OPTIONS = {
+  position: toast.POSITION.BOTTOM_CENTER,
+  transition: Slide,
+  theme: "colored",
+};
+
+export { TOASTR_OPTIONS };
diff --git a/app/javascript/src/utils/storage.js b/app/javascript/src/utils/storage.js
new file mode 100644
index 0000000..79828a0
--- /dev/null
+++ b/app/javascript/src/utils/storage.js
@@ -0,0 +1,16 @@
+const setToLocalStorage = ({ authToken, email, userId, userName }) => {
+  localStorage.setItem("authToken", JSON.stringify(authToken));
+  localStorage.setItem("authEmail", JSON.stringify(email));
+  localStorage.setItem("authUserId", JSON.stringify(userId));
+  localStorage.setItem("authUserName", JSON.stringify(userName));
+};
+
+const getFromLocalStorage = key => {
+  try {
+    return JSON.parse(localStorage.getItem(key));
+  } catch {
+    return null;
+  }
+};
+
+export { setToLocalStorage, getFromLocalStorage };
diff --git a/app/javascript/src/utils/validationSchema.js b/app/javascript/src/utils/validationSchema.js
new file mode 100644
index 0000000..042e56c
--- /dev/null
+++ b/app/javascript/src/utils/validationSchema.js
@@ -0,0 +1,14 @@
+import * as yup from "yup";
+
+export const validationSchema = yup.object({
+  title: yup
+    .string()
+    .required("Title is required")
+    .min(5, "Title must be at least 5 characters")
+    .max(100, "Title must be less than 100 characters"),
+  description: yup
+    .string()
+    .required("Description is required")
+    .min(10, "Description must be at least 10 characters")
+    .max(1000, "Description must be less than 1000 characters"),
+});
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 08dc537..0415ac5 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -2,4 +2,8 @@
 
 class ApplicationRecord < ActiveRecord::Base
   primary_abstract_class
+
+  def errors_to_sentence
+    errors.full_messages.to_sentence
+  end
 end
diff --git a/config/build/config.js b/config/build/config.js
index a90348e..ac881ad 100644
--- a/config/build/config.js
+++ b/config/build/config.js
@@ -14,7 +14,9 @@ const alias = {
   apis: absolutePath("src/apis"),
   common: absolutePath("src/common"),
   components: absolutePath("src/components"),
+  constants: absolutePath("src/constants"),
+  utils: absolutePath("src/utils"),
   assets: absolutePath("../assets"),
 };
 
-export { alias };
\ No newline at end of file
+export { alias };
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 7433bb9..3186952 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -28,6 +28,9 @@
 #       enabled: "ON"
 
 en:
-  task:
+  successfully_created: "Task was successfully created!"
+  successfully_updated: "Task was successfully updated!"
+  generic_error: "Something went wrong. Please try again later."
+  post:
     slug:
       immutable: "is immutable!"
diff --git a/config/routes.rb b/config/routes.rb
index 3abbc43..f22e587 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -7,7 +7,7 @@
   # Can be used by load balancers and uptime monitors to verify that the app is live.
   get "up" => "rails/health#show", as: :rails_health_check
 
-  resources :posts, only: :index, param: :slug
+  resources :posts, only: %i[index create show], param: :slug
 
   root "home#index"
   get "*path", to: "home#index", via: :all
diff --git a/package.json b/package.json
index d61870e..9bcdb8c 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-router-dom": "^5.2.0",
-    "react-toastify": "^9.0.1",
+    "react-toastify": "^8.0.2",
     "react_ujs": "^3.2.1",
     "remixicon": "^4.6.0",
     "sass": "^1.59.3",
@@ -60,7 +60,8 @@
     "tailwindcss": "^3.4.1",
     "util": "^0.12.5",
     "vite": "6.0.3",
-    "vite-plugin-svgr": "^4.3.0"
+    "vite-plugin-svgr": "^4.3.0",
+    "yup": "^0.32.11"
   },
   "resolutions": {
     "strip-ansi": "^6.0.1",
diff --git a/yarn.lock b/yarn.lock
index c67dc82..4812a0c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -924,6 +924,13 @@
   dependencies:
     regenerator-runtime "^0.14.0"
 
+"@babel/runtime@^7.15.4":
+  version "7.27.0"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762"
+  integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==
+  dependencies:
+    regenerator-runtime "^0.14.0"
+
 "@babel/template@^7.25.9", "@babel/template@^7.26.9":
   version "7.26.9"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.26.9.tgz#4577ad3ddf43d194528cff4e1fa6b232fa609bb2"
@@ -1688,6 +1695,11 @@
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
   integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
 
+"@types/lodash@^4.14.175":
+  version "4.17.16"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.16.tgz#94ae78fab4a38d73086e962d0b65c30d816bfb0a"
+  integrity sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==
+
 "@types/node@*":
   version "22.13.11"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.11.tgz#f0ed6b302dcf0f4229d44ea707e77484ad46d234"
@@ -3580,6 +3592,11 @@ mz@^2.7.0:
     object-assign "^4.0.1"
     thenify-all "^1.0.0"
 
+nanoclone@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4"
+  integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==
+
 nanoid@^3.3.8:
   version "3.3.11"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
@@ -4336,6 +4353,11 @@ prop-types@^15.6.2, prop-types@^15.8.1:
     object-assign "^4.1.1"
     react-is "^16.13.1"
 
+property-expr@^2.0.4:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8"
+  integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==
+
 proxy-from-env@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
@@ -4806,10 +4828,10 @@ react-router@5.3.4:
     tiny-invariant "^1.0.2"
     tiny-warning "^1.0.0"
 
-react-toastify@^9.0.1:
-  version "9.1.3"
-  resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-9.1.3.tgz#1e798d260d606f50e0fab5ee31daaae1d628c5ff"
-  integrity sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==
+react-toastify@^8.0.2:
+  version "8.2.0"
+  resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-8.2.0.tgz#ef7d56bdfdc6272ca6b228368ab564721c3a3244"
+  integrity sha512-Pg2Ju7NngAamarFvLwqrFomJ57u/Ay6i6zfLurt/qPynWkAkOthu6vxfqYpJCyNhHRhR4hu7+bySSeWWJu6PAg==
   dependencies:
     clsx "^1.1.1"
 
@@ -5419,6 +5441,11 @@ toggle-selection@^1.0.6:
   resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
   integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
 
+toposort@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
+  integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==
+
 ts-interface-checker@^0.1.9:
   version "0.1.13"
   resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
@@ -5586,3 +5613,16 @@ yaml@^2.3.4:
   version "2.7.0"
   resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.7.0.tgz#aef9bb617a64c937a9a748803786ad8d3ffe1e98"
   integrity sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==
+
+yup@^0.32.11:
+  version "0.32.11"
+  resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5"
+  integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==
+  dependencies:
+    "@babel/runtime" "^7.15.4"
+    "@types/lodash" "^4.14.175"
+    lodash "^4.17.21"
+    lodash-es "^4.17.21"
+    nanoclone "^0.2.1"
+    property-expr "^2.0.4"
+    toposort "^2.0.2"


Reviews:
user: login: jaimysimon
body: Minor changes, you can merge once fixed. I have only reviewed your RoR code because you have not gone through the BBA React.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-01T15:51:40Z
user: login: shantanu471
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-02T05:24:20Z

Review comments:
diff_hunk: @@ -1,8 +1,30 @@
 # frozen_string_literal: true
 
 class PostsController < ApplicationController
+  before_action :load_post!, only: %i[show]
+
   def index
     posts = Post.all
     render status: :ok, json: { posts: }
   end
+
+  def create
+    post = Post.new(post_params)
+    post.save!
path: app/controllers/posts_controller.rb
user: login: jaimysimon
body: ```suggestion
    Post.create!(post_params)
```
This is would do, it is much cleaner.
created_at: 2025-04-01T15:46:10Z
author_association: COLLABORATOR
diff_hunk: @@ -7,7 +7,7 @@
   # Can be used by load balancers and uptime monitors to verify that the app is live.
   get "up" => "rails/health#show", as: :rails_health_check
 
-  resources :posts, only: :index, param: :slug
+  resources :posts, except: %i[new edit destroy update], param: :slug
path: config/routes.rb
user: login: jaimysimon
body: ```suggestion
  resources :posts, only: %i[index create show], param: :slug
```

Reason: https://jaimy-simon.neetorecord.com/watch/fccdbd33bd79f6bdcca6

Not sure if i had explained the same to you. 
created_at: 2025-04-01T15:50:42Z
author_association: COLLABORATOR
diff_hunk: @@ -7,7 +7,7 @@
   # Can be used by load balancers and uptime monitors to verify that the app is live.
   get "up" => "rails/health#show", as: :rails_health_check
 
-  resources :posts, only: :index, param: :slug
+  resources :posts, except: %i[new edit destroy update], param: :slug
path: config/routes.rb
user: login: shantanu471
body: @jaimysimon Understood.
created_at: 2025-04-02T05:24:20Z
author_association: OWNER

PR comments:
user: login: jaimysimon
created_at: 2025-04-01T05:48:20Z
body: @shantanu471 Where is the demo video? 
author_association: COLLABORATOR
user: login: shantanu471
created_at: 2025-04-01T05:54:09Z
body: Here's the video:
https://a-shantanu4.neetorecord.com/watch/145181a6c70066902ee9
author_association: OWNER
user: login: shantanu471
created_at: 2025-04-02T05:27:42Z
body: @jaimysimon I have made the changes you asked me to.
author_association: OWNER