mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-02 01:00:33 -06:00
feat: Make response notes resolvable (#801)
* update notes ux * add capability to resolve notes * add migration * update text color * prevent updating unchanged note * add isEdited to ResponseNote * combine migrations into one * simplify services * fix UI issues --------- Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { updateResponseNoteAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
|
||||
import {
|
||||
resolveResponseNoteAction,
|
||||
updateResponseNoteAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { addResponseNote } from "@/lib/responseNotes/responsesNotes";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TResponseNote } from "@formbricks/types/v1/responses";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { MinusIcon, PencilIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { CheckIcon, PencilIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import { Maximize2Icon } from "lucide-react";
|
||||
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface ResponseNotesProps {
|
||||
@@ -35,6 +39,7 @@ export default function ResponseNotes({
|
||||
const [noteText, setNoteText] = useState("");
|
||||
const [isCreatingNote, setIsCreatingNote] = useState(false);
|
||||
const [isUpdatingNote, setIsUpdatingNote] = useState(false);
|
||||
const [isTextAreaOpen, setIsTextAreaOpen] = useState(true);
|
||||
const [noteId, setNoteId] = useState("");
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -52,7 +57,22 @@ export default function ResponseNotes({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolveNote = (note: TResponseNote) => {
|
||||
try {
|
||||
resolveResponseNoteAction(note.id);
|
||||
// when this was the last note, close the notes panel
|
||||
if (unresolvedNotes.length === 1) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast.error("An error occurred resolving a note");
|
||||
setIsUpdatingNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditPencil = (note: TResponseNote) => {
|
||||
setIsTextAreaOpen(true);
|
||||
setNoteText(note.text);
|
||||
setIsUpdatingNote(true);
|
||||
setNoteId(note.id);
|
||||
@@ -62,7 +82,7 @@ export default function ResponseNotes({
|
||||
e.preventDefault();
|
||||
setIsUpdatingNote(true);
|
||||
try {
|
||||
await updateResponseNoteAction(responseId, noteId, noteText);
|
||||
await updateResponseNoteAction(noteId, noteText);
|
||||
router.refresh();
|
||||
setIsUpdatingNote(false);
|
||||
setNoteText("");
|
||||
@@ -78,15 +98,17 @@ export default function ResponseNotes({
|
||||
}
|
||||
}, [notes]);
|
||||
|
||||
const unresolvedNotes = useMemo(() => notes.filter((note) => !note.isResolved), [notes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
!isOpen && notes.length && "group/hint cursor-pointer bg-white hover:-right-3",
|
||||
!isOpen && !notes.length && "cursor-pointer bg-slate-50",
|
||||
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
|
||||
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
|
||||
isOpen
|
||||
? "-right-5 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
: notes.length
|
||||
: unresolvedNotes.length
|
||||
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
|
||||
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
)}
|
||||
@@ -98,9 +120,9 @@ export default function ResponseNotes({
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
notes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!notes.length ? (
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">Note</h3>
|
||||
@@ -112,7 +134,7 @@ export default function ResponseNotes({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!notes.length ? (
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex flex-1 items-center justify-end pr-3">
|
||||
<span>
|
||||
<PlusIcon className=" h-5 w-5 text-slate-400" />
|
||||
@@ -125,20 +147,20 @@ export default function ResponseNotes({
|
||||
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="pb-1 text-sm text-slate-500">Note</h3>
|
||||
<h3 className="pb-1 text-sm text-amber-500">Note</h3>
|
||||
</div>
|
||||
<button
|
||||
className="h-6 w-6 cursor-pointer"
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}>
|
||||
<MinusIcon className="h-5 w-5 text-amber-500 hover:text-amber-600" />
|
||||
<Minimize2Icon className="h-5 w-5 text-amber-500 hover:text-amber-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto px-4 pt-2" ref={divRef}>
|
||||
{notes.map((note) => (
|
||||
<div className="mb-3" key={note.id}>
|
||||
{unresolvedNotes.map((note) => (
|
||||
<div className="group/notetext mb-3" key={note.id}>
|
||||
<span className="block font-semibold text-slate-700">
|
||||
{note.user.name}
|
||||
<time
|
||||
@@ -146,25 +168,62 @@ export default function ResponseNotes({
|
||||
dateTime={timeSince(note.updatedAt.toISOString())}>
|
||||
{timeSince(note.updatedAt.toISOString())}
|
||||
</time>
|
||||
{note.isEdited && (
|
||||
<span className="ml-1 text-[12px] font-normal text-slate-500">{"(edited)"}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="group/notetext flex items-center">
|
||||
<span className="block pr-1 text-slate-700">{note.text}</span>
|
||||
<div className="flex items-center">
|
||||
<span className="block text-slate-700">{note.text}</span>
|
||||
{profile.id === note.user.id && (
|
||||
<button onClick={() => handleEditPencil(note)}>
|
||||
<PencilIcon className=" h-3 w-3 text-gray-500 opacity-0 group-hover/notetext:opacity-100" />
|
||||
<button
|
||||
className="ml-auto hidden group-hover/notetext:block"
|
||||
onClick={() => {
|
||||
handleEditPencil(note);
|
||||
}}>
|
||||
<PencilIcon className="h-3 w-3 text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="ml-2 hidden group-hover/notetext:block"
|
||||
onClick={() => {
|
||||
handleResolveNote(note);
|
||||
}}>
|
||||
<CheckIcon className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[45rem] break-all" side="left" sideOffset={5}>
|
||||
<span className="text-slate-700">Resolve</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-[120px]">
|
||||
<div className={clsx("absolute bottom-0 w-full px-3 pb-3", !notes.length && "absolute bottom-0")}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-[120px] transition-all duration-300",
|
||||
!isTextAreaOpen && "pointer-events-none h-14"
|
||||
)}>
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute bottom-0 w-full px-3 pb-3",
|
||||
!unresolvedNotes.length && "absolute bottom-0"
|
||||
)}>
|
||||
<form onSubmit={isUpdatingNote ? handleNoteUpdate : handleNoteSubmission}>
|
||||
<div className="mt-4">
|
||||
<textarea
|
||||
rows={2}
|
||||
className="block w-full resize-none rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm"
|
||||
className={cn(
|
||||
"block w-full resize-none rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm",
|
||||
!isTextAreaOpen && "scale-y-0 transition-all duration-1000",
|
||||
!isTextAreaOpen && "translate-y-8 transition-all duration-300",
|
||||
isTextAreaOpen && "scale-y-1 transition-all duration-1000",
|
||||
isTextAreaOpen && "translate-y-0 transition-all duration-300"
|
||||
)}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
value={noteText}
|
||||
autoFocus
|
||||
@@ -178,10 +237,22 @@ export default function ResponseNotes({
|
||||
}}
|
||||
required></textarea>
|
||||
</div>
|
||||
<div className="mt-2 flex w-full justify-end">
|
||||
<Button variant="darkCTA" size="sm" type="submit" loading={isCreatingNote}>
|
||||
{isUpdatingNote ? "Save" : "Send"}
|
||||
<div className="pointer-events-auto z-10 mt-2 flex w-full items-center justify-end">
|
||||
<Button
|
||||
variant="minimal"
|
||||
type="button"
|
||||
size="sm"
|
||||
className={cn("mr-auto duration-300 ")}
|
||||
onClick={() => {
|
||||
setIsTextAreaOpen(!isTextAreaOpen);
|
||||
}}>
|
||||
{isTextAreaOpen ? "Hide" : "Show"}
|
||||
</Button>
|
||||
{isTextAreaOpen && (
|
||||
<Button variant="darkCTA" size="sm" type="submit" loading={isCreatingNote}>
|
||||
{isUpdatingNote ? "Save" : "Send"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { updateResponseNote } from "@formbricks/lib/services/responseNote";
|
||||
import { updateResponseNote, resolveResponseNote } from "@formbricks/lib/services/responseNote";
|
||||
|
||||
export const updateResponseNoteAction = async (responseId: string, noteId: string, text: string) => {
|
||||
await updateResponseNote(responseId, noteId, text);
|
||||
export const updateResponseNoteAction = async (responseNoteId: string, text: string) => {
|
||||
await updateResponseNote(responseNoteId, text);
|
||||
};
|
||||
|
||||
export const resolveResponseNoteAction = async (responseNoteId: string) => {
|
||||
await resolveResponseNote(responseNoteId);
|
||||
};
|
||||
|
||||
@@ -93,6 +93,8 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
isResolved: true,
|
||||
isEdited: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
|
||||
@@ -138,6 +138,8 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
isResolved: true,
|
||||
isEdited: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResponseNote" ADD COLUMN "isEdited" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "isResolved" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -124,6 +124,8 @@ model ResponseNote {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
text String
|
||||
isResolved Boolean @default(false)
|
||||
isEdited Boolean @default(false)
|
||||
}
|
||||
|
||||
model Tag {
|
||||
|
||||
@@ -55,6 +55,8 @@ const responseSelection = {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
isResolved: true,
|
||||
isEdited: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
|
||||
@@ -2,49 +2,61 @@ import "server-only";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { DatabaseError } from "@formbricks/types/v1/errors";
|
||||
import { TResponseNote } from "@formbricks/types/v1/responses";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const updateResponseNote = async (responseId: string, noteId: string, text: string): Promise<any> => {
|
||||
const select = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
isEdited: true,
|
||||
isResolved: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateResponseNote = async (responseNoteId: string, text: string): Promise<TResponseNote> => {
|
||||
try {
|
||||
const currentResponse = await prisma.response.findUnique({
|
||||
const updatedResponseNote = await prisma.responseNote.update({
|
||||
where: {
|
||||
id: responseId,
|
||||
},
|
||||
select: {
|
||||
notes: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentResponse) {
|
||||
throw new ResourceNotFoundError("Response", "No Response Found");
|
||||
}
|
||||
|
||||
const currentNote = currentResponse.notes.find((eachnote) => eachnote.id === noteId);
|
||||
|
||||
if (!currentNote) {
|
||||
throw new ResourceNotFoundError("Note", "No Note Found");
|
||||
}
|
||||
|
||||
const updatedResponse = await prisma.response.update({
|
||||
where: {
|
||||
id: responseId,
|
||||
id: responseNoteId,
|
||||
},
|
||||
data: {
|
||||
notes: {
|
||||
updateMany: {
|
||||
where: {
|
||||
id: noteId,
|
||||
},
|
||||
data: {
|
||||
text: text,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
text: text,
|
||||
updatedAt: new Date(),
|
||||
isEdited: true,
|
||||
},
|
||||
select,
|
||||
});
|
||||
return updatedResponse;
|
||||
return updatedResponseNote;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveResponseNote = async (responseNoteId: string): Promise<TResponseNote> => {
|
||||
try {
|
||||
const responseNote = await prisma.responseNote.update({
|
||||
where: {
|
||||
id: responseNoteId,
|
||||
},
|
||||
data: {
|
||||
updatedAt: new Date(),
|
||||
isResolved: true,
|
||||
},
|
||||
select,
|
||||
});
|
||||
return responseNote;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
|
||||
@@ -24,6 +24,8 @@ const ZResponseNote = z.object({
|
||||
id: z.string(),
|
||||
text: z.string(),
|
||||
user: ZResponseNoteUser,
|
||||
isResolved: z.boolean(),
|
||||
isEdited: z.boolean(),
|
||||
});
|
||||
|
||||
export type TResponseNote = z.infer<typeof ZResponseNote>;
|
||||
|
||||
Reference in New Issue
Block a user