From d01b293a27015f14ebedd0b9e521dcea2c13e31d Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Wed, 6 Mar 2024 17:23:21 +0530 Subject: [PATCH] feat: Move Response Summary Server-side (#2160) Co-authored-by: Matthias Nannt --- .../[personId]/components/ResponsesFeed.tsx | 78 ++- .../surveys/[surveyId]/(analysis)/actions.ts | 17 +- .../surveys/[surveyId]/(analysis)/data.ts | 14 +- .../responses/components/ResponseTimeline.tsx | 6 + .../[surveyId]/(analysis)/summary/actions.ts | 5 +- .../summary/components/CTASummary.tsx | 29 +- .../summary/components/CalSummary.tsx | 85 ++- .../summary/components/ConsentSummary.tsx | 46 +- .../components/DateQuestionSummary.tsx | 87 +-- .../summary/components/FileUploadSummary.tsx | 135 ++-- .../components/HiddenFieldsSummary.tsx | 103 ++- .../components/MultipleChoiceSummary.tsx | 146 +---- .../summary/components/NPSSummary.tsx | 92 +-- .../summary/components/OpenTextSummary.tsx | 88 +-- .../components/PictureChoiceSummary.tsx | 67 +- .../summary/components/RatingSummary.tsx | 111 +--- .../summary/components/SummaryDropOffs.tsx | 173 +---- .../summary/components/SummaryList.tsx | 241 +++---- .../summary/components/SummaryMetadata.tsx | 58 +- .../summary/components/SummaryPage.tsx | 56 +- .../[surveyId]/(analysis)/summary/page.tsx | 8 +- .../[surveyId]/components/CustomFilter.tsx | 17 +- apps/web/app/lib/surveys/surveys.ts | 280 +------- .../responses/components/ResponsePage.tsx | 58 +- .../responses/components/ResponseTimeline.tsx | 37 +- .../(analysis)/responses/page.tsx | 9 +- .../summary/components/SummaryPage.tsx | 68 +- .../[sharingKey]/(analysis)/summary/page.tsx | 11 +- apps/web/app/share/[sharingKey]/action.ts | 20 + .../[sharingKey]/components/CustomFilter.tsx | 261 +------- packages/lib/response/service.ts | 59 +- .../lib/response/tests/__mocks__/data.mock.ts | 39 ++ packages/lib/response/tests/response.test.ts | 40 ++ packages/lib/response/util.ts | 618 +++++++++++++++++- .../lib/utils}/evaluateLogic.ts | 0 packages/types/responses.ts | 248 ++++++- .../ui/PictureSelectionResponse/index.tsx | 2 +- packages/ui/SingleResponseCard/index.tsx | 28 +- 38 files changed, 1691 insertions(+), 1749 deletions(-) rename {apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components => packages/lib/utils}/evaluateLogic.ts (100%) diff --git a/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed.tsx b/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed.tsx index 0592edb5b3..1755adeaba 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed.tsx @@ -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 ? ( ) : ( - fetchedResponses.map((response) => { - const survey = surveys.find((survey) => { - return survey.id === response.surveyId; - }); - return ( -
- {survey && ( - - )} -
- ); - }) + fetchedResponses.map((response) => ( + + )) )} ); } + +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 ( +
+ {survey && ( + + )} +
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts index bd18b909b4..0756e6e221 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts @@ -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 => { + 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); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts index 236192be1d..0af1d05686 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts @@ -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 }; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTimeline.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTimeline.tsx index c823a26bbc..1840c3cf0b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTimeline.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTimeline.tsx @@ -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 (
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? ( @@ -84,6 +89,7 @@ export default function ResponseTimeline({ environment={environment} updateResponse={updateResponse} deleteResponse={deleteResponse} + isViewer={isViewer} />
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts index 776e8376d0..923dd8b172 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -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 { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.tsx index b35d1d3326..482a79af73 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.tsx @@ -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; -} - -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 (
@@ -41,7 +24,7 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {
- {ctr.count} responses + {questionSummary.responseCount} responses
{!questionSummary.question.required && (
Optional
@@ -54,15 +37,15 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {

Clickthrough Rate (CTR)

- {Math.round(ctr.percentage * 100)}% + {Math.round(questionSummary.ctr.percentage)}%

- {ctr.count} {ctr.count === 1 ? "response" : "responses"} + {questionSummary.ctr.count} {questionSummary.ctr.count === 1 ? "response" : "responses"}

- + ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx index e15d1645cd..67d9d74592 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx @@ -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; + 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
- {questionSummary.responses.length} Responses + {questionSummary.responseCount} Responses
+ {!questionSummary.question.required && ( +
Optional
+ )} -
-
-
User
-
Response
-
Time
-
- {questionSummary.responses.map((response) => { - const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null; - return ( -
-
- {response.person ? ( - -
- -
-

- {displayIdentifier} -

- - ) : ( -
-
- -
-

Anonymous

-
- )} +
+
+
+
+

Booked

+
+

+ {Math.round(questionSummary.booked.percentage)}% +

-
- {response.value} -
-
{timeSince(response.updatedAt.toISOString())}
- ); - })} +

+ {questionSummary.booked.count} {questionSummary.booked.count === 1 ? "response" : "responses"} +

+
+ +
+
+
+
+

Dismissed

+
+

+ {Math.round(questionSummary.skipped.percentage)}% +

+
+
+

+ {questionSummary.skipped.count} {questionSummary.skipped.count === 1 ? "response" : "responses"} +

+
+ +
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx index af5ce5119e..7bc3c03e38 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx @@ -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; -} - -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 (
+
{questionTypeInfo && } @@ -48,7 +24,7 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
- {ctr.count} responses + {questionSummary.responseCount} responses
{!questionSummary.question.required && (
Optional
@@ -62,15 +38,16 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)

Accepted

- {Math.round(ctr.acceptedPercentage * 100)}% + {Math.round(questionSummary.accepted.percentage)}%

- {ctr.acceptedCount} {ctr.acceptedCount === 1 ? "response" : "responses"} + {questionSummary.accepted.count}{" "} + {questionSummary.accepted.count === 1 ? "response" : "responses"}

- +
@@ -78,15 +55,16 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)

Dismissed

- {Math.round(ctr.dismissedPercentage * 100)}% + {Math.round(questionSummary.dismissed.percentage)}%

- {ctr.dismissedCount} {ctr.dismissedCount === 1 ? "response" : "responses"} + {questionSummary.dismissed.count}{" "} + {questionSummary.dismissed.count === 1 ? "response" : "responses"}

- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx index 0445ae90b2..cb163d3b7e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx @@ -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; + 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 (
@@ -36,7 +29,7 @@ export default function DateQuestionSummary({
- {questionSummary.responses.length} Responses + {questionSummary.responseCount} Responses
{!questionSummary.question.required && (
Optional
@@ -49,51 +42,39 @@ export default function DateQuestionSummary({
Response
Time
- {questionSummary.responses.slice(0, displayCount).map((response) => { - const displayIdentifier = getPersonIdentifier(response.person!); - return ( -
-
- {response.person ? ( - -
- -
-

- {displayIdentifier} -

- - ) : ( -
-
- -
-

Anonymous

+ {questionSummary.samples.map((response) => ( +
+
+ {response.person ? ( + +
+
- )} -
-
- {formatDateWithOrdinal(new Date(response.value as string))} -
-
{timeSince(response.updatedAt.toISOString())}
+

+ {getPersonIdentifier(response.person)} +

+ + ) : ( +
+
+ +
+

Anonymous

+
+ )} +
+
+ {formatDateWithOrdinal(new Date(response.value as string))} +
+
+ {timeSince(new Date(response.updatedAt).toISOString())}
- ); - })} - - {displayCount < questionSummary.responses.length && ( -
-
- )} + ))}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx index cc3afbe812..3b7f7bdfc4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx @@ -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; + questionSummary: TSurveySummaryFileUpload; environmentId: string; } @@ -31,7 +30,7 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
- {questionSummary.responses.length} Responses + {questionSummary.responseCount} Responses
{!questionSummary.question.required && (
Optional
@@ -44,80 +43,72 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
Response
Time
- {questionSummary.responses.map((response) => { - const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null; - - return ( -
-
- {response.person ? ( - -
- -
-

- {displayIdentifier} -

- - ) : ( -
-
- -
-

Anonymous

+ {questionSummary.files.map((response) => ( +
+
+ {response.person ? ( + +
+
- )} -
+

+ {getPersonIdentifier(response.person)} +

+ + ) : ( +
+
+ +
+

Anonymous

+
+ )} +
-
- {response.value === "skipped" && ( +
+ {Array.isArray(response.value) && + (response.value.length > 0 ? ( + response.value.map((fileUrl, index) => { + const fileName = getOriginalFileNameFromUrl(fileUrl); + + return ( +
+ +
+
+ +
+
+
+ +
+ +

{fileName}

+
+
+ ); + }) + ) : (

skipped

- )} - - {Array.isArray(response.value) && - (response.value.length > 0 ? ( - response.value.map((fileUrl, index) => { - const fileName = getOriginalFileNameFromUrl(fileUrl); - - return ( -
- -
-
- -
-
-
- -
- -

{fileName}

-
-
- ); - }) - ) : ( -
-

skipped

-
- ))} -
- -
{timeSince(response.updatedAt.toISOString())}
+ ))}
- ); - })} + +
+ {timeSince(new Date(response.updatedAt).toISOString())} +
+
+ ))}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx index 77750b13fa..685cbb3a1f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx @@ -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 = ({ 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 = ({ environment, questionSummary }) => { return (
- +
@@ -49,7 +27,7 @@ const HiddenFieldsSummary: FC = ({ environment, respon
- {hiddenFieldResponses?.find((q) => q.question === question)?.responses?.length} Responses + {questionSummary.responseCount} {questionSummary.responseCount === 1 ? "Response" : "Responses"}
@@ -59,44 +37,39 @@ const HiddenFieldsSummary: FC = ({ environment, respon
Response
Time
- {hiddenFieldResponses - ?.find((q) => q.question === question) - ?.responses.map((response) => { - const displayIdentifier = getPersonIdentifier(response.person!); - return ( -
-
- {response.person ? ( - -
- -
-

- {displayIdentifier} -

- - ) : ( -
-
- -
-

Anonymous

-
- )} + {questionSummary.samples.map((response) => ( +
+
+ {response.person ? ( + +
+ +
+

+ {getPersonIdentifier(response.person)} +

+ + ) : ( +
+
+ +
+

Anonymous

-
- {response.value} -
-
- {timeSince(response.updatedAt.toISOString())} -
-
- ); - })} + )} +
+
+ {response.value} +
+
+ {timeSince(new Date(response.updatedAt).toISOString())} +
+
+ ))}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx index 87894abe0b..cea3f14734 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx @@ -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 (
@@ -139,7 +42,7 @@ export default function MultipleChoiceSummary({
- {totalResponses} responses + {questionSummary.responseCount} responses
{!questionSummary.question.required && (
Optional
@@ -151,16 +54,16 @@ export default function MultipleChoiceSummary({
- {results.map((result: any, resultsIdx) => ( -
+ {results.map((result, resultsIdx) => ( +

- {results.length - resultsIdx} - {result.label} + {results.length - resultsIdx} - {result.value}

- {Math.round(result.percentage * 100)}% + {Math.round(result.percentage)}%

@@ -168,16 +71,15 @@ export default function MultipleChoiceSummary({ {result.count} {result.count === 1 ? "response" : "responses"}

- - {result.otherValues.length > 0 && ( + + {result.others && result.others.length > 0 && (
Specified "Other" answers
{surveyType === "web" && "User"}
- {result.otherValues - .filter((otherValue) => otherValue !== "") - .slice(0, otherDisplayCount) + {result.others + .filter((otherValue) => otherValue.value !== "") .map((otherValue, idx) => (
{surveyType === "link" && ( @@ -187,7 +89,7 @@ export default function MultipleChoiceSummary({ {otherValue.value}
)} - {surveyType === "web" && ( + {surveyType === "web" && otherValue.person && ( ))} - {otherDisplayCount < result.otherValues.length && ( -
- -
- )}
)}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx index 1f15037ddb..38bd6f61c6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx @@ -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; -} - -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 (
@@ -89,7 +24,7 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
- {result.total} responses + {questionSummary.responseCount} responses
{!questionSummary.question.required && (
Optional
@@ -104,40 +39,41 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {

{group}

- {Math.round(percentage(result[group], result.total) * 100)}% + {Math.round(questionSummary[group].percentage)}%

- {result[group]} {result[group] === 1 ? "response" : "responses"} + {questionSummary[group].count} {questionSummary[group].count === 1 ? "response" : "responses"}

- +
))} - {dismissed.count > 0 && ( + {questionSummary.dismissed?.count > 0 && (
-
+
-

{dismissed.label}

+

dismissed

- {Math.round(dismissed.percentage * 100)}% + {Math.round(questionSummary.dismissed.percentage)}%

- {dismissed.count} {dismissed.count === 1 ? "response" : "responses"} + {questionSummary.dismissed.count}{" "} + {questionSummary.dismissed.count === 1 ? "response" : "responses"}

- +
)}
- +
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx index c90c840b9e..5e012cf4ff 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx @@ -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; + 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 (
@@ -35,7 +27,7 @@ export default function OpenTextSummary({
- {questionSummary.responses.length} Responses + {questionSummary.responseCount} Responses
{!questionSummary.question.required && (
Optional
@@ -48,51 +40,39 @@ export default function OpenTextSummary({
Response
Time
- {questionSummary.responses.slice(0, displayCount).map((response) => { - const displayIdentifier = getPersonIdentifier(response.person!); - return ( -
-
- {response.person ? ( - -
- -
-

- {displayIdentifier} -

- - ) : ( -
-
- -
-

Anonymous

+ {questionSummary.samples.map((response) => ( +
+
+ {response.person ? ( + +
+
- )} -
-
- {response.value} -
-
{timeSince(response.updatedAt.toISOString())}
+

+ {getPersonIdentifier(response.person)} +

+ + ) : ( +
+
+ +
+

Anonymous

+
+ )} +
+
+ {response.value} +
+
+ {timeSince(new Date(response.updatedAt).toISOString())}
- ); - })} - - {displayCount < questionSummary.responses.length && ( -
-
- )} + ))}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx index d4410c1c73..d2bb8a928e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx @@ -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; -} - -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 (
@@ -83,7 +28,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
- {totalResponses} responses + {questionSummary.responseCount} responses
{isMulti ? "Multi" : "Single"} Select @@ -109,7 +54,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS

- {Math.round((result.percentage || 0) * 100)}% + {Math.round(result.percentage)}%

@@ -117,7 +62,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS {result.count} {result.count === 1 ? "response" : "responses"}

- + ))} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx index c883ea9d27..c4b77507a4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx @@ -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; -} - -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 ; @@ -112,11 +34,11 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
- {totalResponses} responses + {questionSummary.responseCount} responses
{getIconBasedOnScale} -
Overall: {averageRating}
+
Overall: {questionSummary.average.toFixed(2)}
{!questionSummary.question.required && (
Optional
@@ -124,20 +46,20 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
- {results.map((result: any) => ( -
+ {questionSummary.choices.map((result) => ( +
-
+

- {Math.round(result.percentage * 100)}% + {Math.round(result.percentage)}%

@@ -145,27 +67,28 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) { {result.count} {result.count === 1 ? "response" : "responses"}

- +
))}
- {dismissed.count > 0 && ( + {questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
-
+
-

{dismissed.label}

+

dismissed

- {Math.round(dismissed.percentage * 100)}% + {Math.round(questionSummary.dismissed.percentage)}%

- {dismissed.count} {dismissed.count === 1 ? "response" : "responses"} + {questionSummary.dismissed.count}{" "} + {questionSummary.dismissed.count === 1 ? "response" : "responses"}

- +
)} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx index 5ddab5cb92..62c224b631 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx @@ -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({ - 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 (
@@ -179,20 +28,18 @@ export default function SummaryDropOffs({ responses, survey, displayCount }: Sum
Views
Drop Offs
- {survey.questions.map((question, i) => ( + {dropOff.map((quesDropOff) => (
-
{question.headline}
+
{quesDropOff.headline}
- {avgTtc[question.id] !== undefined ? (avgTtc[question.id] / 1000).toFixed(2) + "s" : "N/A"} -
-
- {dropoffMetrics.viewsCount[i]} + {quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
+
{quesDropOff.views}
- {dropoffMetrics.dropoffCount[i]} - ({Math.round(dropoffMetrics.dropoffPercentage[i])}%) + {quesDropOff.dropOffCount} + ({Math.round(quesDropOff.dropOffPercentage)}%)
))} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx index a0bce72259..510467d953 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx @@ -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[] => - 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 (
- {survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? ( + {survey.type === "web" && responseCount === 0 && !environment.widgetSetupCompleted ? ( - ) : responses.length === 0 ? ( + ) : responseCount === 0 ? ( ) : ( - <> - {getSummaryData().map((questionSummary) => { - if (questionSummary.question.type === TSurveyQuestionType.OpenText) { - return ( - } - environmentId={environment.id} - responsesPerPage={responsesPerPage} - /> - ); - } - if ( - questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle || - questionSummary.question.type === TSurveyQuestionType.MultipleChoiceMulti - ) { - return ( - - } - environmentId={environment.id} - surveyType={survey.type} - responsesPerPage={responsesPerPage} - /> - ); - } - if (questionSummary.question.type === TSurveyQuestionType.NPS) { - return ( - } - /> - ); - } - if (questionSummary.question.type === TSurveyQuestionType.CTA) { - return ( - } - /> - ); - } - if (questionSummary.question.type === TSurveyQuestionType.Rating) { - return ( - } - /> - ); - } - if (questionSummary.question.type === TSurveyQuestionType.Consent) { - return ( - } - /> - ); - } - if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) { - return ( - } - /> - ); - } - if (questionSummary.question.type === TSurveyQuestionType.Date) { - return ( - } - environmentId={environment.id} - responsesPerPage={responsesPerPage} - /> - ); - } - if (questionSummary.question.type === TSurveyQuestionType.FileUpload) { - return ( - } - environmentId={environment.id} - /> - ); - } + summary.map((questionSummary) => { + if (questionSummary.type === TSurveyQuestionType.OpenText) { + return ( + + ); + } + if ( + questionSummary.type === TSurveyQuestionType.MultipleChoiceSingle || + questionSummary.type === TSurveyQuestionType.MultipleChoiceMulti + ) { + return ( + + ); + } + if (questionSummary.type === TSurveyQuestionType.NPS) { + return ; + } + if (questionSummary.type === TSurveyQuestionType.CTA) { + return ; + } + if (questionSummary.type === TSurveyQuestionType.Rating) { + return ; + } + if (questionSummary.type === TSurveyQuestionType.Consent) { + return ; + } + if (questionSummary.type === TSurveyQuestionType.PictureSelection) { + return ( + + ); + } + if (questionSummary.type === TSurveyQuestionType.Date) { + return ( + + ); + } + if (questionSummary.type === TSurveyQuestionType.FileUpload) { + return ( + + ); + } + if (questionSummary.type === TSurveyQuestionType.Cal) { + return ( + + ); + } + if (questionSummary.type === "hiddenField") { + return ( + + ); + } - if (questionSummary.question.type === TSurveyQuestionType.Cal) { - return ( - } - environmentId={environment.id} - /> - ); - } - - return null; - })} - - {survey.hiddenFields?.enabled && - survey.hiddenFields.fieldIds?.map((question) => { - return ( - - ); - })} - + return null; + }) )}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx index 8e1f591eb8..8960c7b5d1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx @@ -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>; survey: TSurvey; - displayCount: number; + setShowDropOffs: React.Dispatch>; + showDropOffs: boolean; + surveySummary: TSurveySummary["meta"]; } const StatCard = ({ label, percentage, value, tooltipText }) => ( @@ -36,8 +34,8 @@ const StatCard = ({ label, percentage, value, tooltipText }) => ( ); -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 (
@@ -88,28 +78,26 @@ export default function SummaryMetadata({ /> - : totalResponses} tooltipText="Number of times the survey has been started." /> - : completedResponsesCount} + percentage={`${Math.round(completedPercentage)}%`} + value={completedResponses === 0 ? - : completedResponses} tooltipText="Number of times the survey has been completed." /> - : totalResponses - completedResponsesCount} + percentage={`${Math.round(dropOffPercentage)}%`} + value={dropOffCount === 0 ? - : dropOffCount} tooltipText="Number of times the survey has been started but not completed." /> - : `${formatTime(ttc, validTtcResponsesCount)}` - } + value={ttcAverage === 0 ? - : `${formatTime(ttcAverage)}`} tooltipText="Average time to complete the survey." />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx index 41279d6f8f..da2963d16f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx @@ -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({ + meta: { + completedPercentage: 0, + completedResponses: 0, + displayCount: 0, + dropOffPercentage: 0, + dropOffCount: 0, + startsPercentage: 0, + totalResponses: 0, + ttcAverage: 0, + }, + dropOff: [], + summary: [], + }); const [showDropOffs, setShowDropOffs] = useState(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 ( @@ -86,18 +109,17 @@ const SummaryPage = ({
- {showDropOffs && } + {showDropOffs && } ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx index 4687577c0b..4bfd9d8120 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx @@ -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 }) { <> ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx index 100dea4514..7f3d12f1c2 100755 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -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( dateRange.from && dateRange.to ? getDifferenceOfDays(dateRange.from, dateRange.to) @@ -76,6 +76,21 @@ const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState(false); const [hoveredRange, setHoveredRange] = useState(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( diff --git a/apps/web/app/lib/surveys/surveys.ts b/apps/web/app/lib/surveys/surveys.ts index 3f12edfb7e..b77341317a 100644 --- a/apps/web/app/lib/surveys/surveys.ts +++ b/apps/web/app/lib/surveys/surveys.ts @@ -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; -} diff --git a/apps/web/app/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx index 8a0d4a966d..fdbe6711d6 100644 --- a/apps/web/app/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx @@ -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([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(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 ( - + ); diff --git a/apps/web/app/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx b/apps/web/app/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx index 9d091238e3..c4d7a869b9 100644 --- a/apps/web/app/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx +++ b/apps/web/app/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx @@ -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(responses); const loadingRef = useRef(null); - const [page, setPage] = useState(2); - const [hasMoreResponses, setHasMoreResponses] = useState(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 (
- {survey.type === "web" && fetchedResponses.length === 0 && !environment.widgetSetupCompleted ? ( + {survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? ( - ) : fetchedResponses.length === 0 ? ( + ) : responses.length === 0 ? ( ) : (
- {fetchedResponses.map((response) => { + {responses.map((response) => { return (
); diff --git a/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx b/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx index d69046cfb4..fe6f3c4253 100644 --- a/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx +++ b/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx @@ -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 }) { <> { const { selectedFilter, dateRange, resetState } = useResponseFilter(); + const [surveySummary, setSurveySummary] = useState({ + meta: { + completedPercentage: 0, + completedResponses: 0, + displayCount: 0, + dropOffPercentage: 0, + dropOffCount: 0, + startsPercentage: 0, + totalResponses: 0, + ttcAverage: 0, + }, + dropOff: [], + summary: [], + }); const [showDropOffs, setShowDropOffs] = useState(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 ( - + - {showDropOffs && } + {showDropOffs && } + ); diff --git a/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx b/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx index 1d39e2d14f..6bde70ad53 100644 --- a/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx +++ b/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx @@ -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 }) { <> ); diff --git a/apps/web/app/share/[sharingKey]/action.ts b/apps/web/app/share/[sharingKey]/action.ts index bab2488c7b..e9951c8573 100644 --- a/apps/web/app/share/[sharingKey]/action.ts +++ b/apps/web/app/share/[sharingKey]/action.ts @@ -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 { return getSurveyByResultShareKey(key); } + +export async function getResponsesUnauthorizedAction( + surveyId: string, + page: number, + batchSize?: number, + filterCriteria?: TResponseFilterCriteria +): Promise { + batchSize = batchSize ?? 10; + const responses = await getResponses(surveyId, page, batchSize, filterCriteria); + return responses; +} + +export const getSurveySummaryUnauthorizedAction = async ( + surveyId: string, + filterCriteria?: TResponseFilterCriteria +): Promise => { + return await getSurveySummary(surveyId, filterCriteria); +}; diff --git a/apps/web/app/share/[sharingKey]/components/CustomFilter.tsx b/apps/web/app/share/[sharingKey]/components/CustomFilter.tsx index 5e10a21663..fd20b9ad0c 100755 --- a/apps/web/app/share/[sharingKey]/components/CustomFilter.tsx +++ b/apps/web/app/share/[sharingKey]/components/CustomFilter.tsx @@ -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( dateRange.from && dateRange.to @@ -72,7 +61,6 @@ const CustomFilter = ({ environmentTags, attributes, responses, survey }: Custom const [selectingDate, setSelectingDate] = useState(DateSelected.FROM); const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState(false); - const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState(false); const [hoveredRange, setHoveredRange] = useState(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(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); }}> -
+
{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() }); }}>

Last 7 days

@@ -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() }); }}>

Last 30 days

@@ -411,55 +215,6 @@ const CustomFilter = ({ environmentTags, attributes, responses, survey }: Custom - { - value && handleDatePickerClose(); - setIsDownloadDropDownOpen(value); - }}> - -
-
- Download - {isDownloadDropDownOpen ? ( - - ) : ( - - )} -
- -
-
- - { - downloadResponses(FilterDownload.ALL, "csv"); - }}> -

All responses (CSV)

-
- { - downloadResponses(FilterDownload.ALL, "xlsx"); - }}> -

All responses (Excel)

-
- { - downloadResponses(FilterDownload.FILTER, "csv"); - }}> -

Current selection (CSV)

-
- { - downloadResponses(FilterDownload.FILTER, "xlsx"); - }}> -

Current selection (Excel)

-
-
-
{isDatePickerOpen && (
diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index 90dd889487..f67b67770f 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -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 => { + 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 => } }; -export const getResponseCountBySurveyId = async (surveyId: string): Promise => +export const getResponseCountBySurveyId = async ( + surveyId: string, + filterCriteria?: TResponseFilterCriteria +): Promise => 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; diff --git a/packages/lib/response/tests/__mocks__/data.mock.ts b/packages/lib/response/tests/__mocks__/data.mock.ts index 58d2bff0d2..39581a6b1d 100644 --- a/packages/lib/response/tests/__mocks__/data.mock.ts +++ b/packages/lib/response/tests/__mocks__/data.mock.ts @@ -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", + }, + ], +}; diff --git a/packages/lib/response/tests/response.test.ts b/packages/lib/response/tests/response.test.ts index 594c75b291..ad4e43c9ef 100644 --- a/packages/lib/response/tests/response.test.ts +++ b/packages/lib/response/tests/response.test.ts @@ -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 () => { diff --git a/packages/lib/response/util.ts b/packages/lib/response/util.ts index f1aee3e3f6..64aeeeed02 100644 --- a/packages/lib/response/util.ts +++ b/packages/lib/response/util.ts @@ -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, 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, 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 = {}; + + 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 = {}; + 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; +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic.ts b/packages/lib/utils/evaluateLogic.ts similarity index 100% rename from apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic.ts rename to packages/lib/utils/evaluateLogic.ts diff --git a/packages/types/responses.ts b/packages/types/responses.ts index 56d391fe75..9351c4280e 100644 --- a/packages/types/responses.ts +++ b/packages/types/responses.ts @@ -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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; + +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; diff --git a/packages/ui/PictureSelectionResponse/index.tsx b/packages/ui/PictureSelectionResponse/index.tsx index da5d8353e0..26d9f4865f 100644 --- a/packages/ui/PictureSelectionResponse/index.tsx +++ b/packages/ui/PictureSelectionResponse/index.tsx @@ -21,7 +21,7 @@ export const PictureSelectionResponse = ({ choices, selected }: PictureSelection return (
{selected.map((id) => ( -
+
{choiceImageMapping[id] && ( 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({ )}
- {user && ( - - {!isViewer && ( - ({ tagId: tag.id, tagName: tag.name }))} - environmentTags={environmentTags} - updateFetchedResponses={updateFetchedResponses} - /> - )} - + {user && !isViewer && ( + ({ tagId: tag.id, tagName: tag.name }))} + environmentTags={environmentTags} + updateFetchedResponses={updateFetchedResponses} + /> )}