mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-23 17:21:18 -05:00
Add a note to responses
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
21
apps/web/lib/responseNote/responsesNote.ts
Normal file
21
apps/web/lib/responseNote/responsesNote.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,12 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
},
|
||||
},
|
||||
responseNote: {
|
||||
include: {
|
||||
response: true,
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user