mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
Move Summary & responses page over to serverside data-retrieval (#433)
* use new services for server-side data retrieval in survey responses & summary * fix build errors * add notes to response schema * add response notes * fix type conflicts * add tag functionality * run pnpm format * fix tag state not updating correctly
This commit is contained in:
@@ -262,18 +262,17 @@ export default function Header() {
|
||||
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
Blog {/* <p className="bg-brand inline rounded-full px-2 text-xs text-white">1</p> */}
|
||||
</Link>
|
||||
{/* <Link
|
||||
{/* <Link
|
||||
href="/careers"
|
||||
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
Careers <p className="bg-brand inline rounded-full px-2 text-xs text-white">2</p>
|
||||
</Link> */}
|
||||
|
||||
<Link
|
||||
<Link
|
||||
href="/community"
|
||||
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
Community
|
||||
</Link>
|
||||
|
||||
</Popover.Group>
|
||||
<div className="hidden flex-1 items-center justify-end md:flex">
|
||||
<ThemeSelector className="relative z-10 mr-5" />
|
||||
@@ -368,7 +367,7 @@ export default function Header() {
|
||||
<Link href="#pricing">Pricing</Link>
|
||||
<Link href="/docs">Docs</Link>
|
||||
<Link href="/blog">Blog</Link>
|
||||
{/* <Link href="/careers">Careers</Link> */}
|
||||
{/* <Link href="/careers">Careers</Link> */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
EndIcon={GitHubIcon}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function CareersPage() {
|
||||
headingPt2="decisions."
|
||||
subheading="We are currently not hiring. Contributions are always welcome!"
|
||||
/>
|
||||
{/*
|
||||
{/*
|
||||
<div className="mx-auto w-3/4">
|
||||
|
||||
{Roles.map((role) => (
|
||||
|
||||
@@ -41,7 +41,7 @@ const CommunityPage = () => {
|
||||
Top Contributors
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
Super thankful to have you guys contribute for Formbricks 🙌
|
||||
Super thankful to have you guys contribute for Formbricks 🙌
|
||||
</p>
|
||||
<ol className="ml-4 mt-10 list-decimal">
|
||||
{topContributors.map((MVP) => (
|
||||
|
||||
@@ -295,7 +295,7 @@ export const authOptions: NextAuthOptions = {
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: "automatic",
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
@@ -331,7 +331,7 @@ export const authOptions: NextAuthOptions = {
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: "automatic",
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function createTeam(teamName: string, ownerUserId: string): Promise
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: "automatic",
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
|
||||
+12
-9
@@ -1,17 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
import { useResponses } from "@/lib/responses/responses";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ResponsesLimitReachedBanner({ environmentId, surveyId }) {
|
||||
const { responsesData } = useResponses(environmentId, surveyId);
|
||||
const reachedLimit = responsesData?.reachedLimit;
|
||||
const count = responsesData?.count;
|
||||
interface ResponsesLimitReachedBannerProps {
|
||||
environmentId: string;
|
||||
limitReached: boolean;
|
||||
responsesCount: number;
|
||||
}
|
||||
|
||||
export default function ResponsesLimitReachedBanner({
|
||||
environmentId,
|
||||
limitReached,
|
||||
responsesCount,
|
||||
}: ResponsesLimitReachedBannerProps) {
|
||||
return (
|
||||
<>
|
||||
{reachedLimit && (
|
||||
{limitReached && (
|
||||
<div className="bg-brand-light relative isolate flex items-center gap-x-6 overflow-hidden px-6 py-2.5 sm:px-3.5 sm:before:flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<p className="text-sm leading-6 text-gray-900">
|
||||
@@ -19,7 +22,7 @@ export default function ResponsesLimitReachedBanner({ environmentId, surveyId })
|
||||
<svg viewBox="0 0 2 2" className="mx-2 inline h-0.5 w-0.5 fill-current" aria-hidden="true">
|
||||
<circle cx={1} cy={1} r={1} />
|
||||
</svg>
|
||||
You can only see {RESPONSES_LIMIT_FREE} of the {count} responses you received.
|
||||
You can only see {RESPONSES_LIMIT_FREE} of the {responsesCount} responses you received.
|
||||
</p>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/settings/billing`}
|
||||
|
||||
+29
-24
@@ -1,36 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { OpenTextSummaryProps } from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse";
|
||||
import { addResponseNote } from "@/lib/responseNotes/responsesNotes";
|
||||
import { useResponses } from "@/lib/responses/responses";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TResponseNote } from "@formbricks/types/v1/responses";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { MinusIcon } from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import { Maximize2Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export default function ResponseNote({
|
||||
data,
|
||||
interface ResponseNotesProps {
|
||||
responseId: string;
|
||||
notes: TResponseNote[];
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ResponseNotes({
|
||||
responseId,
|
||||
notes,
|
||||
environmentId,
|
||||
surveyId,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: OpenTextSummaryProps & { isOpen: boolean; setIsOpen: (isOpen: boolean) => void }) {
|
||||
}: ResponseNotesProps) {
|
||||
const router = useRouter();
|
||||
const [noteText, setNoteText] = useState("");
|
||||
const [isCreatingNote, setIsCreatingNote] = useState(false);
|
||||
const { mutateResponses } = useResponses(environmentId, surveyId);
|
||||
const responseNotes = data?.responseNotes;
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleNoteSubmission = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsCreatingNote(true);
|
||||
try {
|
||||
await addResponseNote(environmentId, surveyId, data?.id, noteText);
|
||||
mutateResponses();
|
||||
await addResponseNote(environmentId, surveyId, responseId, noteText);
|
||||
router.refresh();
|
||||
setIsCreatingNote(false);
|
||||
setNoteText("");
|
||||
} catch (e) {
|
||||
@@ -43,17 +52,17 @@ export default function ResponseNote({
|
||||
if (divRef.current) {
|
||||
divRef.current.scrollTop = divRef.current.scrollHeight;
|
||||
}
|
||||
}, [responseNotes]);
|
||||
}, [notes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
!isOpen && responseNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
|
||||
!isOpen && !responseNotes.length && "cursor-pointer bg-slate-50",
|
||||
!isOpen && notes.length && "group/hint cursor-pointer bg-white hover:-right-3",
|
||||
!isOpen && !notes.length && "cursor-pointer bg-slate-50",
|
||||
isOpen
|
||||
? "-right-5 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
: responseNotes.length
|
||||
: notes.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]"
|
||||
)}
|
||||
@@ -65,9 +74,9 @@ export default function ResponseNote({
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
responseNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
notes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!responseNotes.length ? (
|
||||
{!notes.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>
|
||||
@@ -79,7 +88,7 @@ export default function ResponseNote({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!responseNotes.length ? (
|
||||
{!notes.length ? (
|
||||
<div className="flex flex-1 items-center justify-end pr-3">
|
||||
<span>
|
||||
<PlusIcon className=" h-5 w-5 text-slate-400" />
|
||||
@@ -104,14 +113,14 @@ export default function ResponseNote({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto px-4 pt-2" ref={divRef}>
|
||||
{responseNotes.map((note) => (
|
||||
{notes.map((note) => (
|
||||
<div className="mb-3" key={note.id}>
|
||||
<span className="block font-semibold text-slate-700">
|
||||
{note.user.name}
|
||||
<time
|
||||
className="ml-2 text-xs font-normal text-slate-500"
|
||||
dateTime={timeSince(data.updatedAt)}>
|
||||
{timeSince(note.updatedAt)}
|
||||
dateTime={timeSince(note.updatedAt.toISOString())}>
|
||||
{timeSince(note.updatedAt.toISOString())}
|
||||
</time>
|
||||
</span>
|
||||
<span className="block text-slate-700">{note.text}</span>
|
||||
@@ -119,11 +128,7 @@ export default function ResponseNote({
|
||||
))}
|
||||
</div>
|
||||
<div className="h-[120px]">
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute bottom-0 w-full px-3 pb-3",
|
||||
!responseNotes.length && "absolute bottom-0"
|
||||
)}>
|
||||
<div className={clsx("absolute bottom-0 w-full px-3 pb-3", !notes.length && "absolute bottom-0")}>
|
||||
<form onSubmit={handleNoteSubmission}>
|
||||
<div className="mt-4">
|
||||
<textarea
|
||||
|
||||
+6
-54
@@ -1,66 +1,21 @@
|
||||
import { useResponses } from "@/lib/responses/responses";
|
||||
import { removeTagFromResponse, useAddTagToResponse } from "@/lib/tags/mutateTags";
|
||||
import { useCreateTag } from "@/lib/tags/mutateTags";
|
||||
import { useTagsForEnvironment } from "@/lib/tags/tags";
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import { XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import TagsCombobox from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/TagsCombobox";
|
||||
import { useResponses } from "@/lib/responses/responses";
|
||||
import { removeTagFromResponse, useAddTagToResponse, useCreateTag } from "@/lib/tags/mutateTags";
|
||||
import { useTagsForEnvironment } from "@/lib/tags/tags";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { useEffect } from "react";
|
||||
import { Tag } from "./Tag";
|
||||
|
||||
interface ResponseTagsWrapperProps {
|
||||
tags: {
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
}[];
|
||||
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
productId: string;
|
||||
responseId: string;
|
||||
}
|
||||
|
||||
export function Tag({
|
||||
tagId,
|
||||
tagName,
|
||||
onDelete,
|
||||
tags,
|
||||
setTagsState,
|
||||
highlight,
|
||||
}: {
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
onDelete: (tagId: string) => void;
|
||||
tags: ResponseTagsWrapperProps["tags"];
|
||||
setTagsState: (tags: ResponseTagsWrapperProps["tags"]) => void;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
key={tagId}
|
||||
className={cn(
|
||||
"relative flex items-center justify-between gap-2 rounded-full border bg-slate-600 px-2 py-1 text-slate-100",
|
||||
highlight && "border-2 border-green-600"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{tagName}</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="cursor-pointer text-sm"
|
||||
onClick={() => {
|
||||
setTagsState(tags.filter((tag) => tag.tagId !== tagId));
|
||||
|
||||
onDelete(tagId);
|
||||
}}>
|
||||
<XCircleIcon fontSize={24} className="h-4 w-4 text-slate-100 hover:text-slate-200" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
tags,
|
||||
environmentId,
|
||||
@@ -73,11 +28,8 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
|
||||
|
||||
const { createTag } = useCreateTag(environmentId);
|
||||
|
||||
const { mutateResponses } = useResponses(environmentId, surveyId);
|
||||
|
||||
const { data: environmentTags, mutate: refetchEnvironmentTags } = useTagsForEnvironment(environmentId);
|
||||
|
||||
const { addTagToRespone } = useAddTagToResponse(environmentId, surveyId, responseId);
|
||||
|
||||
const onDelete = async (tagId: string) => {
|
||||
@@ -121,7 +73,7 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
|
||||
currentTags={tags.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
|
||||
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
|
||||
createTag={(tagName) => {
|
||||
createTag(
|
||||
{
|
||||
|
||||
+25
-29
@@ -1,29 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useResponses } from "@/lib/responses/responses";
|
||||
import { generateQuestionsAndAttributes, useSurvey } from "@/lib/surveys/surveys";
|
||||
import { Button, ErrorComponent } from "@formbricks/ui";
|
||||
import { useMemo } from "react";
|
||||
import SingleResponse from "./SingleResponse";
|
||||
import { convertToCSV } from "@/lib/csvConversion";
|
||||
import { useCallback } from "react";
|
||||
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { generateQuestionsAndAttributes } from "@/lib/surveys/surveys";
|
||||
import { getTodaysDateFormatted } from "@formbricks/lib/time";
|
||||
import { useProduct } from "@/lib/products/products";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import SingleResponse from "./SingleResponse";
|
||||
|
||||
export default function ResponseTimeline({ environmentId, surveyId }) {
|
||||
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
|
||||
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
|
||||
const { product } = useProduct(environmentId);
|
||||
|
||||
const responses = responsesData?.responses;
|
||||
interface ResponseTimelineProps {
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
responses: TResponse[];
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export default function ResponseTimeline({
|
||||
environmentId,
|
||||
surveyId,
|
||||
responses,
|
||||
survey,
|
||||
}: ResponseTimelineProps) {
|
||||
const { attributeMap, questionNames } = generateQuestionsAndAttributes(survey, responses);
|
||||
|
||||
const [isDownloadCSVLoading, setIsDownloadCSVLoading] = useState(false);
|
||||
|
||||
const matchQandA = useMemo(() => {
|
||||
@@ -37,6 +40,7 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
|
||||
// Replace question IDs with question headlines in response data
|
||||
const updatedResponses = responses.map((response) => {
|
||||
const updatedResponse: Array<{
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
type: string;
|
||||
@@ -48,6 +52,7 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
|
||||
const answer = response.data[question.id];
|
||||
if (answer) {
|
||||
updatedResponse.push({
|
||||
id: createId(),
|
||||
question: question.headline,
|
||||
type: question.type,
|
||||
scale: question.scale,
|
||||
@@ -56,12 +61,12 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ...response, responses: updatedResponse, person: response.person };
|
||||
return { ...response, responses: updatedResponse };
|
||||
});
|
||||
|
||||
const updatedResponsesWithTags = updatedResponses.map((response) => ({
|
||||
...response,
|
||||
tags: response.tags?.map((tag) => tag.tag),
|
||||
tags: response.tags?.map((tag) => tag),
|
||||
}));
|
||||
|
||||
return updatedResponsesWithTags;
|
||||
@@ -164,14 +169,6 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
}, [attributeMap, csvFileName, matchQandA, questionNames]);
|
||||
|
||||
if (isLoadingResponses || isLoadingSurvey) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorResponses || isErrorSurvey) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{responses.length === 0 ? (
|
||||
@@ -191,7 +188,6 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
|
||||
data={updatedResponse}
|
||||
surveyId={surveyId}
|
||||
environmentId={environmentId}
|
||||
productId={product?.id ?? ""}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
+23
-51
@@ -1,49 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import { deleteSubmission, useResponses } from "@/lib/responses/responses";
|
||||
import { truncate } from "@/lib/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { PersonAvatar, TooltipContent, TooltipProvider, TooltipTrigger, Tooltip } from "@formbricks/ui";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { QuestionType } from "@formbricks/types/questions";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { PersonAvatar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
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 "./ResponseNote";
|
||||
import ResponseTagsWrapper from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTagsWrapper";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
import { QuestionType } from "@formbricks/types/questions";
|
||||
import ResponseTagsWrapper from "./ResponseTagsWrapper";
|
||||
|
||||
export interface OpenTextSummaryProps {
|
||||
data: {
|
||||
id: string;
|
||||
personId: string;
|
||||
surveyId: string;
|
||||
person: {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
environmentId: string;
|
||||
attributes: [];
|
||||
};
|
||||
personAttributes: {
|
||||
[key: string]: string;
|
||||
};
|
||||
responseNotes: {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
text: string;
|
||||
user: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
tags: TTag[];
|
||||
value: string;
|
||||
updatedAt: string;
|
||||
finished: boolean;
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
data: TResponse & {
|
||||
responses: {
|
||||
id: string;
|
||||
question: string;
|
||||
@@ -53,20 +30,16 @@ export interface OpenTextSummaryProps {
|
||||
range?: number;
|
||||
}[];
|
||||
};
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
productId: string;
|
||||
}
|
||||
|
||||
function findEmail(person) {
|
||||
const emailAttribute = person.attributes.find((attr) => attr.attributeClass.name === "email");
|
||||
const emailAttribute = person.attributes.email;
|
||||
return emailAttribute ? emailAttribute.value : null;
|
||||
}
|
||||
|
||||
export default function SingleResponse({ data, environmentId, surveyId, productId }: OpenTextSummaryProps) {
|
||||
export default function SingleResponse({ data, environmentId, surveyId }: OpenTextSummaryProps) {
|
||||
const email = data.person && findEmail(data.person);
|
||||
const displayIdentifier = email || data.personId;
|
||||
const responseNotes = data?.responseNotes;
|
||||
const displayIdentifier = email || (data.person && truncate(data.person.id, 16)) || null;
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const { mutateResponses } = useResponses(environmentId, surveyId);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -86,7 +59,7 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
|
||||
{Object.keys(data.personAttributes).map((key) => {
|
||||
return (
|
||||
<p>
|
||||
{key}: <span className="font-bold">{data.personAttributes[key]}</span>
|
||||
{key}: <span className="font-bold">{data.personAttributes && data.personAttributes[key]}</span>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
@@ -98,18 +71,18 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
|
||||
<div
|
||||
className={clsx(
|
||||
"relative z-10 my-6 rounded-lg border border-slate-200 bg-slate-50 shadow-sm transition-all",
|
||||
isOpen ? "w-3/4" : responseNotes.length ? "w-[96.5%]" : "w-full group-hover:w-[96.5%]"
|
||||
isOpen ? "w-3/4" : data.notes.length ? "w-[96.5%]" : "w-full group-hover:w-[96.5%]"
|
||||
)}>
|
||||
<div className="space-y-2 px-6 pb-5 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{data.personId ? (
|
||||
{data.person?.id ? (
|
||||
<Link
|
||||
className="group flex items-center"
|
||||
href={`/environments/${environmentId}/people/${data.personId}`}>
|
||||
href={`/environments/${environmentId}/people/${data.person.id}`}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<PersonAvatar personId={data.personId} />
|
||||
<PersonAvatar personId={data.person.id} />
|
||||
</TooltipTrigger>
|
||||
{tooltipContent}
|
||||
</Tooltip>
|
||||
@@ -131,8 +104,8 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
|
||||
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 className="text-slate-500" dateTime={timeSince(data.updatedAt.toISOString())}>
|
||||
{timeSince(data.updatedAt.toISOString())}
|
||||
</time>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -167,7 +140,6 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
|
||||
<ResponseTagsWrapper
|
||||
environmentId={environmentId}
|
||||
surveyId={surveyId}
|
||||
productId={productId}
|
||||
responseId={data.id}
|
||||
tags={data.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
|
||||
key={data.tags.map((tag) => tag.id).join("-")}
|
||||
@@ -182,12 +154,12 @@ export default function SingleResponse({ data, environmentId, surveyId, productI
|
||||
/>
|
||||
</div>
|
||||
<ResponseNote
|
||||
data={data}
|
||||
responseId={data.id}
|
||||
notes={data.notes}
|
||||
environmentId={environmentId}
|
||||
surveyId={surveyId}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
productId={productId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { XCircleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
interface Tag {
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
}
|
||||
|
||||
interface ResponseTagsWrapperProps {
|
||||
tags: Tag[];
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
onDelete: (tagId: string) => void;
|
||||
setTagsState: (tags: Tag[]) => void;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export function Tag({
|
||||
tagId,
|
||||
tagName,
|
||||
onDelete,
|
||||
tags,
|
||||
setTagsState,
|
||||
highlight,
|
||||
}: {
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
onDelete: (tagId: string) => void;
|
||||
tags: ResponseTagsWrapperProps["tags"];
|
||||
setTagsState: (tags: ResponseTagsWrapperProps["tags"]) => void;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
key={tagId}
|
||||
className={cn(
|
||||
"relative flex items-center justify-between gap-2 rounded-full border bg-slate-600 px-2 py-1 text-slate-100",
|
||||
highlight && "border-2 border-green-600"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{tagName}</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="cursor-pointer text-sm"
|
||||
onClick={() => {
|
||||
setTagsState(tags.filter((tag) => tag.tagId !== tagId));
|
||||
|
||||
onDelete(tagId);
|
||||
}}>
|
||||
<XCircleIcon fontSize={24} className="h-4 w-4 text-slate-100 hover:text-slate-200" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,16 @@ import ContentWrapper from "@/components/shared/ContentWrapper";
|
||||
import SurveyResultsTabs from "../SurveyResultsTabs";
|
||||
import ResponseTimeline from "./ResponseTimeline";
|
||||
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
|
||||
|
||||
export default function ResponsesPage({ params }) {
|
||||
export default async function ResponsesPage({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const { responses, responsesCount, limitReached, survey } = await getAnalysisData(session, params.surveyId);
|
||||
return (
|
||||
<>
|
||||
<SurveyResultsTabs
|
||||
@@ -11,9 +19,18 @@ export default function ResponsesPage({ params }) {
|
||||
environmentId={params.environmentId}
|
||||
surveyId={params.surveyId}
|
||||
/>
|
||||
<ResponsesLimitReachedBanner environmentId={params.environmentId} surveyId={params.surveyId} />
|
||||
<ResponsesLimitReachedBanner
|
||||
environmentId={params.environmentId}
|
||||
limitReached={limitReached}
|
||||
responsesCount={responsesCount}
|
||||
/>
|
||||
<ContentWrapper>
|
||||
<ResponseTimeline environmentId={params.environmentId} surveyId={params.surveyId} />
|
||||
<ResponseTimeline
|
||||
environmentId={params.environmentId}
|
||||
surveyId={params.surveyId}
|
||||
responses={responses}
|
||||
survey={survey}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
|
||||
+3
-4
@@ -2,16 +2,15 @@
|
||||
|
||||
import CodeBlock from "@/components/shared/CodeBlock";
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import { Survey } from "@formbricks/types/surveys";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { CodeBracketIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface LinkSurveyModalProps {
|
||||
survey: Survey;
|
||||
survey: TSurvey;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
+4
-4
@@ -54,7 +54,7 @@ export default function MultipleChoiceSummary({
|
||||
}
|
||||
|
||||
function findEmail(person) {
|
||||
const emailAttribute = person.attributes.find((attr) => attr.attributeClass.name === "email");
|
||||
const emailAttribute = person.attributes.email;
|
||||
return emailAttribute ? emailAttribute.value : null;
|
||||
}
|
||||
|
||||
@@ -79,12 +79,12 @@ export default function MultipleChoiceSummary({
|
||||
// count the responses
|
||||
for (const response of questionSummary.responses) {
|
||||
// if single choice, only add responses that are in the choices
|
||||
if (isSingleChoice && response.value in resultsDict) {
|
||||
resultsDict[response.value].count += 1;
|
||||
if (isSingleChoice && response.value.toString() in resultsDict) {
|
||||
resultsDict[response.value.toString()].count += 1;
|
||||
} else if (isSingleChoice) {
|
||||
// if single choice and not in choices, add to other
|
||||
addOtherChoice(response, response.value);
|
||||
} else {
|
||||
} else if (Array.isArray(response.value)) {
|
||||
// if multi choice add all responses
|
||||
for (const choice of response.value) {
|
||||
if (choice in resultsDict) {
|
||||
|
||||
+6
-6
@@ -12,7 +12,7 @@ interface OpenTextSummaryProps {
|
||||
}
|
||||
|
||||
function findEmail(person) {
|
||||
const emailAttribute = person.attributes.find((attr) => attr.attributeClass.name === "email");
|
||||
const emailAttribute = person.attributes.email;
|
||||
return emailAttribute ? emailAttribute.value : null;
|
||||
}
|
||||
|
||||
@@ -39,17 +39,17 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
|
||||
</div>
|
||||
{questionSummary.responses.map((response) => {
|
||||
const email = response.person && findEmail(response.person);
|
||||
const displayIdentifier = email || truncate(response.personId, 16);
|
||||
const displayIdentifier = email || (response.person && truncate(response.person.id, 16)) || null;
|
||||
return (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-slate-800">
|
||||
<div className="pl-6">
|
||||
{response.personId ? (
|
||||
{response.person ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/people/${response.personId}`}>
|
||||
<PersonAvatar personId={response.personId} />
|
||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||
<PersonAvatar personId={response.person.id} />
|
||||
|
||||
<p className="ph-no-capture ml-2 text-slate-600 group-hover:underline">
|
||||
{displayIdentifier}
|
||||
@@ -65,7 +65,7 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-6 text-slate-500">{timeSince(response.updatedAt)}</div>
|
||||
<div className="px-6 text-slate-500">{timeSince(response.updatedAt.toISOString())}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
|
||||
// count the responses
|
||||
for (const response of questionSummary.responses) {
|
||||
// if single choice, only add responses that are in the choices
|
||||
if (response.value in resultsDict) {
|
||||
if (!Array.isArray(response.value) && response.value in resultsDict) {
|
||||
resultsDict[response.value].count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
+30
-46
@@ -1,17 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useResponses } from "@/lib/responses/responses";
|
||||
import { useSurvey } from "@/lib/surveys/surveys";
|
||||
import type { QuestionSummary } from "@formbricks/types/responses";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
import { useMemo } from "react";
|
||||
import CTASummary from "./CTASummary";
|
||||
import MultipleChoiceSummary from "./MultipleChoiceSummary";
|
||||
import NPSSummary from "./NPSSummary";
|
||||
import OpenTextSummary from "./OpenTextSummary";
|
||||
import RatingSummary from "./RatingSummary";
|
||||
import {
|
||||
QuestionType,
|
||||
type CTAQuestion,
|
||||
@@ -19,43 +6,40 @@ import {
|
||||
type MultipleChoiceSingleQuestion,
|
||||
type NPSQuestion,
|
||||
type OpenTextQuestion,
|
||||
type Question,
|
||||
type RatingQuestion,
|
||||
} from "@formbricks/types/questions";
|
||||
import type { QuestionSummary } from "@formbricks/types/responses";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys";
|
||||
import CTASummary from "./CTASummary";
|
||||
import MultipleChoiceSummary from "./MultipleChoiceSummary";
|
||||
import NPSSummary from "./NPSSummary";
|
||||
import OpenTextSummary from "./OpenTextSummary";
|
||||
import RatingSummary from "./RatingSummary";
|
||||
|
||||
export default function SummaryList({ environmentId, surveyId }) {
|
||||
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
|
||||
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
|
||||
interface SummaryListProps {
|
||||
environmentId: string;
|
||||
responses: TResponse[];
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
const responses = responsesData?.responses;
|
||||
|
||||
const summaryData: QuestionSummary<Question>[] = useMemo(() => {
|
||||
if (survey && responses) {
|
||||
return survey.questions.map((question) => {
|
||||
const questionResponses = responses
|
||||
.filter((response) => question.id in response.data)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.data[question.id],
|
||||
updatedAt: r.updatedAt,
|
||||
personId: r.personId,
|
||||
person: r.person,
|
||||
}));
|
||||
return {
|
||||
question,
|
||||
responses: questionResponses,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}, [survey, responses]);
|
||||
|
||||
if (isLoadingResponses || isLoadingSurvey) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorResponses || isErrorSurvey) {
|
||||
return <ErrorComponent />;
|
||||
export default function SummaryList({ environmentId, responses, survey }: SummaryListProps) {
|
||||
let summaryData: QuestionSummary<TSurveyQuestion>[] = [];
|
||||
if (survey && responses) {
|
||||
summaryData = survey.questions.map((question) => {
|
||||
const questionResponses = responses
|
||||
.filter((response) => question.id in response.data)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.data[question.id],
|
||||
updatedAt: r.updatedAt,
|
||||
person: r.person,
|
||||
}));
|
||||
return {
|
||||
question,
|
||||
responses: questionResponses,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+38
-28
@@ -3,8 +3,9 @@
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { useResponses } from "@/lib/responses/responses";
|
||||
import { useSurvey } from "@/lib/surveys/surveys";
|
||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import {
|
||||
Button,
|
||||
Confetti,
|
||||
@@ -20,48 +21,57 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkSurveyModal from "./LinkSurveyModal";
|
||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||
|
||||
export default function SummaryMetadata({ surveyId, environmentId }) {
|
||||
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
|
||||
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
|
||||
interface SummaryMetadataProps {
|
||||
surveyId: string;
|
||||
environmentId: string;
|
||||
responses: TResponse[];
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export default function SummaryMetadata({
|
||||
surveyId,
|
||||
environmentId,
|
||||
responses,
|
||||
survey,
|
||||
}: SummaryMetadataProps) {
|
||||
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
const [confetti, setConfetti] = useState(false);
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const newSurveyParam = searchParams?.get("success");
|
||||
if (newSurveyParam && survey && environment) {
|
||||
setConfetti(true);
|
||||
toast.success(
|
||||
survey.type === "web" && !environment.widgetSetupCompleted
|
||||
? "Almost there! Install widget to start receiving responses."
|
||||
: "Congrats! Your survey is live.",
|
||||
{
|
||||
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
|
||||
duration: 5000,
|
||||
position: "bottom-right",
|
||||
if (environment) {
|
||||
const newSurveyParam = searchParams?.get("success");
|
||||
if (newSurveyParam && survey && environment) {
|
||||
setConfetti(true);
|
||||
toast.success(
|
||||
survey.type === "web" && !environment.widgetSetupCompleted
|
||||
? "Almost there! Install widget to start receiving responses."
|
||||
: "Congrats! Your survey is live.",
|
||||
{
|
||||
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
|
||||
duration: 5000,
|
||||
position: "bottom-right",
|
||||
}
|
||||
);
|
||||
if (survey.type === "link") {
|
||||
setShowLinkModal(true);
|
||||
}
|
||||
);
|
||||
if (survey.type === "link") {
|
||||
setShowLinkModal(true);
|
||||
}
|
||||
}
|
||||
}, [environment, searchParams, survey]);
|
||||
|
||||
const responses = responsesData?.responses;
|
||||
|
||||
const completionRate = useMemo(() => {
|
||||
if (!responses) return 0;
|
||||
return (responses.filter((r) => r.finished).length / responses.length) * 100;
|
||||
}, [responses]);
|
||||
|
||||
if (isLoadingResponses || isLoadingSurvey || isLoadingEnvironment) {
|
||||
if (isLoadingEnvironment) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorResponses || isErrorSurvey || isErrorEnvironment) {
|
||||
if (isErrorEnvironment) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
@@ -72,7 +82,7 @@ export default function SummaryMetadata({ surveyId, environmentId }) {
|
||||
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Survey displays</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{survey.numDisplays === 0 ? <span>-</span> : survey.numDisplays}
|
||||
{survey.analytics.numDisplays === 0 ? <span>-</span> : survey.analytics.numDisplays}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
@@ -90,10 +100,10 @@ export default function SummaryMetadata({ surveyId, environmentId }) {
|
||||
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{survey.responseRate === null || survey.responseRate === 0 ? (
|
||||
{survey.analytics.responseRate === null || survey.analytics.responseRate === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<span>{Math.round(survey.responseRate * 100)} %</span>
|
||||
<span>{Math.round(survey.analytics.responseRate * 100)} %</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -130,7 +140,7 @@ export default function SummaryMetadata({ surveyId, environmentId }) {
|
||||
</div>
|
||||
<div className="flex flex-col justify-between lg:col-span-1">
|
||||
<div className="text-right text-xs text-slate-400">
|
||||
Last updated: {timeSinceConditionally(survey.updatedAt)}
|
||||
Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())}
|
||||
</div>
|
||||
<div className="flex justify-end gap-x-1.5">
|
||||
{survey.type === "link" && (
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
import { getSurveyResponses } from "@formbricks/lib/services/response";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { Session } from "next-auth";
|
||||
|
||||
export const getAnalysisData = async (session: Session, surveyId: string) => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) throw new Error(`Survey not found: ${surveyId}`);
|
||||
const allResponses = await getSurveyResponses(surveyId);
|
||||
const limitReached =
|
||||
IS_FORMBRICKS_CLOUD && session?.user.plan === "free" && allResponses.length >= RESPONSES_LIMIT_FREE;
|
||||
const responses = limitReached ? allResponses.slice(0, RESPONSES_LIMIT_FREE) : allResponses;
|
||||
const responsesCount = allResponses.length;
|
||||
|
||||
return { responses, responsesCount, limitReached, survey };
|
||||
};
|
||||
@@ -1,17 +1,35 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
|
||||
import ContentWrapper from "@/components/shared/ContentWrapper";
|
||||
import { getServerSession } from "next-auth";
|
||||
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
|
||||
import SurveyResultsTabs from "../SurveyResultsTabs";
|
||||
import SummaryList from "./SummaryList";
|
||||
import SummaryMetadata from "./SummaryMetadata";
|
||||
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
|
||||
|
||||
export default function SummaryPage({ params }) {
|
||||
export default async function SummaryPage({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const { responses, responsesCount, limitReached, survey } = await getAnalysisData(session, params.surveyId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SurveyResultsTabs activeId="summary" environmentId={params.environmentId} surveyId={params.surveyId} />
|
||||
<ResponsesLimitReachedBanner environmentId={params.environmentId} surveyId={params.surveyId} />
|
||||
<ResponsesLimitReachedBanner
|
||||
environmentId={params.environmentId}
|
||||
limitReached={limitReached}
|
||||
responsesCount={responsesCount}
|
||||
/>
|
||||
<ContentWrapper>
|
||||
<SummaryMetadata surveyId={params.surveyId} environmentId={params.environmentId} />
|
||||
<SummaryList environmentId={params.environmentId} surveyId={params.surveyId} />
|
||||
<SummaryMetadata
|
||||
surveyId={params.surveyId}
|
||||
environmentId={params.environmentId}
|
||||
responses={responses}
|
||||
survey={survey}
|
||||
/>
|
||||
<SummaryList environmentId={params.environmentId} survey={survey} responses={responses} />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ export const populateEnvironment = {
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: EventType.automatic,
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "@formbricks/lib/fetcher";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
|
||||
export const useSurveys = (environmentId: string) => {
|
||||
const { data, error, mutate, isLoading } = useSWR(`/api/v1/environments/${environmentId}/surveys`, fetcher);
|
||||
@@ -134,34 +136,29 @@ export const duplicateSurvey = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const generateQuestionsAndAttributes = (survey, responses) => {
|
||||
export const generateQuestionsAndAttributes = (survey: TSurvey, responses: TResponse[]) => {
|
||||
let questionNames: string[] = [];
|
||||
|
||||
if (survey?.questions) {
|
||||
questionNames = survey.questions.map((question) => question.headline);
|
||||
}
|
||||
|
||||
const attributeMap: Record<string, Record<string, string | null>> = {};
|
||||
const attributeMap: Record<string, Record<string, string | number>> = {};
|
||||
|
||||
if (responses) {
|
||||
responses.forEach((response) => {
|
||||
const { person } = response;
|
||||
if (person !== null) {
|
||||
const { id, attributes } = person;
|
||||
attributes.forEach((attribute) => {
|
||||
const { attributeClass, value } = attribute;
|
||||
const attributeName = attributeClass.name;
|
||||
|
||||
Object.keys(attributes).forEach((attributeName) => {
|
||||
if (!attributeMap.hasOwnProperty(attributeName)) {
|
||||
attributeMap[attributeName] = {};
|
||||
}
|
||||
|
||||
attributeMap[attributeName][id] = value;
|
||||
attributeMap[attributeName][id] = attributes[attributeName];
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
questionNames,
|
||||
attributeMap,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { EventType } from '@prisma/client';
|
||||
import { EventType } from "@prisma/client";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
const environmentId = req.query.environmentId?.toString();
|
||||
|
||||
+3
@@ -14,6 +14,9 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
if (surveyId === undefined) {
|
||||
return res.status(400).json({ message: "Missing surveyId" });
|
||||
}
|
||||
if (targetEnvironmentId === undefined) {
|
||||
return res.status(400).json({ message: "Missing targetEnvironmentId" });
|
||||
}
|
||||
|
||||
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
|
||||
const hasTargetEnvAccess = await hasEnvironmentAccess(req, res, targetEnvironmentId);
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
},
|
||||
},
|
||||
responseNotes: {
|
||||
notes: {
|
||||
include: {
|
||||
response: true,
|
||||
user: true,
|
||||
|
||||
@@ -95,7 +95,7 @@ model Response {
|
||||
surveyId String
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
|
||||
personId String?
|
||||
responseNotes ResponseNote[]
|
||||
notes ResponseNote[]
|
||||
/// @zod.custom(imports.ZResponseData)
|
||||
/// [ResponseData]
|
||||
data Json @default("{}")
|
||||
@@ -444,7 +444,7 @@ model User {
|
||||
identityProviderAccountId String?
|
||||
memberships Membership[]
|
||||
accounts Account[]
|
||||
responseNote ResponseNote[]
|
||||
responseNotes ResponseNote[]
|
||||
groupId String?
|
||||
invitesCreated Invite[] @relation("inviteCreatedBy")
|
||||
invitesAccepted Invite[] @relation("inviteAcceptedBy")
|
||||
|
||||
@@ -3,6 +3,59 @@ import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/typ
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
|
||||
import { getPerson, TransformPersonOutput, transformPrismaPerson } from "./person";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
personAttributes: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
|
||||
try {
|
||||
@@ -30,19 +83,13 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
||||
personAttributes: person?.attributes,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
},
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
const response: TResponse = {
|
||||
...responsePrisma,
|
||||
person,
|
||||
person: transformPrismaPerson(responsePrisma.person),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
return response;
|
||||
@@ -61,30 +108,7 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
|
||||
where: {
|
||||
id: responseId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
personAttributes: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
if (!responsePrisma) {
|
||||
@@ -93,8 +117,8 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
|
||||
|
||||
const response: TResponse = {
|
||||
...responsePrisma,
|
||||
personAttributes: responsePrisma.personAttributes as Record<string, string | number>,
|
||||
person: transformPrismaPerson(responsePrisma.person),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
return response;
|
||||
@@ -107,6 +131,36 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
|
||||
}
|
||||
};
|
||||
|
||||
export const getSurveyResponses = async (surveyId: string): Promise<TResponse[]> => {
|
||||
try {
|
||||
const responsesPrisma = await prisma.response.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
},
|
||||
select: responseSelection,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const responses: TResponse[] = responsesPrisma.map((responsePrisma) => ({
|
||||
...responsePrisma,
|
||||
person: transformPrismaPerson(responsePrisma.person),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
}));
|
||||
|
||||
return responses;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateResponse = async (
|
||||
responseId: string,
|
||||
responseInput: TResponseUpdateInput
|
||||
@@ -132,34 +186,13 @@ export const updateResponse = async (
|
||||
finished: responseInput.finished,
|
||||
data,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
const response: TResponse = {
|
||||
...responsePrisma,
|
||||
person: transformPrismaPerson(responsePrisma.person),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
return response;
|
||||
|
||||
@@ -76,7 +76,7 @@ export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
|
||||
});
|
||||
|
||||
// responseRate, rounded to 2 decimal places
|
||||
const responseRate = Math.round((numDisplaysResponded / numDisplays) * 100) / 100;
|
||||
const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0;
|
||||
|
||||
const transformedSurvey = {
|
||||
...surveyPrisma,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Question } from "./questions";
|
||||
import { TPerson } from "./v1/people";
|
||||
|
||||
export interface Response {
|
||||
id: string;
|
||||
@@ -15,20 +15,12 @@ export interface Response {
|
||||
};
|
||||
}
|
||||
|
||||
export interface QuestionSummary<T extends Question> {
|
||||
export interface QuestionSummary<T> {
|
||||
question: T;
|
||||
responses: {
|
||||
id: string;
|
||||
personId: string;
|
||||
value: string;
|
||||
updatedAt: string;
|
||||
person?: {
|
||||
attributes: {
|
||||
attributeClass: {
|
||||
name: string;
|
||||
};
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
value: string | number | string[];
|
||||
updatedAt: Date;
|
||||
person: TPerson | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { z } from "zod";
|
||||
import { ZPersonAttributes } from "./people";
|
||||
import { ZTag } from "./tags";
|
||||
|
||||
export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
|
||||
|
||||
export type TResponseData = z.infer<typeof ZResponseData>;
|
||||
|
||||
export const ZResponsePersonAttributes = ZPersonAttributes.optional();
|
||||
export const ZResponsePersonAttributes = ZPersonAttributes.nullable();
|
||||
|
||||
export type TResponsePersonAttributes = z.infer<typeof ZResponsePersonAttributes>;
|
||||
|
||||
export const ZResponseNoteUser = z.object({
|
||||
id: z.string().cuid2(),
|
||||
name: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type TResponseNoteUser = z.infer<typeof ZResponseNoteUser>;
|
||||
|
||||
const ZResponseNote = z.object({
|
||||
updatedAt: z.date(),
|
||||
createdAt: z.date(),
|
||||
id: z.string(),
|
||||
text: z.string(),
|
||||
user: ZResponseNoteUser,
|
||||
});
|
||||
|
||||
export type TResponseNote = z.infer<typeof ZResponseNote>;
|
||||
|
||||
const ZResponse = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
@@ -23,6 +41,8 @@ const ZResponse = z.object({
|
||||
personAttributes: ZResponsePersonAttributes,
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
notes: z.array(ZResponseNote),
|
||||
tags: z.array(ZTag),
|
||||
});
|
||||
|
||||
export type TResponse = z.infer<typeof ZResponse>;
|
||||
|
||||
@@ -108,6 +108,8 @@ const ZSurveyQuestionBase = z.object({
|
||||
subheader: z.string().optional(),
|
||||
required: z.boolean(),
|
||||
buttonLabel: z.string().optional(),
|
||||
scale: z.enum(["number", "smiley", "star"]).optional(),
|
||||
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
|
||||
logic: z.array(ZSurveyLogic).optional(),
|
||||
});
|
||||
|
||||
@@ -153,7 +155,7 @@ export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({
|
||||
|
||||
export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
|
||||
type: z.literal(QuestionType.Rating),
|
||||
scale: z.union([z.literal("number"), z.literal("smiley"), z.literal("star")]),
|
||||
scale: z.enum(["number", "smiley", "star"]),
|
||||
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]),
|
||||
lowerLabel: z.string(),
|
||||
upperLabel: z.string(),
|
||||
@@ -170,6 +172,8 @@ export const ZSurveyQuestion = z.union([
|
||||
ZSurveyRatingQuestion,
|
||||
]);
|
||||
|
||||
export type TSurveyQuestion = z.infer<typeof ZSurveyQuestion>;
|
||||
|
||||
export const ZSurveyQuestions = z.array(ZSurveyQuestion);
|
||||
|
||||
export type TSurveyQuestions = z.infer<typeof ZSurveyQuestions>;
|
||||
|
||||
Reference in New Issue
Block a user