fix necessary hard reload on responses summary and list (#447)

This commit is contained in:
Matti Nannt
2023-06-28 16:45:30 +02:00
committed by GitHub
parent 4f12886bfc
commit ef0c621e5e
12 changed files with 266 additions and 193 deletions

View File

@@ -1,17 +1,20 @@
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { Session } from "next-auth";
import Link from "next/link";
interface ResponsesLimitReachedBannerProps {
environmentId: string;
limitReached: boolean;
responsesCount: number;
session: Session;
surveyId: string;
}
export default function ResponsesLimitReachedBanner({
export default async function ResponsesLimitReachedBanner({
surveyId,
environmentId,
limitReached,
responsesCount,
session,
}: ResponsesLimitReachedBannerProps) {
const { responsesCount, limitReached } = await getAnalysisData(session, surveyId);
return (
<>
{limitReached && (

View File

@@ -1,3 +1,5 @@
export const revalidate = 0;
import ContentWrapper from "@/components/shared/ContentWrapper";
import SurveyResultsTabs from "../SurveyResultsTabs";
import ResponseTimeline from "./ResponseTimeline";
@@ -11,7 +13,7 @@ export default async function ResponsesPage({ params }) {
if (!session) {
throw new Error("Unauthorized");
}
const { responses, responsesCount, limitReached, survey } = await getAnalysisData(session, params.surveyId);
const { responses, survey } = await getAnalysisData(session, params.surveyId);
return (
<>
<SurveyResultsTabs
@@ -19,10 +21,11 @@ export default async function ResponsesPage({ params }) {
environmentId={params.environmentId}
surveyId={params.surveyId}
/>
{/* @ts-expect-error Server Component */}
<ResponsesLimitReachedBanner
environmentId={params.environmentId}
limitReached={limitReached}
responsesCount={responsesCount}
surveyId={params.surveyId}
session={session}
/>
<ContentWrapper>
<ResponseTimeline

View File

@@ -0,0 +1,26 @@
"use client";
import LinkSurveyModal from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/LinkSurveyModal";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { ShareIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
interface LinkSurveyShareButtonProps {
survey: TSurvey;
}
export default function LinkSurveyShareButton({ survey }: LinkSurveyShareButtonProps) {
const [showLinkModal, setShowLinkModal] = useState(false);
return (
<>
<Button
variant="secondary"
className="h-full border border-slate-300 bg-white px-2 hover:bg-slate-100 focus:bg-slate-100 lg:px-6"
onClick={() => setShowLinkModal(true)}>
<ShareIcon className="h-5 w-5" />
</Button>
{showLinkModal && <LinkSurveyModal survey={survey} open={showLinkModal} setOpen={setShowLinkModal} />}
</>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { useEnvironment } from "@/lib/environments/environments";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { ErrorComponent } from "@formbricks/ui";
interface StatusDropdownProps {
survey: TSurvey;
environmentId: string;
}
export default function StatusDropdown({ survey, environmentId }: StatusDropdownProps) {
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
if (isLoadingEnvironment) {
return <LoadingSpinner />;
}
if (isErrorEnvironment) {
return <ErrorComponent />;
}
return (
<>
{environment.widgetSetupCompleted || survey.type === "link" ? (
<SurveyStatusDropdown surveyId={survey.id} environmentId={environmentId} />
) : null}
</>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useEnvironment } from "@/lib/environments/environments";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Confetti } from "@formbricks/ui";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import LinkSurveyModal from "./LinkSurveyModal";
interface SummaryMetadataProps {
environmentId: string;
survey: TSurvey;
}
export default function SuccessMessage({ environmentId, survey }: SummaryMetadataProps) {
const { environment } = useEnvironment(environmentId);
const searchParams = useSearchParams();
const [showLinkModal, setShowLinkModal] = useState(false);
const [confetti, setConfetti] = useState(false);
useEffect(() => {
if (environment) {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && environment) {
setConfetti(true);
toast.success(
survey.type === "web" && !environment.widgetSetupCompleted
? "Almost there! Install widget to start receiving responses."
: "Congrats! Your survey is live.",
{
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
duration: 5000,
position: "bottom-right",
}
);
if (survey.type === "link") {
setShowLinkModal(true);
}
}
}
}, [environment, searchParams, survey]);
return (
<>
{showLinkModal && <LinkSurveyModal survey={survey} open={showLinkModal} setOpen={setShowLinkModal} />}
{confetti && <Confetti />}
</>
);
}

View File

@@ -1,3 +1,4 @@
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import {
QuestionType,
@@ -9,8 +10,8 @@ import {
type RatingQuestion,
} from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys";
import { Session } from "next-auth";
import CTASummary from "./CTASummary";
import MultipleChoiceSummary from "./MultipleChoiceSummary";
import NPSSummary from "./NPSSummary";
@@ -19,14 +20,16 @@ import RatingSummary from "./RatingSummary";
interface SummaryListProps {
environmentId: string;
responses: TResponse[];
surveyId: string;
session: Session;
survey: TSurvey;
}
export default function SummaryList({ environmentId, responses, survey }: SummaryListProps) {
let summaryData: QuestionSummary<TSurveyQuestion>[] = [];
if (survey && responses) {
summaryData = survey.questions.map((question) => {
export default async function SummaryList({ environmentId, surveyId, session }: SummaryListProps) {
const { survey, responses } = await getAnalysisData(session, surveyId);
const getSummaryData = (): QuestionSummary<TSurveyQuestion>[] =>
survey.questions.map((question) => {
const questionResponses = responses
.filter((response) => question.id in response.data)
.map((r) => ({
@@ -40,7 +43,6 @@ export default function SummaryList({ environmentId, responses, survey }: Summar
responses: questionResponses,
};
});
}
return (
<>
@@ -53,7 +55,7 @@ export default function SummaryList({ environmentId, responses, survey }: Summar
/>
) : (
<>
{summaryData.map((questionSummary) => {
{getSummaryData().map((questionSummary) => {
if (questionSummary.question.type === QuestionType.OpenText) {
return (
<OpenTextSummary

View File

@@ -1,172 +1,116 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { useEnvironment } from "@/lib/environments/environments";
import LinkSurveyShareButton from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/LinkModalButton";
import StatusDropdown from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/StatusDropdown";
import SuccessMessage from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/SuccessMessage";
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { getSurveyResponses } from "@formbricks/lib/services/response";
import { getSurvey } from "@formbricks/lib/services/survey";
import { timeSinceConditionally } from "@formbricks/lib/time";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import {
Button,
Confetti,
ErrorComponent,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import { ShareIcon } from "@heroicons/react/24/outline";
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { PencilSquareIcon, QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import LinkSurveyModal from "./LinkSurveyModal";
import { Session } from "next-auth";
interface SummaryMetadataProps {
session: Session;
surveyId: string;
environmentId: string;
responses: TResponse[];
survey: TSurvey;
}
export default function SummaryMetadata({
surveyId,
environmentId,
responses,
survey,
}: SummaryMetadataProps) {
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
const [confetti, setConfetti] = useState(false);
const [showLinkModal, setShowLinkModal] = useState(false);
const searchParams = useSearchParams();
export default async function SummaryMetadata({ session, surveyId, environmentId }: SummaryMetadataProps) {
const survey = await getSurvey(surveyId);
if (!survey) throw new Error(`Survey not found: ${surveyId}`);
const allResponses = await getSurveyResponses(surveyId);
const limitReached =
IS_FORMBRICKS_CLOUD && session?.user.plan === "free" && allResponses.length >= RESPONSES_LIMIT_FREE;
const responses = limitReached ? allResponses.slice(0, RESPONSES_LIMIT_FREE) : allResponses;
useEffect(() => {
if (environment) {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && environment) {
setConfetti(true);
toast.success(
survey.type === "web" && !environment.widgetSetupCompleted
? "Almost there! Install widget to start receiving responses."
: "Congrats! Your survey is live.",
{
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
duration: 5000,
position: "bottom-right",
}
);
if (survey.type === "link") {
setShowLinkModal(true);
}
}
}
}, [environment, searchParams, survey]);
const completionRate = useMemo(() => {
if (!responses) return 0;
return (responses.filter((r) => r.finished).length / responses.length) * 100;
}, [responses]);
if (isLoadingEnvironment) {
return <LoadingSpinner />;
}
if (isErrorEnvironment) {
return <ErrorComponent />;
}
const completionRate = !responses
? 0
: (responses.filter((r) => r.finished).length / responses.length) * 100;
return (
<div className="mb-4 ">
<div className="flex flex-col-reverse gap-y-2 lg:grid lg:grid-cols-2 lg:gap-x-2">
<div className="grid grid-cols-2 gap-4 md:grid md:grid-cols-4 md:gap-x-2">
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm text-slate-600">Survey displays</p>
<p className="text-2xl font-bold text-slate-800">
{survey.analytics.numDisplays === 0 ? <span>-</span> : survey.analytics.numDisplays}
</p>
<>
<div className="mb-4 ">
<div className="flex flex-col-reverse gap-y-2 lg:grid lg:grid-cols-2 lg:gap-x-2">
<div className="grid grid-cols-2 gap-4 md:grid md:grid-cols-4 md:gap-x-2">
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm text-slate-600">Survey displays</p>
<p className="text-2xl font-bold text-slate-800">
{survey.analytics.numDisplays === 0 ? <span>-</span> : survey.analytics.numDisplays}
</p>
</div>
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm text-slate-600">Total Responses</p>
<p className="text-2xl font-bold text-slate-800">
{responses.length === 0 ? <span>-</span> : responses.length}
</p>
</div>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
<p className="text-sm text-slate-600">
Response %
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
</p>
<p className="text-2xl font-bold text-slate-800">
{survey.analytics.responseRate === null || survey.analytics.responseRate === 0 ? (
<span>-</span>
) : (
<span>{Math.round(survey.analytics.responseRate * 100)} %</span>
)}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p>% of people who responded when survey was shown.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex cursor-default flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
<p className="text-sm text-slate-600">
Completion %
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
</p>
<p className="text-2xl font-bold text-slate-800">
{responses.length === 0 ? (
<span>-</span>
) : (
<span>{parseFloat(completionRate.toFixed(2))} %</span>
)}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
% of people who started <strong>and</strong> completed the survey.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm text-slate-600">Total Responses</p>
<p className="text-2xl font-bold text-slate-800">
{responses.length === 0 ? <span>-</span> : responses.length}
</p>
</div>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
<p className="text-sm text-slate-600">
Response %
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
</p>
<p className="text-2xl font-bold text-slate-800">
{survey.analytics.responseRate === null || survey.analytics.responseRate === 0 ? (
<span>-</span>
) : (
<span>{Math.round(survey.analytics.responseRate * 100)} %</span>
)}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p>% of people who responded when survey was shown.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex cursor-default flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
<p className="text-sm text-slate-600">
Completion %
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
</p>
<p className="text-2xl font-bold text-slate-800">
{responses.length === 0 ? (
<span>-</span>
) : (
<span>{parseFloat(completionRate.toFixed(2))} %</span>
)}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
% of people who started <strong>and</strong> completed the survey.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-col justify-between lg:col-span-1">
<div className="text-right text-xs text-slate-400">
Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())}
</div>
<div className="flex justify-end gap-x-1.5">
{survey.type === "link" && (
<Button
variant="secondary"
className="h-full border border-slate-300 bg-white px-2 hover:bg-slate-100 focus:bg-slate-100 lg:px-6"
onClick={() => setShowLinkModal(true)}>
<ShareIcon className="h-5 w-5" />
</Button>
)}
<div className="flex flex-col justify-between lg:col-span-1">
<div className="text-right text-xs text-slate-400">
Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())}
</div>
<div className="flex justify-end gap-x-1.5">
{survey.type === "link" && <LinkSurveyShareButton survey={survey} />}
{environment.widgetSetupCompleted || survey.type === "link" ? (
<SurveyStatusDropdown surveyId={surveyId} environmentId={environmentId} />
) : null}
<Button
variant="darkCTA"
className="h-full w-full px-3 lg:px-6"
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
<PencilSquareIcon className="mr-2 h-5 w-5 text-white" />
Edit
</Button>
<StatusDropdown survey={survey} environmentId={environmentId} />
<Button
variant="darkCTA"
className="h-full w-full px-3 lg:px-6"
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
<PencilSquareIcon className="mr-2 h-5 w-5 text-white" />
Edit
</Button>
</div>
</div>
</div>
</div>
{showLinkModal && <LinkSurveyModal survey={survey} open={showLinkModal} setOpen={setShowLinkModal} />}
{confetti && <Confetti />}
</div>
<SuccessMessage environmentId={environmentId} survey={survey} />
</>
);
}

View File

@@ -1,5 +1,6 @@
export const revalidate = 0;
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
import ContentWrapper from "@/components/shared/ContentWrapper";
import { getServerSession } from "next-auth";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
@@ -12,24 +13,21 @@ export default async function SummaryPage({ params }) {
if (!session) {
throw new Error("Unauthorized");
}
const { responses, responsesCount, limitReached, survey } = await getAnalysisData(session, params.surveyId);
return (
<>
<SurveyResultsTabs activeId="summary" environmentId={params.environmentId} surveyId={params.surveyId} />
{/* @ts-expect-error Server Component */}
<ResponsesLimitReachedBanner
environmentId={params.environmentId}
limitReached={limitReached}
responsesCount={responsesCount}
session={session}
surveyId={params.surveyId}
/>
<ContentWrapper>
<SummaryMetadata
surveyId={params.surveyId}
environmentId={params.environmentId}
responses={responses}
survey={survey}
/>
<SummaryList environmentId={params.environmentId} survey={survey} responses={responses} />
{/* @ts-expect-error Server Component */}
<SummaryMetadata surveyId={params.surveyId} environmentId={params.environmentId} session={session} />
{/* @ts-expect-error Server Component */}
<SummaryList environmentId={params.environmentId} session={session} surveyId={params.surveyId} />
</ContentWrapper>
</>
);

View File

@@ -13,11 +13,12 @@
},
"dependencies": {
"@formbricks/database": "*",
"@formbricks/types": "*",
"@formbricks/errors": "*",
"@formbricks/types": "*",
"date-fns": "^2.30.0",
"markdown-it": "^13.0.1",
"posthog-node": "^3.1.1",
"server-only": "^0.0.1",
"tailwind-merge": "^1.12.0"
},
"devDependencies": {

View File

@@ -1,9 +1,11 @@
import { prisma } from "@formbricks/database";
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { getPerson, TransformPersonOutput, transformPrismaPerson } from "./person";
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses";
import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import "server-only";
import { TransformPersonOutput, getPerson, transformPrismaPerson } from "./person";
import { cache } from "react";
const responseSelection = {
id: true,
@@ -133,7 +135,11 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
}
};
export const getSurveyResponses = async (surveyId: string): Promise<TResponse[]> => {
export const preloadSurveyResponses = (surveyId: string) => {
void getSurveyResponses(surveyId);
};
export const getSurveyResponses = cache(async (surveyId: string): Promise<TResponse[]> => {
try {
const responsesPrisma = await prisma.response.findMany({
where: {
@@ -161,7 +167,7 @@ export const getSurveyResponses = async (surveyId: string): Promise<TResponse[]>
throw error;
}
};
});
export const updateResponse = async (
responseId: string,

View File

@@ -4,8 +4,14 @@ import { ValidationError } from "@formbricks/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { TSurvey, ZSurvey } from "@formbricks/types/v1/surveys";
import { Prisma } from "@prisma/client";
import "server-only";
import { cache } from "react";
export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
export const preloadSurvey = (surveyId: string) => {
void getSurvey(surveyId);
};
export const getSurvey = cache(async (surveyId: string): Promise<TSurvey | null> => {
let surveyPrisma;
try {
surveyPrisma = await prisma.survey.findUnique({
@@ -96,4 +102,4 @@ export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
}
throw new ValidationError("Data validation of survey failed");
}
};
});

11
pnpm-lock.yaml generated
View File

@@ -576,6 +576,9 @@ importers:
posthog-node:
specifier: ^3.1.1
version: 3.1.1
server-only:
specifier: ^0.0.1
version: 0.0.1
tailwind-merge:
specifier: ^1.12.0
version: 1.12.0
@@ -19111,6 +19114,10 @@ packages:
- supports-color
dev: true
/server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
dev: false
/set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -22134,7 +22141,3 @@ packages:
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false