feat: Move Response Summary Server-side (#2160)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Piyush Gupta
2024-03-06 17:23:21 +05:30
committed by GitHub
parent a9f5289672
commit d01b293a27
38 changed files with 1691 additions and 1749 deletions

View File

@@ -2,6 +2,8 @@
import { useEffect, useState } from "react";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
@@ -44,28 +46,62 @@ export default function ResponseFeed({
{fetchedResponses.length === 0 ? (
<EmptySpaceFiller type="response" environment={environment} />
) : (
fetchedResponses.map((response) => {
const survey = surveys.find((survey) => {
return survey.id === response.surveyId;
});
return (
<div key={response.id}>
{survey && (
<SingleResponseCard
response={response}
survey={survey}
user={user}
pageType="people"
environmentTags={environmentTags}
environment={environment}
deleteResponse={deleteResponse}
updateResponse={updateResponse}
/>
)}
</div>
);
})
fetchedResponses.map((response) => (
<ResponseSurveyCard
key={response.id}
response={response}
surveys={surveys}
user={user}
environmentTags={environmentTags}
environment={environment}
deleteResponse={deleteResponse}
updateResponse={updateResponse}
/>
))
)}
</>
);
}
const ResponseSurveyCard = ({
response,
surveys,
user,
environmentTags,
environment,
deleteResponse,
updateResponse,
}: {
response: TResponse;
surveys: TSurvey[];
user: TUser;
environmentTags: TTag[];
environment: TEnvironment;
deleteResponse: (responseId: string) => void;
updateResponse: (responseId: string, response: TResponse) => void;
}) => {
const survey = surveys.find((survey) => {
return survey.id === response.surveyId;
});
const { membershipRole } = useMembershipRole(survey?.environmentId || "");
const { isViewer } = getAccessFlags(membershipRole);
return (
<div key={response.id}>
{survey && (
<SingleResponseCard
response={response}
survey={survey}
user={user}
pageType="people"
environmentTags={environmentTags}
environment={environment}
deleteResponse={deleteResponse}
updateResponse={updateResponse}
isViewer={isViewer}
/>
)}
</div>
);
};

View File

@@ -4,10 +4,10 @@ import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@formbricks/lib/authOptions";
import { getResponses } from "@formbricks/lib/response/service";
import { getResponses, getSurveySummary } from "@formbricks/lib/response/service";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { AuthorizationError } from "@formbricks/types/errors";
import { TResponse, TResponseFilterCriteria } from "@formbricks/types/responses";
import { TResponse, TResponseFilterCriteria, TSurveySummary } from "@formbricks/types/responses";
export default async function revalidateSurveyIdPath(environmentId: string, surveyId: string) {
revalidatePath(`/environments/${environmentId}/surveys/${surveyId}`);
@@ -45,3 +45,16 @@ export async function getResponsesAction(
const responses = await getResponses(surveyId, page, batchSize, filterCriteria);
return responses;
}
export const getSurveySummaryAction = async (
surveyId: string,
filterCriteria?: TResponseFilterCriteria
): Promise<TSurveySummary> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await getSurveySummary(surveyId, filterCriteria);
};

View File

@@ -1,19 +1,13 @@
import { getDisplayCountBySurveyId } from "@formbricks/lib/display/service";
import { getResponses } from "@formbricks/lib/response/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
export const getAnalysisData = async (surveyId: string, environmentId: string) => {
const [survey, team, responses, displayCount] = await Promise.all([
const [survey, responseCount] = await Promise.all([
getSurvey(surveyId),
getTeamByEnvironmentId(environmentId),
getResponses(surveyId),
getDisplayCountBySurveyId(surveyId),
getResponseCountBySurveyId(surveyId),
]);
if (!survey) throw new Error(`Survey not found: ${surveyId}`);
if (!team) throw new Error(`Team not found for environment: ${environmentId}`);
if (survey.environmentId !== environmentId) throw new Error(`Survey not found: ${surveyId}`);
const responseCount = responses.length;
return { responses, responseCount, survey, displayCount };
return { responseCount, survey };
};

View File

@@ -3,6 +3,8 @@
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import React, { useEffect, useRef } from "react";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
@@ -60,6 +62,9 @@ export default function ResponseTimeline({
};
}, [fetchNextPage, hasMore]);
const { membershipRole } = useMembershipRole(survey.environmentId);
const { isViewer } = getAccessFlags(membershipRole);
return (
<div className="space-y-4">
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
@@ -84,6 +89,7 @@ export default function ResponseTimeline({
environment={environment}
updateResponse={updateResponse}
deleteResponse={deleteResponse}
isViewer={isViewer}
/>
</div>
);

View File

@@ -9,6 +9,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { formatSurveyDateFields } from "@formbricks/lib/survey/util";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
type TSendEmailActionArgs = {
@@ -54,7 +55,7 @@ export async function generateResultShareUrlAction(surveyId: string): Promise<st
20
)();
await updateSurvey({ ...survey, resultShareKey });
await updateSurvey({ ...formatSurveyDateFields(survey), resultShareKey });
return resultShareKey;
}
@@ -86,7 +87,7 @@ export async function deleteResultShareUrlAction(surveyId: string): Promise<void
throw new ResourceNotFoundError("Survey", surveyId);
}
await updateSurvey({ ...survey, resultShareKey: null });
await updateSurvey({ ...formatSurveyDateFields(survey), resultShareKey: null });
}
export const getEmailHtmlAction = async (surveyId: string) => {

View File

@@ -1,34 +1,17 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyCTAQuestion } from "@formbricks/types/surveys";
import { TSurveySummaryCta } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface CTASummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyCTAQuestion>;
}
interface ChoiceResult {
count: number;
percentage: number;
questionSummary: TSurveySummaryCta;
}
export default function CTASummary({ questionSummary }: CTASummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const ctr: ChoiceResult = useMemo(() => {
const clickedAbs = questionSummary.responses.filter((response) => response.value === "clicked").length;
const count = questionSummary.responses.length;
if (count === 0) return { count: 0, percentage: 0 };
return {
count: count,
percentage: clickedAbs / count,
};
}, [questionSummary]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
@@ -41,7 +24,7 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{ctr.count} responses
{questionSummary.responseCount} responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -54,15 +37,15 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {
<p className="font-semibold text-slate-700">Clickthrough Rate (CTR)</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(ctr.percentage * 100)}%
{Math.round(questionSummary.ctr.percentage)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{ctr.count} {ctr.count === 1 ? "response" : "responses"}
{questionSummary.ctr.count} {questionSummary.ctr.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={ctr.percentage} />
<ProgressBar barColor="bg-brand" progress={questionSummary.ctr.percentage / 100} />
</div>
</div>
);

View File

@@ -1,19 +1,16 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import { TSurveyCalQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import { TSurveySummaryCal } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface CalSummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyCalQuestion>;
questionSummary: TSurveySummaryCal;
environmentId: string;
}
export default function CalSummary({ questionSummary, environmentId }: CalSummaryProps) {
export default function CalSummary({ questionSummary }: CalSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
return (
@@ -28,50 +25,46 @@ export default function CalSummary({ questionSummary, environmentId }: CalSummar
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
{questionSummary.responseCount} Responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
)}
</div>
</div>
<div className="rounded-b-lg bg-white ">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.responses.map((response) => {
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
<div className="space-y-5 rounded-b-lg bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">Booked</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary.booked.percentage)}%
</p>
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold capitalize">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
</div>
);
})}
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary.booked.count} {questionSummary.booked.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={questionSummary.booked.percentage / 100} />
</div>
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">Dismissed</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary.skipped.percentage)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary.skipped.count} {questionSummary.skipped.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={questionSummary.skipped.percentage / 100} />
</div>
</div>
</div>
);

View File

@@ -1,46 +1,22 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyConsentQuestion } from "@formbricks/types/surveys";
import { TSurveySummaryConsent } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface ConsentSummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyConsentQuestion>;
}
interface ChoiceResult {
count: number;
acceptedCount: number;
acceptedPercentage: number;
dismissedCount: number;
dismissedPercentage: number;
questionSummary: TSurveySummaryConsent;
}
export default function ConsentSummary({ questionSummary }: ConsentSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const ctr: ChoiceResult = useMemo(() => {
const total = questionSummary.responses.length;
const clickedAbs = questionSummary.responses.filter((response) => response.value !== "dismissed").length;
if (total === 0) {
return { count: 0, acceptedCount: 0, acceptedPercentage: 0, dismissedCount: 0, dismissedPercentage: 0 };
}
return {
count: total,
acceptedCount: clickedAbs,
acceptedPercentage: clickedAbs / total,
dismissedCount: total - clickedAbs,
dismissedPercentage: 1 - clickedAbs / total,
};
}, [questionSummary]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}
@@ -48,7 +24,7 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{ctr.count} responses
{questionSummary.responseCount} responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -62,15 +38,16 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
<p className="font-semibold text-slate-700">Accepted</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(ctr.acceptedPercentage * 100)}%
{Math.round(questionSummary.accepted.percentage)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{ctr.acceptedCount} {ctr.acceptedCount === 1 ? "response" : "responses"}
{questionSummary.accepted.count}{" "}
{questionSummary.accepted.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={ctr.acceptedPercentage} />
<ProgressBar barColor="bg-brand" progress={questionSummary.accepted.percentage / 100} />
</div>
<div>
<div className="text flex justify-between px-2 pb-2">
@@ -78,15 +55,16 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
<p className="font-semibold text-slate-700">Dismissed</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(ctr.dismissedPercentage * 100)}%
{Math.round(questionSummary.dismissed.percentage)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{ctr.dismissedCount} {ctr.dismissedCount === 1 ? "response" : "responses"}
{questionSummary.dismissed.count}{" "}
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={ctr.dismissedPercentage} />
<ProgressBar barColor="bg-brand" progress={questionSummary.dismissed.percentage / 100} />
</div>
</div>
</div>

View File

@@ -2,27 +2,20 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId
import { questionTypes } from "@/app/lib/questions";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
import type { TSurveyDateQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveySummaryDate } from "@formbricks/types/responses";
import { PersonAvatar } from "@formbricks/ui/Avatars";
interface DateQuestionSummary {
questionSummary: TSurveyQuestionSummary<TSurveyDateQuestion>;
questionSummary: TSurveySummaryDate;
environmentId: string;
responsesPerPage: number;
}
export default function DateQuestionSummary({
questionSummary,
environmentId,
responsesPerPage,
}: DateQuestionSummary) {
export default function DateQuestionSummary({ questionSummary, environmentId }: DateQuestionSummary) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const [displayCount, setDisplayCount] = useState(responsesPerPage);
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
@@ -36,7 +29,7 @@ export default function DateQuestionSummary({
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
{questionSummary.responseCount} Responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -49,51 +42,39 @@ export default function DateQuestionSummary({
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.responses.slice(0, displayCount).map((response) => {
const displayIdentifier = getPersonIdentifier(response.person!);
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
{questionSummary.samples.map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{formatDateWithOrdinal(new Date(response.value as string))}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{formatDateWithOrdinal(new Date(response.value as string))}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
</div>
);
})}
{displayCount < questionSummary.responses.length && (
<div className="my-1 flex justify-center">
<button
type="button"
onClick={() => setDisplayCount((prevCount) => prevCount + responsesPerPage)}
className="my-2 flex h-8 items-center justify-center rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-700">
Show more
</button>
</div>
)}
))}
</div>
</div>
);

View File

@@ -7,12 +7,11 @@ import Link from "next/link";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
import { timeSince } from "@formbricks/lib/time";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
import { TSurveySummaryFileUpload } from "@formbricks/types/responses";
import { PersonAvatar } from "@formbricks/ui/Avatars";
interface FileUploadSummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyFileUploadQuestion>;
questionSummary: TSurveySummaryFileUpload;
environmentId: string;
}
@@ -31,7 +30,7 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
{questionSummary.responseCount} Responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -44,80 +43,72 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.responses.map((response) => {
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
{questionSummary.files.map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
)}
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="col-span-2 grid">
{response.value === "skipped" && (
<div className="col-span-2 grid">
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl, index) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a
href={fileUrl as string}
key={index}
download={fileName}
target="_blank"
rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
);
})
) : (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">skipped</p>
</div>
)}
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl, index) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a
href={fileUrl as string}
key={index}
download={fileName}
target="_blank"
rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
);
})
) : (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">skipped</p>
</div>
))}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
))}
</div>
);
})}
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
</div>
</div>
))}
</div>
</div>
);

View File

@@ -1,46 +1,24 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { ChatBubbleBottomCenterTextIcon, InboxStackIcon } from "@heroicons/react/24/solid";
import { Link } from "lucide-react";
import { FC, useMemo } from "react";
import { FC } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TSurveySummaryHiddenField } from "@formbricks/types/responses";
import { PersonAvatar } from "@formbricks/ui/Avatars";
interface HiddenFieldsSummaryProps {
question: string;
survey: TSurvey;
responses: TResponse[];
environment: TEnvironment;
questionSummary: TSurveySummaryHiddenField;
}
const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, responses, survey, question }) => {
const hiddenFieldResponses = useMemo(
() =>
survey.hiddenFields?.fieldIds?.map((question) => {
const questionResponses = responses
.filter((response) => question in response.data)
.map((r) => ({
id: r.id,
value: r.data[question],
updatedAt: r.updatedAt,
person: r.person,
}));
return {
question,
responses: questionResponses,
};
}),
[responses, survey.hiddenFields?.fieldIds]
);
const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, questionSummary }) => {
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={question} />
<Headline headline={questionSummary.question} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
@@ -49,7 +27,7 @@ const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, respon
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
<InboxStackIcon className="mr-2 h-4 w-4" />
{hiddenFieldResponses?.find((q) => q.question === question)?.responses?.length} Responses
{questionSummary.responseCount} {questionSummary.responseCount === 1 ? "Response" : "Responses"}
</div>
</div>
</div>
@@ -59,44 +37,39 @@ const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, respon
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{hiddenFieldResponses
?.find((q) => q.question === question)
?.responses.map((response) => {
const displayIdentifier = getPersonIdentifier(response.person!);
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environment.id}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
{questionSummary.samples.map((response) => (
<div
key={response.value}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environment.id}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(response.updatedAt.toISOString())}
</div>
</div>
);
})}
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
</div>
</div>
))}
</div>
</div>
);

View File

@@ -2,130 +2,33 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId
import { questionTypes } from "@/app/lib/questions";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { useMemo, useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import {
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyQuestionType,
} from "@formbricks/types/surveys";
import { TSurveySummaryMultipleChoice } from "@formbricks/types/responses";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface MultipleChoiceSummaryProps {
questionSummary: TSurveyQuestionSummary<
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
>;
questionSummary: TSurveySummaryMultipleChoice;
environmentId: string;
surveyType: string;
responsesPerPage: number;
}
interface ChoiceResult {
id: string;
label: string;
count: number;
percentage?: number;
otherValues?: {
value: string;
person: {
id: string;
name?: string;
email?: string;
};
}[];
}
export default function MultipleChoiceSummary({
questionSummary,
environmentId,
surveyType,
responsesPerPage,
}: MultipleChoiceSummaryProps) {
const isSingleChoice = questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle;
const [otherDisplayCount, setOtherDisplayCount] = useState(responsesPerPage);
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
// sort by count and transform to array
const results = Object.values(questionSummary.choices).sort((a, b) => {
if (a.others) return 1; // Always put a after b if a has 'others'
if (b.others) return -1; // Always put b after a if b has 'others'
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (const choice of questionSummary.question.choices) {
resultsDict[choice.label] = {
id: choice.id,
label: choice.label,
count: 0,
percentage: 0,
otherValues: [],
};
}
const addOtherChoice = (response, value) => {
for (const key in resultsDict) {
if (resultsDict[key].id === "other" && value !== "") {
const displayIdentifier = getPersonIdentifier(response.person);
resultsDict[key].otherValues?.push({
value,
person: {
id: response.personId,
email: typeof displayIdentifier === "string" ? displayIdentifier : undefined,
},
});
resultsDict[key].count += 1;
break;
}
}
};
// count the responses
for (const response of questionSummary.responses) {
// if single choice, only add responses that are in the choices
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 if (Array.isArray(response.value)) {
// if multi choice add all responses
for (const choice of response.value) {
if (choice in resultsDict) {
resultsDict[choice].count += 1;
} else {
// if multi choice and not in choices, add to other
addOtherChoice(response, choice);
}
}
}
}
// add the percentage
const total = questionSummary.responses.length;
for (const key of Object.keys(resultsDict)) {
if (resultsDict[key].count) {
resultsDict[key].percentage = resultsDict[key].count / total;
}
}
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a: any, b: any) => {
if (a.id === "other") return 1; // Always put a after b if a's id is 'other'
if (b.id === "other") return -1; // Always put b after a if b's id is 'other'
// If neither id is 'other', compare counts
return b.count - a.count;
});
return results;
}, [questionSummary, isSingleChoice]);
const totalResponses = useMemo(() => {
let total = 0;
for (const result of results) {
total += result.count;
}
return total;
}, [results]);
// Sort by count
return b.count - a.count;
});
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
@@ -139,7 +42,7 @@ export default function MultipleChoiceSummary({
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
{questionSummary.responseCount} responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -151,16 +54,16 @@ export default function MultipleChoiceSummary({
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result: any, resultsIdx) => (
<div key={result.label}>
{results.map((result, resultsIdx) => (
<div key={result.value}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700">
{results.length - resultsIdx} - {result.label}
{results.length - resultsIdx} - {result.value}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(result.percentage * 100)}%
{Math.round(result.percentage)}%
</p>
</div>
</div>
@@ -168,16 +71,15 @@ export default function MultipleChoiceSummary({
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage} />
{result.otherValues.length > 0 && (
<ProgressBar barColor="bg-brand" progress={result.percentage / 100} />
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6 ">Specified &quot;Other&quot; answers</div>
<div className="col-span-1 pl-6 ">{surveyType === "web" && "User"}</div>
</div>
{result.otherValues
.filter((otherValue) => otherValue !== "")
.slice(0, otherDisplayCount)
{result.others
.filter((otherValue) => otherValue.value !== "")
.map((otherValue, idx) => (
<div key={idx}>
{surveyType === "link" && (
@@ -187,7 +89,7 @@ export default function MultipleChoiceSummary({
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "web" && (
{surveyType === "web" && otherValue.person && (
<Link
href={
otherValue.person.id
@@ -207,16 +109,6 @@ export default function MultipleChoiceSummary({
)}
</div>
))}
{otherDisplayCount < result.otherValues.length && (
<div className="flex w-full items-center justify-center">
<button
type="button"
onClick={() => setOtherDisplayCount(otherDisplayCount + responsesPerPage)}
className="my-2 flex h-8 items-center justify-center rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-700">
Show more
</button>
</div>
)}
</div>
)}
</div>

View File

@@ -1,82 +1,17 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyNPSQuestion } from "@formbricks/types/surveys";
import { TSurveySummaryNps } from "@formbricks/types/responses";
import { HalfCircle, ProgressBar } from "@formbricks/ui/ProgressBar";
interface NPSSummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyNPSQuestion>;
}
interface Result {
promoters: number;
passives: number;
detractors: number;
total: number;
score: number;
}
interface ChoiceResult {
label: string;
count: number;
percentage: number;
questionSummary: TSurveySummaryNps;
}
export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
const percentage = (count, total) => {
const result = count / total;
return result || 0;
};
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const result: Result = useMemo(() => {
let data = {
promoters: 0,
passives: 0,
detractors: 0,
total: 0,
score: 0,
};
for (let response of questionSummary.responses) {
const value = response.value;
if (typeof value !== "number") continue;
data.total++;
if (value >= 9) {
data.promoters++;
} else if (value >= 7) {
data.passives++;
} else {
data.detractors++;
}
}
data.score = (percentage(data.promoters, data.total) - percentage(data.detractors, data.total)) * 100;
return data;
}, [questionSummary]);
const dismissed: ChoiceResult = useMemo(() => {
if (questionSummary.question.required) return { count: 0, label: "Dismissed", percentage: 0 };
const total = questionSummary.responses.length;
let count = 0;
for (const response of questionSummary.responses) {
if (!response.value) {
count += 1;
}
}
return {
count,
label: "Dismissed",
percentage: count / total,
};
}, [questionSummary]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
@@ -89,7 +24,7 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{result.total} responses
{questionSummary.responseCount} responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -104,40 +39,41 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
<p className="font-semibold capitalize text-slate-700">{group}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(percentage(result[group], result.total) * 100)}%
{Math.round(questionSummary[group].percentage)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result[group]} {result[group] === 1 ? "response" : "responses"}
{questionSummary[group].count} {questionSummary[group].count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={percentage(result[group], result.total)} />
<ProgressBar barColor="bg-brand" progress={questionSummary[group].percentage / 100} />
</div>
))}
</div>
{dismissed.count > 0 && (
{questionSummary.dismissed?.count > 0 && (
<div className="border-t bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div key={dismissed.label}>
<div key={"dismissed"}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">{dismissed.label}</p>
<p className="font-semibold text-slate-700">dismissed</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(dismissed.percentage * 100)}%
{Math.round(questionSummary.dismissed.percentage)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{dismissed.count} {dismissed.count === 1 ? "response" : "responses"}
{questionSummary.dismissed.count}{" "}
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-slate-600" progress={dismissed.percentage} />
<ProgressBar barColor="bg-slate-600" progress={questionSummary.dismissed.percentage / 100} />
</div>
</div>
)}
<div className="flex justify-center rounded-b-lg bg-white pb-4 pt-4">
<HalfCircle value={result.score} />
<HalfCircle value={questionSummary.score} />
</div>
</div>
);

View File

@@ -2,27 +2,19 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId
import { questionTypes } from "@/app/lib/questions";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyOpenTextQuestion } from "@formbricks/types/surveys";
import { TSurveySummaryOpenText } from "@formbricks/types/responses";
import { PersonAvatar } from "@formbricks/ui/Avatars";
interface OpenTextSummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyOpenTextQuestion>;
questionSummary: TSurveySummaryOpenText;
environmentId: string;
responsesPerPage: number;
}
export default function OpenTextSummary({
questionSummary,
environmentId,
responsesPerPage,
}: OpenTextSummaryProps) {
export default function OpenTextSummary({ questionSummary, environmentId }: OpenTextSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const [displayCount, setDisplayCount] = useState(responsesPerPage);
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
@@ -35,7 +27,7 @@ export default function OpenTextSummary({
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
{questionSummary.responseCount} Responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -48,51 +40,39 @@ export default function OpenTextSummary({
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.responses.slice(0, displayCount).map((response) => {
const displayIdentifier = getPersonIdentifier(response.person!);
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
{questionSummary.samples.map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
</div>
);
})}
{displayCount < questionSummary.responses.length && (
<div className="flex justify-center py-1">
<button
type="button"
onClick={() => setDisplayCount((prevCount) => prevCount + responsesPerPage)}
className="my-2 flex h-8 items-center justify-center rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-700">
Show more
</button>
</div>
)}
))}
</div>
</div>
);

View File

@@ -2,74 +2,19 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId
import { questionTypes } from "@/app/lib/questions";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import { useMemo } from "react";
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveySummaryPictureSelection } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface PictureChoiceSummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>;
}
interface ChoiceResult {
id: string;
imageUrl: string;
count: number;
percentage?: number;
questionSummary: TSurveySummaryPictureSelection;
}
export default function PictureChoiceSummary({ questionSummary }: PictureChoiceSummaryProps) {
const isMulti = questionSummary.question.allowMulti;
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (const choice of questionSummary.question.choices) {
resultsDict[choice.id] = {
id: choice.id,
imageUrl: choice.imageUrl,
count: 0,
percentage: 0,
};
}
// count the responses
for (const response of questionSummary.responses) {
if (Array.isArray(response.value)) {
for (const choice of response.value) {
if (choice in resultsDict) {
resultsDict[choice].count += 1;
}
}
}
}
// add the percentage
const total = questionSummary.responses.length;
for (const key of Object.keys(resultsDict)) {
if (resultsDict[key].count) {
resultsDict[key].percentage = resultsDict[key].count / total;
}
}
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a, b) => {
return b.count - a.count;
});
return results;
}, [questionSummary]);
const totalResponses = useMemo(() => {
let total = 0;
for (const result of results) {
total += result.count;
}
return total;
}, [results]);
const results = questionSummary.choices.sort((a, b) => b.count - a.count);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
@@ -83,7 +28,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
{questionSummary.responseCount} responses
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
{isMulti ? "Multi" : "Single"} Select
@@ -109,7 +54,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
</div>
<div className="self-end">
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round((result.percentage || 0) * 100)}%
{Math.round(result.percentage)}%
</p>
</div>
</div>
@@ -117,7 +62,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage || 0} />
<ProgressBar barColor="bg-brand" progress={result.percentage / 100 || 0} />
</div>
))}
</div>

View File

@@ -4,95 +4,17 @@ import { InboxStackIcon } from "@heroicons/react/24/solid";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurveyRatingQuestion } from "@formbricks/types/surveys";
import { TSurveySummaryRating } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { RatingResponse } from "@formbricks/ui/RatingResponse";
interface RatingSummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyRatingQuestion>;
}
interface ChoiceResult {
label: number | string;
count: number;
percentage: number;
questionSummary: TSurveySummaryRating;
}
export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const results: ChoiceResult[] = useMemo(() => {
if (questionSummary.question.type !== TSurveyQuestionType.Rating) return [];
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (let i = 1; i <= questionSummary.question.range; i++) {
resultsDict[i.toString()] = {
count: 0,
label: i,
percentage: 0,
};
}
// count the responses
for (const response of questionSummary.responses) {
// if single choice, only add responses that are in the choices
if (!Array.isArray(response.value) && response.value in resultsDict) {
resultsDict[response.value].count += 1;
}
}
// add the percentage
const total = questionSummary.responses.length;
for (const key of Object.keys(resultsDict)) {
if (resultsDict[key].count) {
resultsDict[key].percentage = resultsDict[key].count / total;
}
}
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a: any, b: any) => a.label - b.label);
return results;
}, [questionSummary]);
const dismissed: ChoiceResult = useMemo(() => {
if (questionSummary.question.required) return { count: 0, label: "Dismissed", percentage: 0 };
const total = questionSummary.responses.length;
let count = 0;
for (const response of questionSummary.responses) {
if (!response.value) {
count += 1;
}
}
return {
count,
label: "Dismissed",
percentage: count / total,
};
}, [questionSummary]);
const totalResponses = useMemo(() => {
let total = 0;
for (const result of results) {
total += result.count;
}
return total;
}, [results]);
const averageRating = useMemo(() => {
let total = 0;
let count = 0;
questionSummary.responses.forEach((response) => {
if (response.value && typeof response.value === "number") {
total += response.value;
count += 1;
}
});
const average = count > 0 ? total / count : 0;
return parseFloat(average.toFixed(2));
}, [questionSummary]);
const getIconBasedOnScale = useMemo(() => {
const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
@@ -112,11 +34,11 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
{questionSummary.responseCount} responses
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>Overall: {averageRating}</div>
<div>Overall: {questionSummary.average.toFixed(2)}</div>
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -124,20 +46,20 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result: any) => (
<div key={result.label}>
{questionSummary.choices.map((result) => (
<div key={result.rating}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.label}
answer={result.rating}
range={questionSummary.question.range}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(result.percentage * 100)}%
{Math.round(result.percentage)}%
</p>
</div>
</div>
@@ -145,27 +67,28 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage} />
<ProgressBar barColor="bg-brand" progress={result.percentage / 100} />
</div>
))}
</div>
{dismissed.count > 0 && (
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 pb-6 pt-4">
<div key={dismissed.label}>
<div key="dismissed">
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">{dismissed.label}</p>
<p className="font-semibold text-slate-700">dismissed</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(dismissed.percentage * 100)}%
{Math.round(questionSummary.dismissed.percentage)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{dismissed.count} {dismissed.count === 1 ? "response" : "responses"}
{questionSummary.dismissed.count}{" "}
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-slate-600" progress={dismissed.percentage} />
<ProgressBar barColor="bg-slate-600" progress={questionSummary.dismissed.percentage / 100} />
</div>
</div>
)}

View File

@@ -1,164 +1,13 @@
import { evaluateCondition } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic";
import { TimerIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TSurveySummary } from "@formbricks/types/responses";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
interface SummaryDropOffsProps {
survey: TSurvey;
responses: TResponse[];
displayCount: number;
dropOff: TSurveySummary["dropOff"];
}
export default function SummaryDropOffs({ responses, survey, displayCount }: SummaryDropOffsProps) {
const initialAvgTtc = useMemo(
() =>
survey.questions.reduce((acc, question) => {
acc[question.id] = 0;
return acc;
}, {}),
[survey.questions]
);
const [avgTtc, setAvgTtc] = useState(initialAvgTtc);
interface DropoffMetricsType {
dropoffCount: number[];
viewsCount: number[];
dropoffPercentage: number[];
}
const [dropoffMetrics, setDropoffMetrics] = useState<DropoffMetricsType>({
dropoffCount: [],
viewsCount: [],
dropoffPercentage: [],
});
const calculateMetrics = useCallback(() => {
let totalTtc = { ...initialAvgTtc };
let responseCounts = { ...initialAvgTtc };
let dropoffArr = new Array(survey.questions.length).fill(0);
let viewsArr = new Array(survey.questions.length).fill(0);
let dropoffPercentageArr = new Array(survey.questions.length).fill(0);
responses.forEach((response) => {
// Calculate total time-to-completion
Object.keys(avgTtc).forEach((questionId) => {
if (response.ttc && response.ttc[questionId]) {
totalTtc[questionId] += response.ttc[questionId];
responseCounts[questionId]++;
}
});
let currQuesIdx = 0;
while (currQuesIdx < survey.questions.length) {
const currQues = survey.questions[currQuesIdx];
if (!currQues) break;
if (!currQues.required) {
if (!response.data[currQues.id]) {
viewsArr[currQuesIdx]++;
if (currQuesIdx === survey.questions.length - 1 && !response.finished) {
dropoffArr[currQuesIdx]++;
break;
}
const questionHasCustomLogic = currQues.logic;
if (questionHasCustomLogic) {
let didLogicPass = false;
for (let logic of questionHasCustomLogic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, response.data[currQues.id] ?? null)) {
didLogicPass = true;
currQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
break;
}
}
if (!didLogicPass) currQuesIdx++;
} else {
currQuesIdx++;
}
continue;
}
}
if (
(response.data[currQues.id] === undefined && !response.finished) ||
(currQues.required && !response.data[currQues.id])
) {
dropoffArr[currQuesIdx]++;
viewsArr[currQuesIdx]++;
break;
}
viewsArr[currQuesIdx]++;
let nextQuesIdx = currQuesIdx + 1;
const questionHasCustomLogic = currQues.logic;
if (questionHasCustomLogic) {
for (let logic of questionHasCustomLogic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, response.data[currQues.id])) {
nextQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
break;
}
}
}
if (!response.data[survey.questions[nextQuesIdx]?.id] && !response.finished) {
dropoffArr[nextQuesIdx]++;
viewsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
}
});
// Calculate the average time for each question
Object.keys(totalTtc).forEach((questionId) => {
totalTtc[questionId] =
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
});
if (!survey.welcomeCard.enabled) {
dropoffArr[0] = displayCount - viewsArr[0];
if (viewsArr[0] > displayCount) dropoffPercentageArr[0] = 0;
dropoffPercentageArr[0] =
viewsArr[0] - displayCount >= 0 ? 0 : ((displayCount - viewsArr[0]) / displayCount) * 100 || 0;
viewsArr[0] = displayCount;
} else {
dropoffPercentageArr[0] = (dropoffArr[0] / viewsArr[0]) * 100;
}
for (let i = 1; i < survey.questions.length; i++) {
if (viewsArr[i] !== 0) {
dropoffPercentageArr[i] = (dropoffArr[i] / viewsArr[i]) * 100;
}
}
return {
newAvgTtc: totalTtc,
dropoffCount: dropoffArr,
viewsCount: viewsArr,
dropoffPercentage: dropoffPercentageArr,
};
}, [responses, survey.questions, displayCount, initialAvgTtc, avgTtc, survey.welcomeCard.enabled]);
useEffect(() => {
const { newAvgTtc, dropoffCount, viewsCount, dropoffPercentage } = calculateMetrics();
setAvgTtc(newAvgTtc);
setDropoffMetrics({ dropoffCount, viewsCount, dropoffPercentage });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [responses]);
export default function SummaryDropOffs({ dropOff }: SummaryDropOffsProps) {
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="rounded-b-lg bg-white ">
@@ -179,20 +28,18 @@ export default function SummaryDropOffs({ responses, survey, displayCount }: Sum
<div className="px-4 text-center md:px-6">Views</div>
<div className="pr-6 text-center md:pl-6">Drop Offs</div>
</div>
{survey.questions.map((question, i) => (
{dropOff.map((quesDropOff) => (
<div
key={question.id}
key={quesDropOff.questionId}
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="col-span-3 pl-4 md:pl-6">{question.headline}</div>
<div className="col-span-3 pl-4 md:pl-6">{quesDropOff.headline}</div>
<div className="whitespace-pre-wrap text-center font-semibold">
{avgTtc[question.id] !== undefined ? (avgTtc[question.id] / 1000).toFixed(2) + "s" : "N/A"}
</div>
<div className="whitespace-pre-wrap text-center font-semibold">
{dropoffMetrics.viewsCount[i]}
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div>
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.views}</div>
<div className=" pl-6 text-center md:px-6">
<span className="font-semibold">{dropoffMetrics.dropoffCount[i]} </span>
<span>({Math.round(dropoffMetrics.dropoffPercentage[i])}%)</span>
<span className="font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
</div>
</div>
))}

View File

@@ -4,26 +4,9 @@ import ConsentSummary from "@/app/(app)/environments/[environmentId]/surveys/[su
import HiddenFieldsSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurveySummary } from "@formbricks/types/responses";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import type {
TSurveyCalQuestion,
TSurveyDateQuestion,
TSurveyFileUploadQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestionSummary,
} from "@formbricks/types/surveys";
import {
TSurvey,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyRatingQuestion,
} from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import CTASummary from "./CTASummary";
@@ -36,157 +19,103 @@ import PictureChoiceSummary from "./PictureChoiceSummary";
import RatingSummary from "./RatingSummary";
interface SummaryListProps {
summary: TSurveySummary["summary"];
responseCount: number;
environment: TEnvironment;
survey: TSurvey;
responses: TResponse[];
responsesPerPage: number;
}
export default function SummaryList({ environment, survey, responses, responsesPerPage }: SummaryListProps) {
const getSummaryData = (): TSurveyQuestionSummary<TSurveyQuestion>[] =>
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,
};
});
export default function SummaryList({ summary, environment, responseCount, survey }: SummaryListProps) {
return (
<div className="mt-10 space-y-8">
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
{survey.type === "web" && responseCount === 0 && !environment.widgetSetupCompleted ? (
<EmptyInAppSurveys environment={environment} />
) : responses.length === 0 ? (
) : responseCount === 0 ? (
<EmptySpaceFiller
type="response"
environment={environment}
noWidgetRequired={survey.type === "link"}
/>
) : (
<>
{getSummaryData().map((questionSummary) => {
if (questionSummary.question.type === TSurveyQuestionType.OpenText) {
return (
<OpenTextSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyOpenTextQuestion>}
environmentId={environment.id}
responsesPerPage={responsesPerPage}
/>
);
}
if (
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle ||
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceMulti
) {
return (
<MultipleChoiceSummary
key={questionSummary.question.id}
questionSummary={
questionSummary as TSurveyQuestionSummary<
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
>
}
environmentId={environment.id}
surveyType={survey.type}
responsesPerPage={responsesPerPage}
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.NPS) {
return (
<NPSSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyNPSQuestion>}
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.CTA) {
return (
<CTASummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyCTAQuestion>}
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.Rating) {
return (
<RatingSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyRatingQuestion>}
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.Consent) {
return (
<ConsentSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyConsentQuestion>}
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) {
return (
<PictureChoiceSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>}
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.Date) {
return (
<DateQuestionSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyDateQuestion>}
environmentId={environment.id}
responsesPerPage={responsesPerPage}
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.FileUpload) {
return (
<FileUploadSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyFileUploadQuestion>}
environmentId={environment.id}
/>
);
}
summary.map((questionSummary) => {
if (questionSummary.type === TSurveyQuestionType.OpenText) {
return (
<OpenTextSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
/>
);
}
if (
questionSummary.type === TSurveyQuestionType.MultipleChoiceSingle ||
questionSummary.type === TSurveyQuestionType.MultipleChoiceMulti
) {
return (
<MultipleChoiceSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
surveyType={survey.type}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.NPS) {
return <NPSSummary key={questionSummary.question.id} questionSummary={questionSummary} />;
}
if (questionSummary.type === TSurveyQuestionType.CTA) {
return <CTASummary key={questionSummary.question.id} questionSummary={questionSummary} />;
}
if (questionSummary.type === TSurveyQuestionType.Rating) {
return <RatingSummary key={questionSummary.question.id} questionSummary={questionSummary} />;
}
if (questionSummary.type === TSurveyQuestionType.Consent) {
return <ConsentSummary key={questionSummary.question.id} questionSummary={questionSummary} />;
}
if (questionSummary.type === TSurveyQuestionType.PictureSelection) {
return (
<PictureChoiceSummary key={questionSummary.question.id} questionSummary={questionSummary} />
);
}
if (questionSummary.type === TSurveyQuestionType.Date) {
return (
<DateQuestionSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.FileUpload) {
return (
<FileUploadSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
/>
);
}
if (questionSummary.type === TSurveyQuestionType.Cal) {
return (
<CalSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
/>
);
}
if (questionSummary.type === "hiddenField") {
return (
<HiddenFieldsSummary
key={questionSummary.question}
questionSummary={questionSummary}
environment={environment}
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.Cal) {
return (
<CalSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyCalQuestion>}
environmentId={environment.id}
/>
);
}
return null;
})}
{survey.hiddenFields?.enabled &&
survey.hiddenFields.fieldIds?.map((question) => {
return (
<HiddenFieldsSummary
environment={environment}
question={question}
responses={responses}
survey={survey}
key={question}
/>
);
})}
</>
return null;
})
)}
</div>
);

View File

@@ -1,18 +1,16 @@
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/solid";
import { useMemo, useState } from "react";
import { timeSinceConditionally } from "@formbricks/lib/time";
import { TResponse } from "@formbricks/types/responses";
import { TSurveySummary } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
interface SummaryMetadataProps {
responses: TResponse[];
showDropOffs: boolean;
setShowDropOffs: React.Dispatch<React.SetStateAction<boolean>>;
survey: TSurvey;
displayCount: number;
setShowDropOffs: React.Dispatch<React.SetStateAction<boolean>>;
showDropOffs: boolean;
surveySummary: TSurveySummary["meta"];
}
const StatCard = ({ label, percentage, value, tooltipText }) => (
@@ -36,8 +34,8 @@ const StatCard = ({ label, percentage, value, tooltipText }) => (
</TooltipProvider>
);
function formatTime(ttc, totalResponses) {
const seconds = ttc / (1000 * totalResponses);
function formatTime(ttc) {
const seconds = ttc / 1000;
let formattedValue;
if (seconds >= 60) {
@@ -52,29 +50,21 @@ function formatTime(ttc, totalResponses) {
}
export default function SummaryMetadata({
responses,
survey,
displayCount,
setShowDropOffs,
showDropOffs,
surveySummary,
}: SummaryMetadataProps) {
const completedResponsesCount = useMemo(() => responses.filter((r) => r.finished).length, [responses]);
const [validTtcResponsesCount, setValidResponsesCount] = useState(0);
const ttc = useMemo(() => {
let validTtcResponsesCountAcc = 0; //stores the count of responses that contains a _total value
const ttc = responses.reduce((acc, response) => {
if (response.ttc?._total) {
validTtcResponsesCountAcc++;
return acc + response.ttc._total;
}
return acc;
}, 0);
setValidResponsesCount(validTtcResponsesCountAcc);
return ttc;
}, [responses]);
const totalResponses = responses.length;
const {
completedPercentage,
completedResponses,
displayCount,
dropOffPercentage,
dropOffCount,
startsPercentage,
totalResponses,
ttcAverage,
} = surveySummary;
return (
<div className="mb-4">
@@ -88,28 +78,26 @@ export default function SummaryMetadata({
/>
<StatCard
label="Starts"
percentage={`${Math.round((totalResponses / displayCount) * 100)}%`}
percentage={`${Math.round(startsPercentage)}%`}
value={totalResponses === 0 ? <span>-</span> : totalResponses}
tooltipText="Number of times the survey has been started."
/>
<StatCard
label="Responses"
percentage={`${Math.round((completedResponsesCount / displayCount) * 100)}%`}
value={responses.length === 0 ? <span>-</span> : completedResponsesCount}
percentage={`${Math.round(completedPercentage)}%`}
value={completedResponses === 0 ? <span>-</span> : completedResponses}
tooltipText="Number of times the survey has been completed."
/>
<StatCard
label="Drop Offs"
percentage={`${Math.round(((totalResponses - completedResponsesCount) / totalResponses) * 100)}%`}
value={responses.length === 0 ? <span>-</span> : totalResponses - completedResponsesCount}
percentage={`${Math.round(dropOffPercentage)}%`}
value={dropOffCount === 0 ? <span>-</span> : dropOffCount}
tooltipText="Number of times the survey has been started but not completed."
/>
<StatCard
label="Time to Complete"
percentage={null}
value={
validTtcResponsesCount === 0 ? <span>-</span> : `${formatTime(ttc, validTtcResponsesCount)}`
}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText="Average time to complete the survey."
/>
</div>

View File

@@ -1,13 +1,14 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import SurveyResultsTabs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyResultsTabs";
import SummaryDropOffs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import SummaryList from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList";
import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata";
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import SummaryHeader from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader";
import { getFilterResponses } from "@/app/lib/surveys/surveys";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
@@ -15,7 +16,7 @@ import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
import { TResponse, TSurveyPersonAttributes } from "@formbricks/types/responses";
import { TSurveyPersonAttributes, TSurveySummary } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
@@ -27,47 +28,69 @@ interface SummaryPageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
responses: TResponse[];
webAppUrl: string;
product: TProduct;
user: TUser;
environmentTags: TTag[];
attributes: TSurveyPersonAttributes;
displayCount: number;
responsesPerPage: number;
membershipRole?: TMembershipRole;
responseCount: number;
}
const SummaryPage = ({
environment,
survey,
surveyId,
responses,
webAppUrl,
product,
user,
environmentTags,
attributes,
displayCount,
responsesPerPage,
membershipRole,
responseCount,
}: SummaryPageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>({
meta: {
completedPercentage: 0,
completedResponses: 0,
displayCount: 0,
dropOffPercentage: 0,
dropOffCount: 0,
startsPercentage: 0,
totalResponses: 0,
ttcAverage: 0,
},
dropOff: [],
summary: [],
});
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[survey, selectedFilter, dateRange]
);
useEffect(() => {
const fetchSurveySummary = async () => {
const response = await getSurveySummaryAction(surveyId, filters);
setSurveySummary(response);
};
fetchSurveySummary();
}, [filters, surveyId]);
const searchParams = useSearchParams();
survey = useMemo(() => {
return checkForRecallInHeadline(survey);
}, [survey]);
useEffect(() => {
if (!searchParams?.get("referer")) {
resetState();
}
}, [searchParams, resetState]);
// get the filtered array when the selected filter value changes
const filterResponses: TResponse[] = useMemo(() => {
return getFilterResponses(responses, selectedFilter, survey, dateRange);
}, [selectedFilter, responses, survey, dateRange]);
console.log({ surveySummary });
return (
<ContentWrapper>
@@ -86,18 +109,17 @@ const SummaryPage = ({
</div>
<SurveyResultsTabs activeId="summary" environmentId={environment.id} surveyId={surveyId} />
<SummaryMetadata
responses={filterResponses}
survey={survey}
displayCount={displayCount}
surveySummary={surveySummary.meta}
showDropOffs={showDropOffs}
setShowDropOffs={setShowDropOffs}
/>
{showDropOffs && <SummaryDropOffs survey={survey} responses={responses} displayCount={displayCount} />}
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} />}
<SummaryList
responses={filterResponses}
summary={surveySummary.summary}
responseCount={responseCount}
survey={survey}
environment={environment}
responsesPerPage={responsesPerPage}
/>
</ContentWrapper>
);

View File

@@ -3,7 +3,7 @@ import SummaryPage from "@/app/(app)/environments/[environmentId]/surveys/[surve
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { TEXT_RESPONSES_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
@@ -18,7 +18,7 @@ export default async function Page({ params }) {
throw new Error("Unauthorized");
}
const [{ responses, survey, displayCount }, environment] = await Promise.all([
const [{ survey, responseCount }, environment] = await Promise.all([
getAnalysisData(params.surveyId, params.environmentId),
getEnvironment(params.environmentId),
]);
@@ -51,7 +51,6 @@ export default async function Page({ params }) {
<>
<SummaryPage
environment={environment}
responses={responses}
survey={survey}
surveyId={params.surveyId}
webAppUrl={WEBAPP_URL}
@@ -59,9 +58,8 @@ export default async function Page({ params }) {
user={user}
environmentTags={tags}
attributes={attributes}
displayCount={displayCount}
responsesPerPage={TEXT_RESPONSES_PER_PAGE}
membershipRole={currentUserMembership?.role}
responseCount={responseCount}
/>
</>
);

View File

@@ -64,7 +64,7 @@ const getDifferenceOfDays = (from, to) => {
};
const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps) => {
const { selectedFilter, setSelectedOptions, dateRange, setDateRange } = useResponseFilter();
const { selectedFilter, setSelectedOptions, dateRange, setDateRange, resetState } = useResponseFilter();
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
dateRange.from && dateRange.to
? getDifferenceOfDays(dateRange.from, dateRange.to)
@@ -76,6 +76,21 @@ const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const firstMountRef = useRef(true);
useEffect(() => {
if (!firstMountRef.current) {
firstMountRef.current = false;
return;
}
}, []);
useEffect(() => {
if (!firstMountRef.current) {
resetState();
}
}, [survey?.id]);
// when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options
useEffect(() => {
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(

View File

@@ -8,9 +8,8 @@ import {
QuestionOptions,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { isWithinInterval } from "date-fns";
import { TResponse, TResponseFilterCriteria, TSurveyPersonAttributes } from "@formbricks/types/responses";
import { TResponseFilterCriteria, TSurveyPersonAttributes } from "@formbricks/types/responses";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
@@ -310,286 +309,9 @@ export const getFormattedFilters = (
return filters;
};
// get the filtered responses
export const getFilterResponses = (
responses: TResponse[],
selectedFilter: SelectedFilterValue,
survey: TSurvey,
dateRange: DateRange
) => {
// added the question on the response object to filter out the responses which has been selected
let toBeFilterResponses = responses.map((r) => {
return {
...r,
questions: survey.questions.map((q) => {
if (q.id in r.data) {
return q;
}
}),
};
});
// filtering the responses according to the value selected
selectedFilter.filter.forEach((filter) => {
if (filter.questionType?.type === "Questions") {
switch (filter.questionType?.questionType) {
case TSurveyQuestionType.Consent:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === TSurveyQuestionType.Consent && q?.id === filter?.questionType?.id
)?.id;
if (filter?.filterType?.filterComboBoxValue) {
if (questionID) {
const responseValue = response.data[questionID];
if (filter?.filterType?.filterComboBoxValue === "Accepted") {
return responseValue === "accepted";
}
if (filter?.filterType?.filterComboBoxValue === "Dismissed") {
return responseValue === "dismissed";
}
return true;
}
return false;
}
return true;
});
break;
case TSurveyQuestionType.OpenText:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === TSurveyQuestionType.OpenText && q?.id === filter?.questionType?.id
)?.id;
if (filter?.filterType?.filterComboBoxValue) {
if (questionID) {
const responseValue = response.data[questionID];
if (filter?.filterType?.filterComboBoxValue === "Filled out") {
return typeof responseValue === "string" && responseValue.trim() !== "" ? true : false;
}
if (filter?.filterType?.filterComboBoxValue === "Skipped") {
return typeof responseValue === "string" && responseValue.trim() === "" ? true : false;
}
return true;
}
return false;
}
return true;
});
break;
case TSurveyQuestionType.CTA:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === TSurveyQuestionType.CTA && q?.id === filter?.questionType?.id
)?.id;
if (filter?.filterType?.filterComboBoxValue) {
if (questionID) {
const responseValue = response.data[questionID];
if (filter?.filterType?.filterComboBoxValue === "Clicked") {
return responseValue === "clicked";
}
if (filter?.filterType?.filterComboBoxValue === "Dismissed") {
return responseValue === "dismissed";
}
return true;
}
return false;
}
return true;
});
break;
case TSurveyQuestionType.MultipleChoiceMulti:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const question = response.questions.find(
(q) => q?.type === TSurveyQuestionType.MultipleChoiceMulti && q?.id === filter?.questionType?.id
);
if (filter?.filterType?.filterComboBoxValue) {
if (question) {
const responseValue = response.data[question.id];
const filterValue = filter?.filterType?.filterComboBoxValue;
if (Array.isArray(responseValue) && Array.isArray(filterValue) && filterValue.length > 0) {
//@ts-expect-error
const updatedResponseValue = question?.choices
? //@ts-expect-error
matchAndUpdateArray([...question?.choices], [...responseValue])
: responseValue;
if (filter?.filterType?.filterValue === "Includes all") {
return filterValue.every((item) => updatedResponseValue.includes(item));
}
if (filter?.filterType?.filterValue === "Includes either") {
return filterValue.some((item) => updatedResponseValue.includes(item));
}
}
return true;
}
return false;
}
return true;
});
break;
case TSurveyQuestionType.MultipleChoiceSingle:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) =>
q?.type === TSurveyQuestionType.MultipleChoiceSingle && q?.id === filter?.questionType?.id
)?.id;
if (filter?.filterType?.filterComboBoxValue) {
if (questionID) {
const responseValue = response.data[questionID];
const filterValue = filter?.filterType?.filterComboBoxValue;
if (
filter?.filterType?.filterValue === "Includes either" &&
Array.isArray(filterValue) &&
filterValue.length > 0 &&
typeof responseValue === "string"
) {
return filterValue.includes(responseValue);
}
return true;
}
return false;
}
return true;
});
break;
case TSurveyQuestionType.NPS:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === TSurveyQuestionType.NPS && q?.id === filter?.questionType?.id
)?.id;
const responseValue = questionID ? response.data[questionID] : undefined;
const filterValue =
filter?.filterType?.filterComboBoxValue &&
typeof filter?.filterType?.filterComboBoxValue === "string" &&
parseInt(filter?.filterType?.filterComboBoxValue);
if (filter?.filterType?.filterValue === "Submitted") {
return responseValue ? true : false;
}
if (filter?.filterType?.filterValue === "Skipped") {
return responseValue === "dismissed";
}
if (!questionID && typeof filterValue === "number") {
return false;
}
if (questionID && typeof responseValue === "number" && typeof filterValue === "number") {
if (filter?.filterType?.filterValue === "Is equal to") {
return responseValue === filterValue;
}
if (filter?.filterType?.filterValue === "Is more than") {
return responseValue > filterValue;
}
if (filter?.filterType?.filterValue === "Is less than") {
return responseValue < filterValue;
}
}
return true;
});
break;
case TSurveyQuestionType.Rating:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === TSurveyQuestionType.Rating && q?.id === filter?.questionType?.id
)?.id;
const responseValue = questionID ? response.data[questionID] : undefined;
const filterValue =
filter?.filterType?.filterComboBoxValue &&
typeof filter?.filterType?.filterComboBoxValue === "string" &&
parseInt(filter?.filterType?.filterComboBoxValue);
if (filter?.filterType?.filterValue === "Submitted") {
return responseValue ? true : false;
}
if (filter?.filterType?.filterValue === "Skipped") {
return responseValue === "dismissed";
}
if (!questionID && typeof filterValue === "number") {
return false;
}
if (questionID && typeof responseValue === "number" && typeof filterValue === "number") {
if (filter?.filterType?.filterValue === "Is equal to") {
return responseValue === filterValue;
}
if (filter?.filterType?.filterValue === "Is more than") {
return responseValue > filterValue;
}
if (filter?.filterType?.filterValue === "Is less than") {
return responseValue < filterValue;
}
}
return true;
});
break;
}
}
if (filter.questionType?.type === "Tags") {
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const tagNames = response.tags.map((tag) => tag.name);
if (filter?.filterType?.filterComboBoxValue) {
if (filter?.filterType?.filterComboBoxValue === "Applied") {
if (filter?.questionType?.label) return tagNames.includes(filter.questionType.label);
}
if (filter?.filterType?.filterComboBoxValue === "Not applied") {
if (filter?.questionType?.label) return !tagNames.includes(filter?.questionType?.label);
}
}
return true;
});
}
if (filter.questionType?.type === "Attributes") {
toBeFilterResponses = toBeFilterResponses.filter((response) => {
if (filter?.questionType?.label && filter?.filterType?.filterComboBoxValue) {
const attributes =
response.personAttributes && Object.keys(response.personAttributes).length > 0
? response.personAttributes
: null;
if (attributes && attributes.hasOwnProperty(filter?.questionType?.label)) {
if (filter?.filterType?.filterValue === "Equals") {
return attributes[filter?.questionType?.label] === filter?.filterType?.filterComboBoxValue;
}
if (filter?.filterType?.filterValue === "Not equals") {
return attributes[filter?.questionType?.label] !== filter?.filterType?.filterComboBoxValue;
}
} else {
return false;
}
}
return true;
});
}
});
// filtering for the responses which is completed
toBeFilterResponses = toBeFilterResponses.filter((r) => (selectedFilter.onlyComplete ? r.finished : true));
// filtering the data according to the dates
if (dateRange?.from !== undefined && dateRange?.to !== undefined) {
toBeFilterResponses = toBeFilterResponses.filter((r) =>
isWithinInterval(r.createdAt, { start: dateRange.from!, end: dateRange.to! })
);
}
return toBeFilterResponses;
};
// get the today date with full hours
export const getTodayDate = (): Date => {
const date = new Date();
date.setHours(23, 59, 59, 999);
return date;
};
// function update the response value of question multiChoiceSelect
function matchAndUpdateArray(choices: any, responseValue: string[]) {
const choicesArray = choices.map((obj) => obj.label);
responseValue.forEach((element, index) => {
// Check if the element is present in the choices
if (choicesArray.includes(element)) {
return; // No changes needed, move to the next iteration
}
// Check if the choices has 'Other'
if (choicesArray.includes("Other") && !choicesArray.includes(element)) {
responseValue[index] = "Other"; // Update the element to 'Other'
}
});
return responseValue;
}

View File

@@ -1,14 +1,16 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getFilterResponses } from "@/app/lib/surveys/surveys";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import SurveyResultsTabs from "@/app/share/[sharingKey]/(analysis)/components/SurveyResultsTabs";
import ResponseTimeline from "@/app/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline";
import { getResponsesUnauthorizedAction } from "@/app/share/[sharingKey]/action";
import CustomFilter from "@/app/share/[sharingKey]/components/CustomFilter";
import SummaryHeader from "@/app/share/[sharingKey]/components/SummaryHeader";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TResponse, TSurveyPersonAttributes } from "@formbricks/types/responses";
@@ -20,7 +22,6 @@ interface ResponsePageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
responses: TResponse[];
webAppUrl: string;
product: TProduct;
sharingKey: string;
@@ -33,36 +34,60 @@ const ResponsePage = ({
environment,
survey,
surveyId,
responses,
product,
sharingKey,
environmentTags,
attributes,
responsesPerPage,
}: ResponsePageProps) => {
const [responses, setResponses] = useState<TResponse[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[survey, selectedFilter, dateRange]
);
const searchParams = useSearchParams();
survey = useMemo(() => {
return checkForRecallInHeadline(survey);
}, [survey]);
useEffect(() => {
if (!searchParams?.get("referer")) {
resetState();
}
}, [searchParams, resetState]);
// get the filtered array when the selected filter value changes
const filterResponses: TResponse[] = useMemo(() => {
return getFilterResponses(responses, selectedFilter, survey, dateRange);
}, [selectedFilter, responses, survey, dateRange]);
useEffect(() => {
const fetchInitialResponses = async () => {
const responses = await getResponsesUnauthorizedAction(surveyId, 1, responsesPerPage, filters);
if (responses.length < responsesPerPage) {
setHasMore(false);
}
setResponses(responses);
};
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage]);
const fetchNextPage = useCallback(async () => {
const newPage = page + 1;
const newResponses = await getResponsesUnauthorizedAction(surveyId, newPage, responsesPerPage, filters);
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
setHasMore(false);
}
setResponses([...responses, ...newResponses]);
setPage(newPage);
}, [filters, page, responses, responsesPerPage, surveyId]);
return (
<ContentWrapper>
<SummaryHeader survey={survey} product={product} />
<CustomFilter
environmentTags={environmentTags}
attributes={attributes}
responses={filterResponses}
survey={survey}
/>
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
<SurveyResultsTabs
activeId="responses"
environmentId={environment.id}
@@ -72,10 +97,11 @@ const ResponsePage = ({
<ResponseTimeline
environment={environment}
surveyId={surveyId}
responses={filterResponses}
responses={responses}
survey={survey}
environmentTags={environmentTags}
responsesPerPage={responsesPerPage}
fetchNextPage={fetchNextPage}
hasMore={hasMore}
/>
</ContentWrapper>
);

View File

@@ -1,9 +1,10 @@
"use client";
import { getMoreResponses } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
@@ -17,7 +18,8 @@ interface ResponseTimelineProps {
responses: TResponse[];
survey: TSurvey;
environmentTags: TTag[];
responsesPerPage: number;
fetchNextPage: () => void;
hasMore: boolean;
}
export default function ResponseTimeline({
@@ -25,29 +27,18 @@ export default function ResponseTimeline({
responses,
survey,
environmentTags,
responsesPerPage,
fetchNextPage,
hasMore,
}: ResponseTimelineProps) {
const [fetchedResponses, setFetchedResponses] = useState<TResponse[]>(responses);
const loadingRef = useRef(null);
const [page, setPage] = useState(2);
const [hasMoreResponses, setHasMoreResponses] = useState<boolean>(responses.length > 0);
useEffect(() => {
const currentLoadingRef = loadingRef.current;
const loadResponses = async () => {
const newResponses = await getMoreResponses(survey.id, page);
if (newResponses.length === 0) {
setHasMoreResponses(false);
} else {
setPage(page + 1);
}
setFetchedResponses((prevResponses) => [...prevResponses, ...newResponses]);
};
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
if (hasMoreResponses) loadResponses();
if (hasMore) fetchNextPage();
}
},
{ threshold: 0.8 }
@@ -62,13 +53,16 @@ export default function ResponseTimeline({
observer.unobserve(currentLoadingRef);
}
};
}, [responsesPerPage, page, survey.id, fetchedResponses.length, hasMoreResponses]);
}, [fetchNextPage, hasMore]);
const { membershipRole } = useMembershipRole(survey.environmentId);
const { isViewer } = getAccessFlags(membershipRole);
return (
<div className="space-y-4">
{survey.type === "web" && fetchedResponses.length === 0 && !environment.widgetSetupCompleted ? (
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
<EmptyInAppSurveys environment={environment} />
) : fetchedResponses.length === 0 ? (
) : responses.length === 0 ? (
<EmptySpaceFiller
type="response"
environment={environment}
@@ -76,7 +70,7 @@ export default function ResponseTimeline({
/>
) : (
<div>
{fetchedResponses.map((response) => {
{responses.map((response) => {
return (
<div key={response.id}>
<SingleResponseCard
@@ -86,6 +80,7 @@ export default function ResponseTimeline({
environmentTags={environmentTags}
pageType="response"
environment={environment}
isViewer={isViewer}
/>
</div>
);

View File

@@ -1,4 +1,3 @@
import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
import ResponsePage from "@/app/share/[sharingKey]/(analysis)/responses/components/ResponsePage";
import { getResultShareUrlSurveyAction } from "@/app/share/[sharingKey]/action";
import { notFound } from "next/navigation";
@@ -25,10 +24,7 @@ export default async function Page({ params }) {
throw new Error("Survey not found");
}
const [{ responses }, environment] = await Promise.all([
getAnalysisData(survey.id, survey.environmentId),
getEnvironment(survey.environmentId),
]);
const environment = await getEnvironment(survey.environmentId);
if (!environment) {
throw new Error("Environment not found");
@@ -45,9 +41,8 @@ export default async function Page({ params }) {
<>
<ResponsePage
environment={environment}
responses={responses}
survey={survey}
surveyId={params.surveyId}
surveyId={surveyId}
webAppUrl={WEBAPP_URL}
product={product}
sharingKey={params.sharingKey}

View File

@@ -4,16 +4,18 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/comp
import SummaryDropOffs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import SummaryList from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList";
import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata";
import { getFilterResponses } from "@/app/lib/surveys/surveys";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import SurveyResultsTabs from "@/app/share/[sharingKey]/(analysis)/components/SurveyResultsTabs";
import { getSurveySummaryUnauthorizedAction } from "@/app/share/[sharingKey]/action";
import CustomFilter from "@/app/share/[sharingKey]/components/CustomFilter";
import SummaryHeader from "@/app/share/[sharingKey]/components/SummaryHeader";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TResponse, TSurveyPersonAttributes } from "@formbricks/types/responses";
import { TSurveyPersonAttributes, TSurveySummary } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
@@ -22,29 +24,57 @@ interface SummaryPageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
responses: TResponse[];
product: TProduct;
sharingKey: string;
environmentTags: TTag[];
attributes: TSurveyPersonAttributes;
displayCount: number;
responsesPerPage: number;
responseCount: number;
}
const SummaryPage = ({
environment,
survey,
surveyId,
responses,
product,
sharingKey,
environmentTags,
attributes,
displayCount,
responsesPerPage: openTextResponsesPerPage,
responseCount,
}: SummaryPageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>({
meta: {
completedPercentage: 0,
completedResponses: 0,
displayCount: 0,
dropOffPercentage: 0,
dropOffCount: 0,
startsPercentage: 0,
totalResponses: 0,
ttcAverage: 0,
},
dropOff: [],
summary: [],
});
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[survey, selectedFilter, dateRange]
);
useEffect(() => {
const fetchSurveySummary = async () => {
const response = await getSurveySummaryUnauthorizedAction(surveyId, filters);
setSurveySummary(response);
};
fetchSurveySummary();
}, [filters, surveyId]);
survey = useMemo(() => {
return checkForRecallInHeadline(survey);
}, [survey]);
const searchParams = useSearchParams();
useEffect(() => {
@@ -53,20 +83,10 @@ const SummaryPage = ({
}
}, [searchParams, resetState]);
// get the filtered array when the selected filter value changes
const filterResponses: TResponse[] = useMemo(() => {
return getFilterResponses(responses, selectedFilter, survey, dateRange);
}, [selectedFilter, responses, survey, dateRange]);
return (
<ContentWrapper>
<SummaryHeader survey={survey} product={product} />
<CustomFilter
environmentTags={environmentTags}
attributes={attributes}
responses={filterResponses}
survey={survey}
/>
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
<SurveyResultsTabs
activeId="summary"
environmentId={environment.id}
@@ -74,18 +94,18 @@ const SummaryPage = ({
sharingKey={sharingKey}
/>
<SummaryMetadata
responses={filterResponses}
survey={survey}
displayCount={displayCount}
surveySummary={surveySummary.meta}
showDropOffs={showDropOffs}
setShowDropOffs={setShowDropOffs}
/>
{showDropOffs && <SummaryDropOffs survey={survey} responses={responses} displayCount={displayCount} />}
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} />}
<SummaryList
responses={filterResponses}
summary={surveySummary.summary}
responseCount={responseCount}
survey={survey}
environment={environment}
responsesPerPage={openTextResponsesPerPage}
/>
</ContentWrapper>
);

View File

@@ -3,7 +3,7 @@ import SummaryPage from "@/app/share/[sharingKey]/(analysis)/summary/components/
import { getResultShareUrlSurveyAction } from "@/app/share/[sharingKey]/action";
import { notFound } from "next/navigation";
import { REVALIDATION_INTERVAL, TEXT_RESPONSES_PER_PAGE } from "@formbricks/lib/constants";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponsePersonAttributes } from "@formbricks/lib/response/service";
@@ -24,8 +24,7 @@ export default async function Page({ params }) {
if (!survey) {
throw new Error("Survey not found");
}
const [{ responses, displayCount }, environment] = await Promise.all([
const [{ responseCount }, environment] = await Promise.all([
getAnalysisData(survey.id, survey.environmentId),
getEnvironment(survey.environmentId),
]);
@@ -46,15 +45,13 @@ export default async function Page({ params }) {
<>
<SummaryPage
environment={environment}
responses={responses}
survey={survey}
sharingKey={params.sharingKey}
surveyId={survey.id}
sharingKey={params.sharingKey}
product={product}
environmentTags={tags}
attributes={attributes}
displayCount={displayCount}
responsesPerPage={TEXT_RESPONSES_PER_PAGE}
responseCount={responseCount}
/>
</>
);

View File

@@ -1,7 +1,27 @@
"use server";
import { getResponses, getSurveySummary } from "@formbricks/lib/response/service";
import { getSurveyByResultShareKey } from "@formbricks/lib/survey/service";
import { TResponse, TResponseFilterCriteria, TSurveySummary } from "@formbricks/types/responses";
export async function getResultShareUrlSurveyAction(key: string): Promise<string | null> {
return getSurveyByResultShareKey(key);
}
export async function getResponsesUnauthorizedAction(
surveyId: string,
page: number,
batchSize?: number,
filterCriteria?: TResponseFilterCriteria
): Promise<TResponse[]> {
batchSize = batchSize ?? 10;
const responses = await getResponses(surveyId, page, batchSize, filterCriteria);
return responses;
}
export const getSurveySummaryUnauthorizedAction = async (
surveyId: string,
filterCriteria?: TResponseFilterCriteria
): Promise<TSurveySummary> => {
return await getSurveySummary(surveyId, filterCriteria);
};

View File

@@ -4,19 +4,14 @@ import {
DateRange,
useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import ResponseFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { fetchFile } from "@/app/lib/fetchFile";
import { generateQuestionAndFilterOptions, getTodayDate } from "@/app/lib/surveys/surveys";
import { createId } from "@paralleldrive/cuid2";
import { differenceInDays, format, subDays } from "date-fns";
import { ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { differenceInDays, format, startOfDay, subDays } from "date-fns";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getTodaysDateFormatted } from "@formbricks/lib/time";
import useClickOutside from "@formbricks/lib/useClickOutside";
import { TResponse, TSurveyPersonAttributes } from "@formbricks/types/responses";
import { TSurveyPersonAttributes } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { Calendar } from "@formbricks/ui/Calendar";
@@ -32,11 +27,6 @@ enum DateSelected {
TO = "to",
}
enum FilterDownload {
ALL = "all",
FILTER = "filter",
}
enum FilterDropDownLabels {
ALL_TIME = "All time",
LAST_7_DAYS = "Last 7 days",
@@ -48,7 +38,6 @@ interface CustomFilterProps {
environmentTags: TTag[];
attributes: TSurveyPersonAttributes;
survey: TSurvey;
responses: TResponse[];
}
const getDifferenceOfDays = (from, to) => {
@@ -62,7 +51,7 @@ const getDifferenceOfDays = (from, to) => {
}
};
const CustomFilter = ({ environmentTags, attributes, responses, survey }: CustomFilterProps) => {
const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps) => {
const { setSelectedOptions, dateRange, setDateRange } = useResponseFilter();
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
dateRange.from && dateRange.to
@@ -72,7 +61,6 @@ const CustomFilter = ({ environmentTags, attributes, responses, survey }: Custom
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
// when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options
@@ -87,60 +75,6 @@ const CustomFilter = ({ environmentTags, attributes, responses, survey }: Custom
const datePickerRef = useRef<HTMLDivElement>(null);
const getMatchQandA = (responses: TResponse[], survey: TSurvey) => {
if (survey && responses) {
// Create a mapping of question IDs to their headlines
const questionIdToHeadline = {};
survey.questions.forEach((question) => {
questionIdToHeadline[question.id] = question.headline;
});
// 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;
scale?: "number" | "star" | "smiley";
range?: number;
}> = []; // Specify the type of updatedData
// iterate over survey questions and build the updated response
for (const question of survey.questions) {
const answer = response.data[question.id];
if (answer) {
updatedResponse.push({
id: createId(),
question: question.headline,
type: question.type,
scale: question.scale,
range: question.range,
answer: answer as string,
});
}
}
return { ...response, responses: updatedResponse };
});
const updatedResponsesWithTags = updatedResponses.map((response) => ({
...response,
tags: response.tags?.map((tag) => tag),
}));
return updatedResponsesWithTags;
}
return [];
};
const downloadFileName = useMemo(() => {
if (survey) {
const formattedDateString = getTodaysDateFormatted("_");
return `${survey.name.split(" ").join("_")}_responses_${formattedDateString}`.toLocaleLowerCase();
}
return "my_survey_responses";
}, [survey]);
const extracMetadataKeys = useCallback((obj, parentKey = "") => {
let keys: string[] = [];
@@ -155,136 +89,6 @@ const CustomFilter = ({ environmentTags, attributes, responses, survey }: Custom
return keys;
}, []);
const getAllResponsesInBatches = useCallback(async () => {
const BATCH_SIZE = 3000;
const responses: TResponse[] = [];
for (let page = 1; ; page++) {
const batchResponses = await getResponsesAction(survey.id, page, BATCH_SIZE);
responses.push(...batchResponses);
if (batchResponses.length < BATCH_SIZE) {
break;
}
}
return responses;
}, [survey.id]);
const downloadResponses = useCallback(
async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
const downloadResponse = filter === FilterDownload.ALL ? await getAllResponsesInBatches() : responses;
const questionNames = survey.questions?.map((question) => question.headline);
const hiddenFieldIds = survey.hiddenFields.fieldIds;
const hiddenFieldResponse = {};
let metaDataFields = extracMetadataKeys(downloadResponse[0].meta);
const userAttributes = ["Init Attribute 1", "Init Attribute 2"];
const matchQandA = getMatchQandA(downloadResponse, survey);
const jsonData = matchQandA.map((response) => {
const basicInfo = {
"Response ID": response.id,
Timestamp: response.createdAt,
Finished: response.finished,
"User ID": response.person?.userId,
"Survey ID": response.surveyId,
};
const metaDataKeys = extracMetadataKeys(response.meta);
let metaData = {};
metaDataKeys.forEach((key) => {
if (!metaDataFields.includes(key)) metaDataFields.push(key);
if (response.meta) {
if (key.includes("-")) {
const nestedKeyArray = key.split("-");
metaData[key] = response.meta[nestedKeyArray[0].trim()][nestedKeyArray[1].trim()] ?? "";
} else {
metaData[key] = response.meta[key] ?? "";
}
}
});
const personAttributes = response.personAttributes;
if (hiddenFieldIds && hiddenFieldIds.length > 0) {
hiddenFieldIds.forEach((hiddenFieldId) => {
hiddenFieldResponse[hiddenFieldId] = response.data[hiddenFieldId] ?? "";
});
}
const fileResponse = { ...basicInfo, ...metaData, ...personAttributes, ...hiddenFieldResponse };
// Map each question name to its corresponding answer
questionNames.forEach((questionName: string) => {
const matchingQuestion = response.responses.find((question) => question.question === questionName);
let transformedAnswer = "";
if (matchingQuestion) {
const answer = matchingQuestion.answer;
if (Array.isArray(answer)) {
transformedAnswer = answer.join("; ");
} else {
transformedAnswer = answer;
}
}
fileResponse[questionName] = matchingQuestion ? transformedAnswer : "";
});
return fileResponse;
});
// Fields which will be used as column headers in the file
const fields = [
"Response ID",
"Timestamp",
"Finished",
"Survey ID",
"User ID",
...metaDataFields,
...questionNames,
...(hiddenFieldIds ?? []),
...(survey.type === "web" ? userAttributes : []),
];
let response;
try {
response = await fetchFile(
{
json: jsonData,
fields,
fileName: downloadFileName,
},
filetype
);
} catch (err) {
toast.error(`Error downloading ${filetype === "csv" ? "CSV" : "Excel"}`);
return;
}
let blob: Blob;
if (filetype === "csv") {
blob = new Blob([response.fileResponse], { type: "text/csv;charset=utf-8;" });
} else if (filetype === "xlsx") {
const binaryString = atob(response["fileResponse"]);
const byteArray = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i);
}
blob = new Blob([byteArray], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
} else {
throw new Error(`Unsupported filetype: ${filetype}`);
}
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `${downloadFileName}.${filetype}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
},
[downloadFileName, extracMetadataKeys, getAllResponsesInBatches, responses, survey]
);
const handleDateHoveredChange = (date: Date) => {
if (selectingDate === DateSelected.FROM) {
const startOfRange = new Date(date);
@@ -360,7 +164,7 @@ const CustomFilter = ({ environmentTags, attributes, responses, survey }: Custom
setIsFilterDropDownOpen(value);
}}>
<DropdownMenuTrigger>
<div className="flex h-auto min-w-[8rem] items-center justify-between rounded-md border bg-white p-3 sm:min-w-[11rem] sm:px-6 sm:py-3">
<div className="flex h-auto min-w-[8rem] items-center justify-between rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span className="text-sm text-slate-700">
{filterRange === FilterDropDownLabels.CUSTOM_RANGE
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
@@ -388,7 +192,7 @@ const CustomFilter = ({ environmentTags, attributes, responses, survey }: Custom
className="hover:ring-0"
onClick={() => {
setFilterRange(FilterDropDownLabels.LAST_7_DAYS);
setDateRange({ from: subDays(new Date(), 7), to: getTodayDate() });
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
}}>
<p className="text-slate-700">Last 7 days</p>
</DropdownMenuItem>
@@ -396,7 +200,7 @@ const CustomFilter = ({ environmentTags, attributes, responses, survey }: Custom
className="hover:ring-0"
onClick={() => {
setFilterRange(FilterDropDownLabels.LAST_30_DAYS);
setDateRange({ from: subDays(new Date(), 30), to: getTodayDate() });
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
}}>
<p className="text-slate-700">Last 30 days</p>
</DropdownMenuItem>
@@ -411,55 +215,6 @@ const CustomFilter = ({ environmentTags, attributes, responses, survey }: Custom
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsDownloadDropDownOpen(value);
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[11rem] sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">Download</span>
{isDownloadDropDownOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">All responses (CSV)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">All responses (Excel)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">Current selection (CSV)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">Current selection (Excel)</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isDatePickerOpen && (
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">

View File

@@ -15,6 +15,7 @@ import {
TResponseLegacyInput,
TResponseUpdateInput,
TSurveyPersonAttributes,
TSurveySummary,
ZResponse,
ZResponseFilterCriteria,
ZResponseInput,
@@ -31,8 +32,11 @@ import {
buildWhereClause,
calculateTtcTotal,
extractSurveyDetails,
getQuestionWiseSummary,
getResponsesFileName,
getResponsesJson,
getSurveySummaryDropOff,
getSurveySummaryMeta,
} from "../response/util";
import { responseNoteCache } from "../responseNote/cache";
import { getResponseNotes } from "../responseNote/service";
@@ -515,6 +519,53 @@ export const getResponses = async (
}));
};
export const getSurveySummary = (
surveyId: string,
filterCriteria?: TResponseFilterCriteria
): Promise<TSurveySummary> => {
const summary = unstable_cache(
async () => {
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const batchSize = 3000;
const responseCount = await getResponseCountBySurveyId(surveyId);
const pages = Math.ceil(responseCount / batchSize);
const responsesArray = await Promise.all(
Array.from({ length: pages }, (_, i) => {
return getResponses(surveyId, i + 1, batchSize, filterCriteria);
})
);
const responses = responsesArray.flat();
const displayCount = await prisma.display.count({
where: {
surveyId,
},
});
const meta = getSurveySummaryMeta(responses, displayCount);
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const questionWiseSummary = getQuestionWiseSummary(survey, responses);
return { meta, dropOff, summary: questionWiseSummary };
},
[`getSurveySummary-${surveyId}-${JSON.stringify(filterCriteria)}`],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return summary;
};
export const getResponseDownloadUrl = async (
surveyId: string,
format: "csv" | "xlsx",
@@ -755,15 +806,19 @@ export const deleteResponse = async (responseId: string): Promise<TResponse> =>
}
};
export const getResponseCountBySurveyId = async (surveyId: string): Promise<number> =>
export const getResponseCountBySurveyId = async (
surveyId: string,
filterCriteria?: TResponseFilterCriteria
): Promise<number> =>
unstable_cache(
async () => {
validateInputs([surveyId, ZId]);
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
try {
const responseCount = await prisma.response.count({
where: {
surveyId: surveyId,
...buildWhereClause(filterCriteria),
},
});
return responseCount;

View File

@@ -7,7 +7,9 @@ import {
TResponseFilterCriteria,
TResponseUpdateInput,
TSurveyPersonAttributes,
TSurveySummary,
} from "@formbricks/types/responses";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { transformPrismaPerson } from "../../../person/service";
@@ -458,3 +460,40 @@ export const getMockUpdateResponseInput = (finished: boolean = false): TResponse
data: mockResponseData,
finished,
});
export const mockSurveySummaryOutput: TSurveySummary = {
dropOff: [
{
dropOffCount: 0,
dropOffPercentage: 0,
headline: "Question Text",
questionId: "ars2tjk8hsi8oqk1uac00mo8",
ttc: 0,
views: 0,
},
],
meta: {
completedPercentage: 0,
completedResponses: 1,
displayCount: 0,
dropOffPercentage: 0,
dropOffCount: 0,
startsPercentage: 0,
totalResponses: 1,
ttcAverage: 0,
},
summary: [
{
question: {
headline: "Question Text",
id: "ars2tjk8hsi8oqk1uac00mo8",
inputType: "text",
required: false,
type: TSurveyQuestionType.OpenText,
},
responseCount: 0,
samples: [],
type: "openText",
},
],
};

View File

@@ -15,6 +15,7 @@ import {
mockResponseWithMockPerson,
mockSingleUseId,
mockSurveyId,
mockSurveySummaryOutput,
mockTags,
mockUserId,
} from "./__mocks__/data.mock";
@@ -45,6 +46,7 @@ import {
getResponses,
getResponsesByEnvironmentId,
getResponsesByPersonId,
getSurveySummary,
updateResponse,
} from "../service";
import { buildWhereClause } from "../util";
@@ -468,6 +470,44 @@ describe("Tests for getResponses service", () => {
});
});
describe("Tests for getSurveySummary service", () => {
describe("Happy Path", () => {
it("Returns a summary of the survey responses", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.findMany.mockResolvedValue([mockResponse]);
const summary = await getSurveySummary(mockSurveyId);
expect(summary).toEqual(mockSurveySummaryOutput);
});
});
describe("Sad Path", () => {
testInputValidation(getSurveySummary, 1);
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
clientVersion: "0.0.1",
});
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.findMany.mockRejectedValue(errToThrow);
await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(DatabaseError);
});
it("Throws a generic Error for unexpected problems", async () => {
const mockErrorMessage = "Mock error message";
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.findMany.mockRejectedValue(new Error(mockErrorMessage));
await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(Error);
});
});
});
describe("Tests for getResponseDownloadUrl service", () => {
describe("Happy Path", () => {
it("Returns a download URL for the csv response file", async () => {

View File

@@ -2,10 +2,24 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { TResponse, TResponseFilterCriteria, TResponseTtc } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TPerson } from "@formbricks/types/people";
import {
TResponse,
TResponseFilterCriteria,
TResponseTtc,
TSurveySummary,
TSurveySummaryDate,
TSurveySummaryFileUpload,
TSurveySummaryHiddenField,
TSurveySummaryMultipleChoice,
TSurveySummaryOpenText,
TSurveySummaryPictureSelection,
TSurveySummaryRating,
} from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
import { getTodaysDateTimeFormatted } from "../time";
import { evaluateCondition } from "../utils/evaluateLogic";
export function calculateTtcTotal(ttc: TResponseTtc) {
const result = { ...ttc };
@@ -382,3 +396,603 @@ export const getResponsesJson = (
return jsonData;
};
const convertFloatTo2Decimal = (num: number) => {
return Math.round(num * 100) / 100;
};
export const getSurveySummaryMeta = (
responses: TResponse[],
displayCount: number
): TSurveySummary["meta"] => {
const completedResponses = responses.filter((response) => response.finished).length;
let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => {
if (response.ttc?._total) {
ttcResponseCount++;
return acc + response.ttc._total;
}
return acc;
}, 0);
const responseCount = responses.length;
const startsPercentage = displayCount > 0 ? (responseCount / displayCount) * 100 : 0;
const completedPercentage = displayCount > 0 ? (completedResponses / displayCount) * 100 : 0;
const dropOffCount = responseCount - completedResponses;
const dropOffPercentage = responseCount > 0 ? (dropOffCount / responseCount) * 100 : 0;
const ttcAverage = ttcResponseCount > 0 ? ttcSum / ttcResponseCount : 0;
return {
displayCount: displayCount || 0,
totalResponses: responseCount,
startsPercentage: convertFloatTo2Decimal(startsPercentage),
completedResponses,
completedPercentage: convertFloatTo2Decimal(completedPercentage),
dropOffCount,
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentage),
ttcAverage: convertFloatTo2Decimal(ttcAverage),
};
};
export const getSurveySummaryDropOff = (
survey: TSurvey,
responses: TResponse[],
displayCount: number
): TSurveySummary["dropOff"] => {
const initialTtc = survey.questions.reduce((acc: Record<string, number>, question) => {
acc[question.id] = 0;
return acc;
}, {});
let totalTtc = { ...initialTtc };
let responseCounts = { ...initialTtc };
let dropOffArr = new Array(survey.questions.length).fill(0) as number[];
let viewsArr = new Array(survey.questions.length).fill(0) as number[];
let dropOffPercentageArr = new Array(survey.questions.length).fill(0) as number[];
responses.forEach((response) => {
// Calculate total time-to-completion
Object.keys(totalTtc).forEach((questionId) => {
if (response.ttc && response.ttc[questionId]) {
totalTtc[questionId] += response.ttc[questionId];
responseCounts[questionId]++;
}
});
let currQuesIdx = 0;
while (currQuesIdx < survey.questions.length) {
const currQues = survey.questions[currQuesIdx];
if (!currQues) break;
if (!currQues.required) {
if (!response.data[currQues.id]) {
viewsArr[currQuesIdx]++;
if (currQuesIdx === survey.questions.length - 1 && !response.finished) {
dropOffArr[currQuesIdx]++;
break;
}
const questionHasCustomLogic = currQues.logic;
if (questionHasCustomLogic) {
let didLogicPass = false;
for (let logic of questionHasCustomLogic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, response.data[currQues.id] ?? null)) {
didLogicPass = true;
currQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
break;
}
}
if (!didLogicPass) currQuesIdx++;
} else {
currQuesIdx++;
}
continue;
}
}
if (
(response.data[currQues.id] === undefined && !response.finished) ||
(currQues.required && !response.data[currQues.id])
) {
dropOffArr[currQuesIdx]++;
viewsArr[currQuesIdx]++;
break;
}
viewsArr[currQuesIdx]++;
let nextQuesIdx = currQuesIdx + 1;
const questionHasCustomLogic = currQues.logic;
if (questionHasCustomLogic) {
for (let logic of questionHasCustomLogic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, response.data[currQues.id])) {
nextQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
break;
}
}
}
if (!response.data[survey.questions[nextQuesIdx]?.id] && !response.finished) {
dropOffArr[nextQuesIdx]++;
viewsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
}
});
// Calculate the average time for each question
Object.keys(totalTtc).forEach((questionId) => {
totalTtc[questionId] =
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
});
if (!survey.welcomeCard.enabled) {
dropOffArr[0] = displayCount - viewsArr[0];
if (viewsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
dropOffPercentageArr[0] =
viewsArr[0] - displayCount >= 0 ? 0 : ((displayCount - viewsArr[0]) / displayCount) * 100 || 0;
viewsArr[0] = displayCount;
} else {
dropOffPercentageArr[0] = (dropOffArr[0] / viewsArr[0]) * 100;
}
for (let i = 1; i < survey.questions.length; i++) {
if (viewsArr[i] !== 0) {
dropOffPercentageArr[i] = (dropOffArr[i] / viewsArr[i]) * 100;
}
}
const dropOff = survey.questions.map((question, index) => {
return {
questionId: question.id,
headline: question.headline,
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
views: viewsArr[index] || 0,
dropOffCount: dropOffArr[index] || 0,
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0,
};
});
return dropOff;
};
export const getQuestionWiseSummary = (
survey: TSurvey,
responses: TResponse[]
): TSurveySummary["summary"] => {
const VALUES_LIMIT = 10;
let summary: TSurveySummary["summary"] = [];
survey.questions.forEach((question) => {
switch (question.type) {
case TSurveyQuestionType.OpenText: {
let values: TSurveySummaryOpenText["samples"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (answer && typeof answer === "string") {
values.push({
id: response.id,
updatedAt: response.updatedAt,
value: answer,
person: response.person,
});
}
});
summary.push({
type: question.type,
question,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
});
values = [];
break;
}
case TSurveyQuestionType.MultipleChoiceSingle:
case TSurveyQuestionType.MultipleChoiceMulti: {
let values: TSurveySummaryMultipleChoice["choices"] = [];
// check last choice is others or not
const lastChoice = question.choices[question.choices.length - 1];
const isOthersEnabled = lastChoice.id === "other";
const questionChoices = question.choices.map((choice) => choice.label);
if (isOthersEnabled) {
questionChoices.pop();
}
let totalResponseCount = 0;
const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
acc[choice] = 0;
return acc;
}, {});
const otherValues: { value: string; person: TPerson | null }[] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (Array.isArray(answer)) {
answer.forEach((value) => {
totalResponseCount++;
if (questionChoices.includes(value)) {
choiceCountMap[value]++;
} else {
otherValues.push({
value,
person: response.person,
});
}
});
} else if (typeof answer === "string") {
totalResponseCount++;
if (questionChoices.includes(answer)) {
choiceCountMap[answer]++;
} else {
otherValues.push({
value: answer,
person: response.person,
});
}
}
});
Object.entries(choiceCountMap).map(([label, count]) => {
values.push({
value: label,
count,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
});
});
if (isOthersEnabled) {
values.push({
value: lastChoice.label || "Other",
count: otherValues.length,
percentage: convertFloatTo2Decimal((otherValues.length / totalResponseCount) * 100),
others: otherValues.slice(0, VALUES_LIMIT),
});
}
summary.push({
type: question.type,
question,
responseCount: totalResponseCount,
choices: values,
});
values = [];
break;
}
case TSurveyQuestionType.PictureSelection: {
let values: TSurveySummaryPictureSelection["choices"] = [];
const choiceCountMap: Record<string, number> = {};
question.choices.forEach((choice) => {
choiceCountMap[choice.id] = 0;
});
let totalResponseCount = 0;
responses.forEach((response) => {
const answer = response.data[question.id];
if (Array.isArray(answer)) {
answer.forEach((value) => {
totalResponseCount++;
choiceCountMap[value]++;
});
}
});
question.choices.forEach((choice) => {
values.push({
id: choice.id,
imageUrl: choice.imageUrl,
count: choiceCountMap[choice.id],
percentage:
totalResponseCount > 0
? convertFloatTo2Decimal((choiceCountMap[choice.id] / totalResponseCount) * 100)
: 0,
});
});
summary.push({
type: question.type,
question,
responseCount: totalResponseCount,
choices: values,
});
values = [];
break;
}
case TSurveyQuestionType.Rating: {
let values: TSurveySummaryRating["choices"] = [];
const choiceCountMap: Record<number, number> = {};
const range = question.range;
for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0;
}
let totalResponseCount = 0;
let totalRating = 0;
let dismissed = 0;
responses.forEach((response) => {
const answer = response.data[question.id];
if (typeof answer === "number") {
totalResponseCount++;
choiceCountMap[answer]++;
totalRating += answer;
} else if (response.ttc && response.ttc[question.id] > 0) {
totalResponseCount++;
dismissed++;
}
});
Object.entries(choiceCountMap).map(([label, count]) => {
values.push({
rating: parseInt(label),
count,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
});
});
summary.push({
type: question.type,
question,
average: convertFloatTo2Decimal(totalRating / (totalResponseCount - dismissed)) || 0,
responseCount: totalResponseCount,
choices: values,
dismissed: {
count: dismissed,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((dismissed / totalResponseCount) * 100) : 0,
},
});
values = [];
break;
}
case TSurveyQuestionType.NPS: {
const data = {
promoters: 0,
passives: 0,
detractors: 0,
dismissed: 0,
total: 0,
score: 0,
};
responses.forEach((response) => {
const value = response.data[question.id];
if (typeof value === "number") {
data.total++;
if (value >= 9) {
data.promoters++;
} else if (value >= 7) {
data.passives++;
} else {
data.detractors++;
}
} else if (response.ttc && response.ttc[question.id] > 0) {
data.total++;
data.dismissed++;
}
});
data.score =
data.total > 0
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
: 0;
summary.push({
type: question.type,
question,
responseCount: data.total,
total: data.total,
score: data.score,
promoters: {
count: data.promoters,
percentage: data.total > 0 ? convertFloatTo2Decimal((data.promoters / data.total) * 100) : 0,
},
passives: {
count: data.passives,
percentage: data.total > 0 ? convertFloatTo2Decimal((data.passives / data.total) * 100) : 0,
},
detractors: {
count: data.detractors,
percentage: data.total > 0 ? convertFloatTo2Decimal((data.detractors / data.total) * 100) : 0,
},
dismissed: {
count: data.dismissed,
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
},
});
break;
}
case TSurveyQuestionType.CTA: {
const data = {
clicked: 0,
dismissed: 0,
};
responses.forEach((response) => {
const value = response.data[question.id];
if (value === "clicked") {
data.clicked++;
} else if (value === "dismissed") {
data.dismissed++;
}
});
const totalResponses = data.clicked + data.dismissed;
summary.push({
type: question.type,
question,
responseCount: totalResponses,
ctr: {
count: data.clicked,
percentage:
totalResponses > 0 ? convertFloatTo2Decimal((data.clicked / totalResponses) * 100) : 0,
},
});
break;
}
case TSurveyQuestionType.Consent: {
const data = {
accepted: 0,
dismissed: 0,
};
responses.forEach((response) => {
const value = response.data[question.id];
if (value === "accepted") {
data.accepted++;
} else if (response.ttc && response.ttc[question.id] > 0) {
data.dismissed++;
}
});
const totalResponses = data.accepted + data.dismissed;
summary.push({
type: question.type,
question,
responseCount: totalResponses,
accepted: {
count: data.accepted,
percentage:
totalResponses > 0 ? convertFloatTo2Decimal((data.accepted / totalResponses) * 100) : 0,
},
dismissed: {
count: data.dismissed,
percentage:
totalResponses > 0 ? convertFloatTo2Decimal((data.dismissed / totalResponses) * 100) : 0,
},
});
break;
}
case TSurveyQuestionType.Date: {
let values: TSurveySummaryDate["samples"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (answer && typeof answer === "string") {
values.push({
id: response.id,
updatedAt: response.updatedAt,
value: answer,
person: response.person,
});
}
});
summary.push({
type: question.type,
question,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
});
values = [];
break;
}
case TSurveyQuestionType.FileUpload: {
let values: TSurveySummaryFileUpload["files"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (Array.isArray(answer)) {
values.push({
id: response.id,
updatedAt: response.updatedAt,
value: answer,
person: response.person,
});
}
});
summary.push({
type: question.type,
question,
responseCount: values.length,
files: values.slice(0, VALUES_LIMIT),
});
values = [];
break;
}
case TSurveyQuestionType.Cal: {
const data = {
booked: 0,
skipped: 0,
};
responses.forEach((response) => {
const value = response.data[question.id];
if (value === "booked") {
data.booked++;
} else if (response.ttc && response.ttc[question.id] > 0) {
data.skipped++;
}
});
const totalResponses = data.booked + data.skipped;
summary.push({
type: question.type,
question,
responseCount: totalResponses,
booked: {
count: data.booked,
percentage: totalResponses > 0 ? convertFloatTo2Decimal((data.booked / totalResponses) * 100) : 0,
},
skipped: {
count: data.skipped,
percentage:
totalResponses > 0 ? convertFloatTo2Decimal((data.skipped / totalResponses) * 100) : 0,
},
});
break;
}
}
});
survey.hiddenFields?.fieldIds?.forEach((question) => {
let values: TSurveySummaryHiddenField["samples"] = [];
responses.forEach((response) => {
const answer = response.data[question];
if (answer && typeof answer === "string") {
values.push({
updatedAt: response.updatedAt,
value: answer,
person: response.person,
});
}
});
summary.push({
type: "hiddenField",
question,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
});
values = [];
});
return summary;
};

View File

@@ -1,7 +1,21 @@
import { z } from "zod";
import { ZPerson, ZPersonAttributes } from "./people";
import { ZSurvey, ZSurveyLogicCondition } from "./surveys";
import {
ZSurvey,
ZSurveyCTAQuestion,
ZSurveyCalQuestion,
ZSurveyConsentQuestion,
ZSurveyDateQuestion,
ZSurveyFileUploadQuestion,
ZSurveyLogicCondition,
ZSurveyMultipleChoiceMultiQuestion,
ZSurveyMultipleChoiceSingleQuestion,
ZSurveyNPSQuestion,
ZSurveyOpenTextQuestion,
ZSurveyPictureSelectionQuestion,
ZSurveyRatingQuestion,
} from "./surveys";
import { ZTag } from "./tags";
export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
@@ -249,3 +263,235 @@ export const ZResponseUpdate = z.object({
});
export type TResponseUpdate = z.infer<typeof ZResponseUpdate>;
export const ZSurveySummaryOpenText = z.object({
type: z.literal("openText"),
question: ZSurveyOpenTextQuestion,
responseCount: z.number(),
samples: z.array(
z.object({
id: z.string(),
updatedAt: z.date(),
value: z.string(),
person: ZPerson.nullable(),
})
),
});
export type TSurveySummaryOpenText = z.infer<typeof ZSurveySummaryOpenText>;
export const ZSurveySummaryMultipleChoice = z.object({
type: z.union([z.literal("multipleChoiceMulti"), z.literal("multipleChoiceSingle")]),
question: z.union([ZSurveyMultipleChoiceSingleQuestion, ZSurveyMultipleChoiceMultiQuestion]),
responseCount: z.number(),
choices: z.array(
z.object({
value: z.string(),
count: z.number(),
percentage: z.number(),
others: z
.array(
z.object({
value: z.string(),
person: ZPerson.nullable(),
})
)
.optional(),
})
),
});
export type TSurveySummaryMultipleChoice = z.infer<typeof ZSurveySummaryMultipleChoice>;
export const ZSurveySummaryPictureSelection = z.object({
type: z.literal("pictureSelection"),
question: ZSurveyPictureSelectionQuestion,
responseCount: z.number(),
choices: z.array(
z.object({
id: z.string(),
imageUrl: z.string(),
count: z.number(),
percentage: z.number(),
})
),
});
export type TSurveySummaryPictureSelection = z.infer<typeof ZSurveySummaryPictureSelection>;
export const ZSurveySummaryRating = z.object({
type: z.literal("rating"),
question: ZSurveyRatingQuestion,
responseCount: z.number(),
average: z.number(),
choices: z.array(
z.object({
rating: z.number(),
count: z.number(),
percentage: z.number(),
})
),
dismissed: z.object({
count: z.number(),
percentage: z.number(),
}),
});
export type TSurveySummaryRating = z.infer<typeof ZSurveySummaryRating>;
export const ZSurveySummaryNps = z.object({
type: z.literal("nps"),
question: ZSurveyNPSQuestion,
responseCount: z.number(),
total: z.number(),
score: z.number(),
promoters: z.object({
count: z.number(),
percentage: z.number(),
}),
passives: z.object({
count: z.number(),
percentage: z.number(),
}),
detractors: z.object({
count: z.number(),
percentage: z.number(),
}),
dismissed: z.object({
count: z.number(),
percentage: z.number(),
}),
});
export type TSurveySummaryNps = z.infer<typeof ZSurveySummaryNps>;
export const ZSurveySummaryCta = z.object({
type: z.literal("cta"),
question: ZSurveyCTAQuestion,
responseCount: z.number(),
ctr: z.object({
count: z.number(),
percentage: z.number(),
}),
});
export type TSurveySummaryCta = z.infer<typeof ZSurveySummaryCta>;
export const ZSurveySummaryConsent = z.object({
type: z.literal("consent"),
question: ZSurveyConsentQuestion,
responseCount: z.number(),
accepted: z.object({
count: z.number(),
percentage: z.number(),
}),
dismissed: z.object({
count: z.number(),
percentage: z.number(),
}),
});
export type TSurveySummaryConsent = z.infer<typeof ZSurveySummaryConsent>;
export const ZSurveySummaryDate = z.object({
type: z.literal("date"),
question: ZSurveyDateQuestion,
responseCount: z.number(),
samples: z.array(
z.object({
id: z.string(),
updatedAt: z.date(),
value: z.string(),
person: ZPerson.nullable(),
})
),
});
export type TSurveySummaryDate = z.infer<typeof ZSurveySummaryDate>;
export const ZSurveySummaryFileUpload = z.object({
type: z.literal("fileUpload"),
question: ZSurveyFileUploadQuestion,
responseCount: z.number(),
files: z.array(
z.object({
id: z.string(),
updatedAt: z.date(),
value: z.array(z.string()),
person: ZPerson.nullable(),
})
),
});
export type TSurveySummaryFileUpload = z.infer<typeof ZSurveySummaryFileUpload>;
export const ZSurveySummaryCal = z.object({
type: z.literal("cal"),
question: ZSurveyCalQuestion,
responseCount: z.number(),
booked: z.object({
count: z.number(),
percentage: z.number(),
}),
skipped: z.object({
count: z.number(),
percentage: z.number(),
}),
});
export type TSurveySummaryCal = z.infer<typeof ZSurveySummaryCal>;
export const ZSurveySummaryHiddenField = z.object({
type: z.literal("hiddenField"),
question: z.string(),
responseCount: z.number(),
samples: z.array(
z.object({
updatedAt: z.date(),
value: z.string(),
person: ZPerson.nullable(),
})
),
});
export type TSurveySummaryHiddenField = z.infer<typeof ZSurveySummaryHiddenField>;
export const ZSurveySummary = z.object({
meta: z.object({
displayCount: z.number(),
totalResponses: z.number(),
startsPercentage: z.number(),
completedResponses: z.number(),
completedPercentage: z.number(),
dropOffCount: z.number(),
dropOffPercentage: z.number(),
ttcAverage: z.number(),
}),
dropOff: z.array(
z.object({
questionId: z.string().cuid2(),
headline: z.string(),
ttc: z.number(),
views: z.number(),
dropOffCount: z.number(),
dropOffPercentage: z.number(),
})
),
summary: z.array(
z.union([
ZSurveySummaryOpenText,
ZSurveySummaryMultipleChoice,
ZSurveySummaryPictureSelection,
ZSurveySummaryRating,
ZSurveySummaryNps,
ZSurveySummaryCta,
ZSurveySummaryConsent,
ZSurveySummaryDate,
ZSurveySummaryFileUpload,
ZSurveySummaryCal,
ZSurveySummaryHiddenField,
])
),
});
export type TSurveySummary = z.infer<typeof ZSurveySummary>;

View File

@@ -21,7 +21,7 @@ export const PictureSelectionResponse = ({ choices, selected }: PictureSelection
return (
<div className="my-1 flex flex-wrap gap-x-5 gap-y-4">
{selected.map((id) => (
<div className="relative h-32 w-56">
<div className="relative h-32 w-56" key={id}>
{choiceImageMapping[id] && (
<Image
src={choiceImageMapping[id]}

View File

@@ -9,8 +9,6 @@ import { ReactNode, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
@@ -23,7 +21,6 @@ import { TUser } from "@formbricks/types/user";
import { PersonAvatar } from "../Avatars";
import { DeleteDialog } from "../DeleteDialog";
import { FileUploadResponse } from "../FileUploadResponse";
import { LoadingWrapper } from "../LoadingWrapper";
import { PictureSelectionResponse } from "../PictureSelectionResponse";
import { RatingResponse } from "../RatingResponse";
import { SurveyStatusIndicator } from "../SurveyStatusIndicator";
@@ -43,6 +40,7 @@ export interface SingleResponseCardProps {
environment: TEnvironment;
updateResponse?: (responseId: string, responses: TResponse) => void;
deleteResponse?: (responseId: string) => void;
isViewer: boolean;
}
interface TooltipRendererProps {
@@ -83,6 +81,7 @@ export default function SingleResponseCard({
environment,
updateResponse,
deleteResponse,
isViewer,
}: SingleResponseCardProps) {
const environmentId = survey.environmentId;
const router = useRouter();
@@ -95,8 +94,7 @@ export default function SingleResponseCard({
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
let skippedQuestions: string[][] = [];
let temp: string[] = [];
const { membershipRole, isLoading, error } = useMembershipRole(environmentId);
const { isViewer } = getAccessFlags(membershipRole);
const isFirstQuestionAnswered = response.data[survey.questions[0].id] ? true : false;
function isValidValue(value: any) {
@@ -424,18 +422,14 @@ export default function SingleResponseCard({
)}
</div>
{user && (
<LoadingWrapper isLoading={isLoading} error={error}>
{!isViewer && (
<ResponseTagsWrapper
environmentId={environmentId}
responseId={response.id}
tags={response.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
environmentTags={environmentTags}
updateFetchedResponses={updateFetchedResponses}
/>
)}
</LoadingWrapper>
{user && !isViewer && (
<ResponseTagsWrapper
environmentId={environmentId}
responseId={response.id}
tags={response.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
environmentTags={environmentTags}
updateFetchedResponses={updateFetchedResponses}
/>
)}
<DeleteDialog