[How to pass data between two client components?] Unable to complete Additional Exercise 4

Task: When an issue is assigned to a user, automatically set the status to IN_PROGRESS.

I am able to send a PATCH request using axios api and it is working fine (updating the badge if the issue detail page which is a server component). But my <AssigneeSelect … /> component which is the drop down is not updating its value. I’m unable to communicate between two client components which are inside a server component using router.refresh(). Can anyone please guide me here?

page.tsx

import authOptions from "@/app/auth/authOptions";
import prisma from "@/prisma/client";
import { Box, Flex, Grid } from "@radix-ui/themes";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { cache } from "react";
import AssigneeSelect from "./AssigneeSelect";
import DeleteIssueButton from "./DeleteIssueButton";
import EditIssueButton from "./EditIssueButton";
import IssueDetails from "./IssueDetails";
import StatusSelect from "./StatusSelect";

interface Props {
  params: { id: string };
}

const fetchIssue = cache((issueId: number) =>
  prisma.issue.findUnique({
    where: { id: issueId },
  })
);

const IssueDetailPage = async ({ params }: Props) => {
  const session = await getServerSession(authOptions);

  const issue = await fetchIssue(parseInt(params.id));

  if (!issue) notFound();

  console.log(issue.status);

  return (
    <Grid columns={{ initial: "1", sm: "5" }} gap="5">
      <Box className="md:col-span-4">
        <IssueDetails issue={issue} />
      </Box>
      {session && (
        <Box>
          <Flex direction="column" gap="4">
            <AssigneeSelect issue={issue} />
            <StatusSelect issue={issue} />
            <EditIssueButton issueId={issue.id} />
            <DeleteIssueButton issueId={issue.id} />
          </Flex>
        </Box>
      )}
    </Grid>
  );
};

export default IssueDetailPage;

export async function generateMetadata({ params }: Props) {
  const issue = await fetchIssue(parseInt(params.id));

  return {
    title: issue?.title,
    description: "Details of issue " + issue?.title,
  };
}

AssigneeSelect.tsx

"use client";

import { Skeleton } from "@/app/components";
import { Issue, Status, User } from "@prisma/client";
import { Select } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { useRouter } from "next/navigation";
import toast, { Toaster } from "react-hot-toast";

const AssigneeSelect = ({ issue }: { issue: Issue }) => {
  const { data: users, error, isLoading } = useUsers();

  if (isLoading) return <Skeleton />;

  if (error) return null;

  const router = useRouter();

  const assignIssue = async (userId: string) => {
    try {
      await axios.patch(`/api/issues/${issue.id}`, {
        assignedToUserId: userId || null,
        status: userId ? Status.IN_PROGRESS : Status.OPEN,
      });
      router.refresh();
    } catch (error) {
      toast.error("Changes could not be saved");
    }
  };

  return (
    <>
      <Select.Root
        defaultValue={issue.assignedToUserId || ""}
        onValueChange={assignIssue}
      >
        <Select.Trigger placeholder="Assign..."></Select.Trigger>
        <Select.Content>
          <Select.Group>
            <Select.Label>Suggestions</Select.Label>
            <Select.Item value="">Unassigned</Select.Item>
            {users?.map((user) => (
              <Select.Item key={user.id} value={user.id}>
                {user.name}
              </Select.Item>
            ))}
          </Select.Group>
        </Select.Content>
      </Select.Root>
      <Toaster />
    </>
  );
};

const useUsers = () =>
  useQuery<User[]>({
    queryKey: ["users"],
    queryFn: () => axios.get("/api/users").then((res) => res.data),
    staleTime: 60 * 1000, // 60 s refresh time
    retry: 3, // retry 3 times after first request
  });

export default AssigneeSelect;

StatusSelect.tsx

"use client";

import { Issue, Status } from "@prisma/client";
import { Select } from "@radix-ui/themes";
import axios from "axios";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast, { Toaster } from "react-hot-toast";

const statuses: { label: string; value: Status }[] = [
  { label: "Open", value: "OPEN" },
  { label: "In Progress", value: "IN_PROGRESS" },
  { label: "Closed", value: "CLOSED" },
];

const StatusSelect = ({ issue }: { issue: Issue }) => {
  const router = useRouter();

  const assignIssueStatus = async (status: Status) => {
    try {
      await axios.patch(`/api/issues/${issue.id}`, {
        status: status,
      });
      router.refresh();
    } catch (error) {
      toast.error("Changes could not be saved");
    }
  };

  return (
    <>
      <Select.Root
        defaultValue={issue.status}
        onValueChange={assignIssueStatus}
      >
        <Select.Trigger placeholder="Status Change"></Select.Trigger>
        <Select.Content>
          <Select.Group>
            <Select.Label>Status</Select.Label>
            {statuses.map((status) => (
              <Select.Item key={status.label} value={status.value}>
                {status.label}
              </Select.Item>
            ))}
          </Select.Group>
        </Select.Content>
      </Select.Root>
      <Toaster />
    </>
  );
};

export default StatusSelect;

validationSchemas.ts

import { z } from "zod";

export const issueSchema = z.object({
  title: z.string().min(1, "Title is required.").max(255),
  description: z.string().min(1, "Description is required.").max(65535),
});

export const patchIssueSchema = z.object({
  title: z.string().min(1, "Title is required.").max(255).optional(),
  description: z
    .string()
    .min(1, "Description is required.")
    .max(65535)
    .optional(),
  assignedToUserId: z
    .string()
    .min(1, "AssignedToUserId is required.")
    .max(255)
    .optional()
    .nullable(),
  status: z.enum(["OPEN", "IN_PROGRESS", "CLOSED"]).optional(),
});

issue-tracker\app\api\issues[id]\route.ts

import authOptions from "@/app/auth/authOptions";
import { patchIssueSchema } from "@/app/validationSchemas";
import prisma from "@/prisma/client";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";

export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const session = await getServerSession(authOptions);

  if (!session) return NextResponse.json({}, { status: 401 });

  const body = await request.json();
  const validation = patchIssueSchema.safeParse(body);

  if (!validation.success)
    return NextResponse.json(validation.error.format(), { status: 400 });

  console.log(body);

  const { assignedToUserId, title, description, status } = body;

  if (assignedToUserId) {
    const user = await prisma.user.findUnique({
      where: { id: assignedToUserId },
    });

    if (!user)
      return NextResponse.json({ error: "Invalid user." }, { status: 400 });
  }

  const issue = await prisma.issue.findUnique({
    where: { id: parseInt(params.id) },
  });

  if (!issue)
    return NextResponse.json({ error: "Invalid issue" }, { status: 404 });

  const updatedIssue = await prisma.issue.update({
    where: { id: issue.id },
    data: {
      title,
      description,
      assignedToUserId,
      status,
    },
  });

  return NextResponse.json(updatedIssue);
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const session = await getServerSession(authOptions);

  if (!session) return NextResponse.json({}, { status: 401 });

  const issue = await prisma.issue.findUnique({
    where: { id: parseInt(params.id) },
  });

  if (!issue)
    return NextResponse.json({ error: "Invalid issue" }, { status: 404 });

  await prisma.issue.delete({
    where: { id: issue.id },
  });

  return NextResponse.json({});
}

I’m not sure why yours isn’t working. I got mine to work by doing these three things (all of which you did too):

  • change the PATCH request in AssigneeSelect.tsx > assignIssue to change the status
  • update the backend in issue-tracker\app\api\issues[id]\route.ts to get status from body and then include in prisma update
  • back in assignIssue add router.refresh() from useRouter from next/navigation

When I assign an issue to a person you see the status badge change as expected. All of the things I changed I see you changed in the same way, so I don’t know why you are having a problem!

The badge is changing but the other client component which is StatusSelect.tsx is not updating.

Hi Kranthi, I came up with a solution to this if you’re still interested. In your StatusSelect component instead of giving the Select.Root a ‘defaultValue’, just give it a ‘value’. This makes it a controlled component, which has the slight downside that when you change it it can take a second to update, as it has to change the status in the db and then React has to notice the change and then update the page. If you can think of ways to improve this I would be interested to hear about them, but for the moment this at least does what we wanted. :slight_smile:

Actually, I figured out a way to avoid that annoying lag. Here’s what I ended up with:

"use client";

import { Issue, Status } from "@prisma/client";
import { IssueStatusBadge } from "@/app/components";
import { Select } from "@radix-ui/themes";
import axios from "axios";
import { useRouter } from "next/navigation";
import toast, { Toaster } from "react-hot-toast";
import { useState, useEffect } from "react";

const StatusSelect = ({ issue }: { issue: Issue }) => {
  const router = useRouter();
  const [selectedStatus, setSelectedStatus] = useState<Status>(issue.status);

  useEffect(() => {
    setSelectedStatus(issue.status)
  }, [issue.status])

  const assignStatus = async (newStatus: Status) => {
    const previousStatus = selectedStatus;
    setSelectedStatus(newStatus);
    try {
      await axios.patch(`/api/issues/${issue.id}`, {
        status: newStatus,
      });
      router.refresh();
    } catch {
      setSelectedStatus(previousStatus)
      toast.error("Changes could not be saved");
    }
  };

  return (
    <>
      <Select.Root value={selectedStatus} onValueChange={assignStatus}>
        <Select.Trigger />
        <Select.Content>
          <Select.Group>
            <Select.Label>Current Status</Select.Label>
            {Object.values(Status).map((status) => (
              <Select.Item value={status} key={status}>
                <IssueStatusBadge status={status} />
              </Select.Item>
            ))}
          </Select.Group>
        </Select.Content>
      </Select.Root>
      <Toaster />
    </>
  );
};

export default StatusSelect;

Thank you @eleerogers.
Such a simple yet elegant solution. :smile: