mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
feat: Move Response Summary Server-side (#2160)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 "Other" 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user