Add a note to responses

This commit is contained in:
gitstart-formbricks
2023-06-07 16:10:23 +00:00
7 changed files with 336 additions and 66 deletions

View File

@@ -0,0 +1,112 @@
"use client";
import { timeSince } from "@formbricks/lib/time";
import { EyeSlashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import toast from "react-hot-toast";
import { useResponses } from "@/lib/responses/responses";
import { Button } from "@formbricks/ui";
import clsx from "clsx";
import { addResponseNote } from "@/lib/responseNote/responsesNote";
import { FormEvent } from "react";
import { OpenTextSummaryProps } from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse";
export default function ResponseNote({ data, environmentId, surveyId, isOpen, setIsOpen }: OpenTextSummaryProps & { isOpen: boolean, setIsOpen: (isOpen: boolean) => void}) {
const [noteText, setNoteText] = useState('');
const [isCreatingNote, setIsCreatingNote] = useState(false);
const { mutateResponses } = useResponses(environmentId, surveyId);
const responseNotes = data?.responseNote;
const handleNoteSubmission = async (e: FormEvent) => {
e.preventDefault()
setIsCreatingNote(true)
try {
await addResponseNote(environmentId, surveyId, data?.id, noteText)
mutateResponses()
setIsCreatingNote(false)
setNoteText('')
} catch (e) {
toast.error("An error occurred creating a new note");
setIsCreatingNote(false)
}
}
return (
<div
className={clsx(
"rounded-lg border border-slate-200 shadow-sm absolute transition-all w-1/5",
!isOpen && responseNotes.length && "bg-white",
!isOpen && !responseNotes.length && "bg-slate-50",
isOpen ? "h-full top-0 -right-5 bg-white" : "h-5/6 top-5 group-hover:right-[200px]"
)}
onClick={() => {
if(!isOpen) setIsOpen(true)
}}
>
{!isOpen ?
<div className="flex flex-col h-full">
<div className={clsx("space-y-2 px-2 pb-2 pt-2", responseNotes.length ? "bg-amber-50 h-16" : "bg-slate-200")}>
{!responseNotes.length ?
<div className="flex items-center justify-end">
<div className="group flex items-center">
<h3 className="ml-4 pb-1 text-slate-600 float-left">Note</h3>
</div>
</div> : null}
</div>
{!responseNotes.length ?
<div className="flex-1 flex justify-end items-center pr-3">
<button className="bg-slate-600 w-6 h-6 rounded-full">
<span>
<PlusIcon className="text-white"/>
</span>
</button>
</div> : null}
</div> :
<div className="flex flex-col h-full relative">
<div className="px-4 pb-5 pt-6 bg-amber-50 rounded-t-lg">
<div className="flex items-center justify-between">
<div className="group flex items-center">
<h3 className="pb-1 text-slate-600">Note</h3>
</div>
<div
className="w-8 h-8 cursor-pointer"
onClick={() => {
setIsOpen(!isOpen)
}}
>
<EyeSlashIcon className="text-amber-400"/>
</div>
</div>
</div>
<div className="px-4 overflow-auto pt-2">
{responseNotes.map((note) => (
<div className="mb-2" key={note.id}>
<span className="text-xs text-slate-500 block">
{note.user.name} wrote {" "}
<time className="text-slate-500" dateTime={timeSince(data.updatedAt)}>
({timeSince(note.updatedAt)})
</time>
</span>
<span className="block text-slate-700">{note.text}</span>
</div>
))}
</div>
<div className={clsx("flex-1 w-full px-4 pb-2", !responseNotes.length && "absolute bottom-0")}>
<form onSubmit={handleNoteSubmission}>
<div className="mt-4">
<textarea
rows={2}
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm"
onChange={(e) => setNoteText(e.target.value)}
value={noteText}
>
</textarea>
</div>
<div className="mt-4 flex w-full justify-end">
<Button className="bg-slate-600 hover:bg-slate-400" size="sm" type="submit" loading={isCreatingNote}>Send</Button>
</div>
</form>
</div>
</div>}
</div>
);
}

View File

@@ -10,8 +10,10 @@ import { useState } from "react";
import toast from "react-hot-toast";
import { RatingResponse } from "../RatingResponse";
import { deleteSubmission, useResponses } from "@/lib/responses/responses";
import clsx from "clsx";
import ResponseNote from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseNote";
interface OpenTextSummaryProps {
export interface OpenTextSummaryProps {
data: {
id: string;
personId: string;
@@ -23,6 +25,15 @@ interface OpenTextSummaryProps {
environmentId: string;
attributes: [];
};
responseNote: {
updatedAt: string;
createdAt: string;
id: string;
text: string;
user: {
name: string
}
}[]
value: string;
updatedAt: string;
finished: boolean;
@@ -50,6 +61,7 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { mutateResponses } = useResponses(environmentId, surveyId);
const [isDeleting, setIsDeleting] = useState(false);
const [isOpen, setIsOpen] = useState(false)
const handleDeleteSubmission = async () => {
setIsDeleting(true);
@@ -61,68 +73,71 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
};
return (
<div className=" my-6 rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-6 pb-5 pt-6">
<div className="flex items-center justify-between">
{data.personId ? (
<Link
className="group flex items-center"
href={`/environments/${environmentId}/people/${data.personId}`}>
<PersonAvatar personId={data.personId} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 group-hover:underline">
{displayIdentifier}
</h3>
</Link>
) : (
<div className="group flex items-center">
<PersonAvatar personId="anonymous" />
<h3 className="ml-4 pb-1 font-semibold text-slate-600">Anonymous</h3>
</div>
)}
<div className="flex space-x-4 text-sm">
{data.finished && (
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
Completed <CheckCircleIcon className="ml-1 h-5 w-5 text-green-400" />
</span>
<div className={clsx("relative group", isOpen && "min-h-[280px]")}>
<div className="my-6 rounded-lg border border-slate-200 bg-slate-50 shadow-sm z-10 w-4/5 relative">
<div className="space-y-2 px-6 pb-5 pt-6">
<div className="flex items-center justify-between">
{data.personId ? (
<Link
className="group flex items-center"
href={`/environments/${environmentId}/people/${data.personId}`}>
<PersonAvatar personId={data.personId} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 group-hover:underline">
{displayIdentifier}
</h3>
</Link>
) : (
<div className="group flex items-center">
<PersonAvatar personId="anonymous" />
<h3 className="ml-4 pb-1 font-semibold text-slate-600">Anonymous</h3>
</div>
)}
<time className="text-slate-500" dateTime={timeSince(data.updatedAt)}>
{timeSince(data.updatedAt)}
</time>
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-4 w-4 text-slate-500 hover:text-red-700" />
</button>
<div className="flex space-x-4 text-sm">
{data.finished && (
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
Completed <CheckCircleIcon className="ml-1 h-5 w-5 text-green-400" />
</span>
)}
<time className="text-slate-500" dateTime={timeSince(data.updatedAt)}>
{timeSince(data.updatedAt)}
</time>
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-4 w-4 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
</div>
</div>
<div className="space-y-6 rounded-b-lg bg-white p-6">
{data.responses.map((response, idx) => (
<div key={`${response.id}-${idx}`}>
<p className="text-sm text-slate-500">{response.question}</p>
{typeof response.answer !== "object" ? (
response.type === "rating" ? (
<div className="h-8">
<RatingResponse scale={response.scale} answer={response.answer} range={response.range} />
</div>
<div className="space-y-6 rounded-b-lg bg-white p-6">
{data.responses.map((response, idx) => (
<div key={`${response.id}-${idx}`}>
<p className="text-sm text-slate-500">{response.question}</p>
{typeof response.answer !== "object" ? (
response.type === "rating" ? (
<div className="h-8">
<RatingResponse scale={response.scale} answer={response.answer} range={response.range} />
</div>
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">{response.answer}</p>
)
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">{response.answer}</p>
)
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">{response.answer.join(", ")}</p>
)}
</div>
))}
<p className="ph-no-capture my-1 font-semibold text-slate-700">{response.answer.join(", ")}</p>
)}
</div>
))}
</div>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="response"
onDelete={handleDeleteSubmission}
isDeleting={isDeleting}
/>
</div>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="response"
onDelete={handleDeleteSubmission}
isDeleting={isDeleting}
/>
<ResponseNote data={data} environmentId={environmentId} surveyId={surveyId} isOpen={isOpen} setIsOpen={setIsOpen} />
</div>
);
}

View File

@@ -0,0 +1,21 @@
export const addResponseNote = async (
environmentId: string,
surveyId: string,
responseId: string,
text: string
) => {
try {
const res = await fetch(
`/api/v1/environments/${environmentId}/surveys/${surveyId}/responses/${responseId}/responsesNote`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(text),
}
);
return await res.json();
} catch (error) {
console.error(error);
throw Error(`createResponseNote: unable to create responseNote: ${error.message}`);
}
};

View File

@@ -0,0 +1,86 @@
import { captureTelemetry } from "@/../../packages/lib/telemetry";
import { hasEnvironmentAccess, getSessionUser } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
import { responses } from "@/lib/api/response";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
const responseId = req.query.submissionId?.toString();
const surveyId = req.query.surveyId?.toString();
// Check Authentication
const currentUser: any = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
// Check environmentId
if (environmentId === undefined) {
return res.status(400).json({ message: "Missing environmentId" });
}
// Check responseId
if (responseId === undefined) {
return res.status(400).json({ message: "Missing responseId" });
}
// Check surveyId
if (surveyId === undefined) {
return res.status(400).json({ message: "Missing surveyId" });
}
// Check whether user has access to the environment
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "Not authorized" });
}
// GET /api/environments[environmentId]/survey[surveyId]/responses/[responseId]/responsesNote
// Create a note to a response
if (req.method === "POST") {
const currentResponse = await prisma.response.findUnique({
where: {
id: responseId,
},
select: {
data: true,
survey: {
select: {
environmentId: true,
},
},
},
});
if (!currentResponse) {
return responses.notFoundResponse("Response", responseId, true);
}
const responseNote = {
data: {
createdAt: new Date(),
updatedAt: new Date(),
response: {
connect: {
id: responseId,
},
},
user: {
connect: {
id: currentUser.id,
},
},
text: req.body,
},
};
const newResponseNote = await prisma.responseNote.create(responseNote);
captureTelemetry("responseNote created");
return res.json(newResponseNote);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -49,6 +49,12 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
},
responseNote: {
include: {
response: true,
user: true,
},
},
},
});

View File

@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "ResponseNote" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"responseId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"text" TEXT NOT NULL,
CONSTRAINT "ResponseNote_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ResponseNote" ADD CONSTRAINT "ResponseNote_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "Response"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ResponseNote" ADD CONSTRAINT "ResponseNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -85,20 +85,32 @@ model Person {
}
model Response {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
personId String?
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
personId String?
responseNote ResponseNote[]
/// [ResponseData]
data Json @default("{}")
/// [ResponseMeta]
meta Json @default("{}")
}
model ResponseNote {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
responseId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
text String
}
enum SurveyStatus {
draft
inProgress
@@ -394,6 +406,7 @@ model User {
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
responseNote ResponseNote[]
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")