Hi everyone, I am a rookie developer learning Nextjs from Mosh.
It is such a great course that not only allows me to follow and practice but also encourages me to challenge myself continuously.
So I challenged myself to implement the Issue Status switching by creating a reusable issueStatusBadge component using the “Badge” component from Radix UI along with implementing the tooltip when hovered.
It changes the Issue’s status both on the frontend and backend at an amazing speed whenever the user clicks on the status badge.
I would appreciate it if anyone could correct my code or improve it in any form of way!!!
"use client";
import { Spinner } from "@/app/components";
import { Issue, Status } from "@prisma/client";
import { Badge, Tooltip } from "@radix-ui/themes";
import axios from "axios";
import { useState } from "react";
const statusMap: Record<
Status,
{
label: string;
nextLabel: string;
color: "red" | "violet" | "cyan" | "orange" | "green";
variant: "soft" | "solid";
}
> = {
OPEN: {
label: "Open",
nextLabel: "In Progress",
color: "red",
variant: "soft",
},
CLOSED: {
label: "Closed",
nextLabel: "Open",
color: "green",
variant: "soft",
},
IN_PROGRESS: {
label: "In Progress",
nextLabel: "Closed",
color: "violet",
variant: "soft",
},
};
type Props = {
title?: Issue["title"];
description?: Issue["description"];
issueId?: Issue["id"];
status: Issue["status"];
};
const IssueStatusBadge = ({ status, issueId, title, description }: Props) => {
const [currentStatus, setCurrentStatus] = useState(status);
const [isSettingStatus, setIsSettingStatus] = useState(false);
const handleClick = async () => {
let newStatus: Status;
switch (currentStatus) {
case "OPEN":
newStatus = "IN_PROGRESS";
break;
case "IN_PROGRESS":
newStatus = "CLOSED";
break;
case "CLOSED":
newStatus = "OPEN";
break;
default:
newStatus = currentStatus;
break;
}
try {
setIsSettingStatus(true);
await axios.patch("/api/issues/" + issueId, {
title: title,
description: description,
status: newStatus,
});
setCurrentStatus(newStatus);
} catch (error) {
setIsSettingStatus(false);
console.error(error);
}
setIsSettingStatus(false);
};
return (
<>
<Tooltip
className="!opacity-50"
delayDuration={0}
content={`Switch Status to: ${statusMap[currentStatus].nextLabel}`}
disableHoverableContent={true}
>
<Badge
className="transition-all duration-300 hover:cursor-pointer hover:shadow"
variant={statusMap[currentStatus].variant}
onClick={handleClick}
radius="large"
color={statusMap[currentStatus].color}
>
{statusMap[currentStatus].label}{" "}
{isSettingStatus ? <Spinner /> : null}
</Badge>
</Tooltip>
</>
);
};
export default IssueStatusBadge;
Hi Felix,. I’m happy to do a code review.
Design Critiques
IssueStatusBadge Single Responsibility
The purpose of IssueStatusBadge is to display a status badge, but in this design it also knows about issue details and can make API calls that change issues. It is doing too much (SRP). This inhibits its reuse in places like the dashboard where we probably don’t want to make changes to data and should only show the badge.
Suggestion
Revert IssueStatusBadge to its simple form and wrap (decorate) it with a surrounding component that implements the additional functionality. Something like this:
const IssueStatusChooser = ({ status, issueId, title, description }: Props) => {
const [currentStatus, setCurrentStatus] = useState(status);
...
const changeStatus = async () => {
const previousStatus = currentStatus;
try {
// call setCurrentStatus with new status (optimistic update)
// make API PATCH call to update status
} catch (err) {
// revert setCurrentStatus to previousStatus status if update fails
}
}
return <Button onClick={changeStatus}><IssueStatusBadge status={currentStatus} /></Button>
}
IssueStatusBadge Change Cycle
Instead of wrapping the badge in a button and cycling through statuses on each click (which requires a DB update each time) maybe consider adding status badges to a Select list and updating on each new selection. This would also allow you to remove the switch statement logic. Something like this should work, but might require some tweaking of styles to remove select or reduce the select border outline.
import { Status } from "@prisma/client";
...
<Select.Root
value={status}
onValueChange={changeStatus}
>
<Select.Trigger placeholder="Status" />
<Select.Content>
{Object.values(Status).map((status) => (
<Select.Item key={status} value={status}>
<IssueStatusBadge status={status} />
</Select.Item>
))}
</Select.Content>
</Select.Root>
Miscellaneous
Your color sum type includes unused strings “cyan” and “orange”
There’s no need to add variant
to statusMap
. Since the value is always “soft” you can pass it as a literal prop (variant="soft"
).
Hope this helps.
2 Likes
WOW! It’s the first time I’ve posted on this forum, and actually wasn’t expecting someone really reply.
Your advice is really helpful.
Thank you Mr.Programmist.
1 Like
Hi there,
I know it’s been 3 months now but I’d be happy if you can help me in this challenge.
I implemented the code Felix gave us, but it doesn’t change the database’s status. It does change the button tho. I’m using MongoDB instead.
Any idea why this is happening?
"use client"
import { Spinner } from "@/app/components"
import { Issue, Status } from "@prisma/client"
import { Badge, Tooltip } from "@radix-ui/themes"
import axios from "axios"
import { useState } from "react"
const statusMap: Record<
Status,
{
label: string
nextLabel: string
color: "red" | "violet" | "cyan" | "orange" | "green"
variant: "soft" | "solid"
}
> = {
OPEN: {
label: "Open",
nextLabel: "In Progress",
color: "red",
variant: "soft",
},
CLOSED: {
label: "Closed",
nextLabel: "Open",
color: "green",
variant: "soft",
},
IN_PROGRESS: {
label: "In Progress",
nextLabel: "Closed",
color: "violet",
variant: "soft",
},
}
type Props = {
title?: Issue["title"]
description?: Issue["description"]
issueId?: Issue["id"]
status: Issue["status"]
}
const IssueStatusBadge = ({ status, issueId, title, description }: Props) => {
const [currentStatus, setCurrentStatus] = useState(status)
const [isSettingStatus, setIsSettingStatus] = useState(false)
const handleClick = async () => {
let newStatus: Status
switch (currentStatus) {
case "OPEN":
newStatus = "IN_PROGRESS"
break
case "IN_PROGRESS":
newStatus = "CLOSED"
break
case "CLOSED":
newStatus = "OPEN"
break
default:
newStatus = currentStatus
break
}
try {
setIsSettingStatus(true)
await axios.patch(`/api/issues/${issueId}`, {
title: title,
description: description,
status: newStatus,
})
setCurrentStatus(newStatus)
} catch (error) {
setIsSettingStatus(false)
console.error("An error ocurred: ", error)
}
setIsSettingStatus(false)
}
return (
<>
<Tooltip
className="!opacity-50"
delayDuration={0}
content={`Switch Status to: ${statusMap[currentStatus].nextLabel}`}
disableHoverableContent={true}
>
<Badge
className="transition-all duration-300 hover:cursor-pointer hover:shadow"
variant={statusMap[currentStatus].variant}
onClick={handleClick}
radius="large"
color={statusMap[currentStatus].color}
>
{statusMap[currentStatus].label}{" "}
{isSettingStatus ? <Spinner /> : null}
</Badge>
</Tooltip>
</>
)
}
export default IssueStatusBadge
Felix_Lu:
"use client";
import { Spinner } from "@/app/components";
import { Issue, Status } from "@prisma/client";
import { Badge, Tooltip } from "@radix-ui/themes";
import axios from "axios";
import { useState } from "react";
const statusMap: Record<
Status,
{
label: string;
nextLabel: string;
color: "red" | "violet" | "cyan" | "orange" | "green";
variant: "soft" | "solid";
}
> = {
OPEN: {
label: "Open",
nextLabel: "In Progress",
color: "red",
variant: "soft",
},
CLOSED: {
label: "Closed",
nextLabel: "Open",
color: "green",
variant: "soft",
},
IN_PROGRESS: {
label: "In Progress",
nextLabel: "Closed",
color: "violet",
variant: "soft",
},
};
type Props = {
title?: Issue["title"];
description?: Issue["description"];
issueId?: Issue["id"];
status: Issue["status"];
};
const IssueStatusBadge = ({ status, issueId, title, description }: Props) => {
const [currentStatus, setCurrentStatus] = useState(status);
const [isSettingStatus, setIsSettingStatus] = useState(false);
const handleClick = async () => {
let newStatus: Status;
switch (currentStatus) {
case "OPEN":
newStatus = "IN_PROGRESS";
break;
case "IN_PROGRESS":
newStatus = "CLOSED";
break;
case "CLOSED":
newStatus = "OPEN";
break;
default:
newStatus = currentStatus;
break;
}
try {
setIsSettingStatus(true);
await axios.patch("/api/issues/" + issueId, {
title: title,
description: description,
status: newStatus,
});
setCurrentStatus(newStatus);
} catch (error) {
setIsSettingStatus(false);
console.error(error);
}
setIsSettingStatus(false);
};
return (
<>
<Tooltip
className="!opacity-50"
delayDuration={0}
content={`Switch Status to: ${statusMap[currentStatus].nextLabel}`}
disableHoverableContent={true}
>
<Badge
className="transition-all duration-300 hover:cursor-pointer hover:shadow"
variant={statusMap[currentStatus].variant}
onClick={handleClick}
radius="large"
color={statusMap[currentStatus].color}
>
{statusMap[currentStatus].label}{" "}
{isSettingStatus ? <Spinner /> : null}
</Badge>
</Tooltip>
</>
);
};
export default IssueStatusBadge;
When I try implementing this it doesnt change in the database. any tips?