feat: added survey response sharing feature (#1469)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Anjy Gupta
2024-01-03 21:03:52 +05:30
committed by GitHub
parent 0d74921233
commit e05cfaba5f
30 changed files with 1603 additions and 62 deletions

View File

@@ -2,12 +2,14 @@
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { customAlphabet } from "nanoid";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
type TSendEmailActionArgs = {
to: string;
@@ -35,6 +37,58 @@ export const sendEmailAction = async ({ html, subject, to }: TSendEmailActionArg
return await sendEmbedSurveyPreviewEmail(to, subject, html);
};
export async function generateResultShareUrlAction(surveyId: string): Promise<string> {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
const survey = await getSurvey(surveyId);
if (!survey?.id) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
await updateSurvey({ ...survey, resultShareKey });
return resultShareKey;
}
export async function getResultShareUrlAction(surveyId: string): Promise<string | null> {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
const survey = await getSurvey(surveyId);
if (!survey?.id) {
throw new ResourceNotFoundError("Survey", surveyId);
}
return survey.resultShareKey;
}
export async function deleteResultShareUrlAction(surveyId: string): Promise<void> {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
const survey = await getSurvey(surveyId);
if (!survey?.id) {
throw new ResourceNotFoundError("Survey", surveyId);
}
await updateSurvey({ ...survey, resultShareKey: null });
}
export const getEmailHtmlAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");

View File

@@ -1,17 +1,30 @@
"use client";
import { ShareIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import {
deleteResultShareUrlAction,
generateResultShareUrlAction,
getResultShareUrlAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { LinkIcon } from "@heroicons/react/24/outline";
import { DownloadIcon } from "lucide-react";
import { useState } from "react";
import { useEffect } from "react";
import toast from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import { TUser } from "@formbricks/types/user";
import { Button } from "@formbricks/ui/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
import ShareEmbedSurvey from "./ShareEmbedSurvey";
import ShareSurveyResults from "./ShareSurveyResults";
interface LinkSurveyShareButtonProps {
interface SurveyShareButtonProps {
survey: TSurvey;
className?: string;
webAppUrl: string;
@@ -19,28 +32,83 @@ interface LinkSurveyShareButtonProps {
user: TUser;
}
export default function LinkSurveyShareButton({
survey,
className,
webAppUrl,
product,
user,
}: LinkSurveyShareButtonProps) {
export default function SurveyShareButton({ survey, webAppUrl, product, user }: SurveyShareButtonProps) {
const [showLinkModal, setShowLinkModal] = useState(false);
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
const [showPublishModal, setShowPublishModal] = useState(false);
const [surveyUrl, setSurveyUrl] = useState("");
const handlePublish = async () => {
const key = await generateResultShareUrlAction(survey.id);
setSurveyUrl(webAppUrl + "/share/" + key);
setShowPublishModal(true);
};
const handleUnpublish = () => {
deleteResultShareUrlAction(survey.id)
.then(() => {
toast.success("Survey Unpublished successfully");
setShowPublishModal(false);
setShowLinkModal(false);
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
};
useEffect(() => {
async function fetchSharingKey() {
const sharingKey = await getResultShareUrlAction(survey.id);
if (sharingKey) {
setSurveyUrl(webAppUrl + "/share/" + sharingKey);
setShowPublishModal(true);
}
}
fetchSharingKey();
}, [survey.id, webAppUrl]);
useEffect(() => {
if (showResultsLinkModal) {
setShowLinkModal(false);
}
}, [showResultsLinkModal]);
return (
<>
<Button
variant="secondary"
className={clsx(
"border border-slate-300 bg-white px-2 hover:bg-slate-100 focus:bg-slate-100 lg:px-6",
className
)}
onClick={() => {
setShowLinkModal(true);
}}>
<ShareIcon className="h-5 w-5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
asChild
className="focus:bg-muted cursor-pointer border border-slate-300 outline-none">
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700"> Share</span>
<LinkIcon className="h-4 w-4" />
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{survey.type === "link" && (
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setShowLinkModal(true);
}}>
<p className="text-slate-700">Share Survey</p>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setShowResultsLinkModal(true);
}}>
<p className="text-slate-700">Publish Results</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{showLinkModal && (
<ShareEmbedSurvey
survey={survey}
@@ -51,6 +119,16 @@ export default function LinkSurveyShareButton({
user={user}
/>
)}
{showResultsLinkModal && (
<ShareSurveyResults
open={showResultsLinkModal}
setOpen={setShowResultsLinkModal}
surveyUrl={surveyUrl}
handlePublish={handlePublish}
handleUnpublish={handleUnpublish}
showPublishModal={showPublishModal}
/>
)}
</>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { CheckCircleIcon, GlobeEuropeAfricaIcon } from "@heroicons/react/24/solid";
import { Clipboard } from "lucide-react";
import { toast } from "react-hot-toast";
import { Button } from "@formbricks/ui/Button";
import { Dialog, DialogContent } from "@formbricks/ui/Dialog";
interface ShareEmbedSurveyProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
handlePublish: () => void;
handleUnpublish: () => void;
showPublishModal: boolean;
surveyUrl: string;
}
export default function ShareSurveyResults({
open,
setOpen,
handlePublish,
handleUnpublish,
showPublishModal,
surveyUrl,
}: ShareEmbedSurveyProps) {
return (
<Dialog
open={open}
onOpenChange={(open) => {
setOpen(open);
}}>
{showPublishModal && surveyUrl ? (
<DialogContent className="bottom-0 flex h-[95%] w-full cursor-pointer flex-col gap-0 overflow-hidden rounded-2xl bg-white p-0 sm:max-w-none lg:bottom-auto lg:h-auto lg:w-[40%]">
<div className="no-scrollbar mt-4 flex grow flex-col items-center justify-center overflow-x-hidden overflow-y-scroll">
<CheckCircleIcon className="mt-4 h-20 w-20 text-slate-300" />
<div className="mt-6 px-4 py-3 text-lg font-medium text-slate-600 lg:px-6 lg:py-3">
Your survey results are public on the web.
</div>
<div className="text-md px-4 py-3 text-slate-500 lg:px-6 lg:py-0">
Your survey results are shared with anyone who has the link.
</div>
<div className="text-md mb-6 px-4 py-3 text-slate-500 lg:px-6 lg:py-0">
The results will not be indexed by search engines.
</div>
<div className="flex gap-2">
<div className="relative grow overflow-auto rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span
style={{
wordBreak: "break-all",
}}>
{surveyUrl}
</span>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success("URL copied to clipboard!");
}}>
<Clipboard />
</Button>
</div>
<div className="my-6 flex gap-2">
<Button
type="submit"
variant="secondary"
className=" text-center"
onClick={() => handleUnpublish()}>
Unpublish
</Button>
<Button variant="darkCTA" className=" text-center" href={surveyUrl} target="_blank">
View Site
</Button>
</div>
</div>
</DialogContent>
) : (
<DialogContent className="bottom-0 flex h-[95%] w-full flex-col gap-0 overflow-hidden rounded-2xl bg-white p-0 sm:max-w-none lg:bottom-auto lg:h-auto lg:w-[40%]">
<div className="no-scrollbar mt-4 flex grow flex-col items-center justify-center overflow-x-hidden overflow-y-scroll">
<GlobeEuropeAfricaIcon className="mt-4 h-20 w-20 text-slate-300" />
<div className=" mt-6 px-4 py-3 text-lg font-medium text-slate-600 lg:px-6 lg:py-3">
Publish Results to web
</div>
<div className="text-md px-4 py-3 text-slate-500 lg:px-6 lg:py-0">
Your survey results are shared with anyone who has the link.
</div>
<div className=" text-md px-4 py-3 text-slate-500 lg:px-6 lg:py-0">
The results will not be indexed by search engines.
</div>
<Button
type="submit"
variant="darkCTA"
className="my-8 h-full text-center"
onClick={() => handlePublish()}>
Publish to web
</Button>
</div>
</DialogContent>
)}
</Dialog>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import LinkSurveyShareButton from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton";
import SurveyShareButton from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton";
import SuccessMessage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import SurveyStatusDropdown from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
@@ -14,6 +14,7 @@ import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import { TUser } from "@formbricks/types/user";
import { Badge } from "@formbricks/ui/Badge";
import { Button } from "@formbricks/ui/Button";
import {
DropdownMenu,
@@ -53,16 +54,18 @@ const SummaryHeader = ({
const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null;
const isStatusChangeDisabled = (isCloseOnDateEnabled && closeOnDate && closeOnDate < new Date()) ?? false;
const { isViewer } = getAccessFlags(membershipRole);
return (
<div className="mb-11 mt-6 flex flex-wrap items-center justify-between">
<div>
<p className="text-3xl font-bold text-slate-800">{survey.name}</p>
<div className="flex gap-4">
<p className="text-3xl font-bold text-slate-800">{survey.name}</p>
{survey.resultShareKey && <Badge text="Public Results" type="success" size="normal"></Badge>}
</div>
<span className="text-base font-extralight text-slate-600">{product.name}</span>
</div>
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.type === "link" && (
<LinkSurveyShareButton survey={survey} webAppUrl={webAppUrl} product={product} user={user} />
)}
<SurveyShareButton survey={survey} webAppUrl={webAppUrl} product={product} user={user} />
{!isViewer &&
(environment?.widgetSetupCompleted || survey.type === "link") &&
survey?.status !== "draft" ? (
@@ -88,7 +91,7 @@ const SummaryHeader = ({
<DropdownMenuContent align="end" className="p-2">
{survey.type === "link" && (
<>
<LinkSurveyShareButton
<SurveyShareButton
className="flex w-full justify-center p-1"
survey={survey}
webAppUrl={webAppUrl}

View File

@@ -2528,4 +2528,5 @@ export const minimalSurvey: TSurvey = {
productOverwrites: null,
singleUse: null,
styling: null,
resultShareKey: null,
};

View File

@@ -1,6 +1,6 @@
import FormbricksClient from "@/app/(app)/components/FormbricksClient";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
// import { redirect } from "next/navigation";
import { Suspense } from "react";
import { authOptions } from "@formbricks/lib/authOptions";
@@ -11,9 +11,9 @@ import PosthogIdentify from "./components/PosthogIdentify";
export default async function AppLayout({ children }) {
const session = await getServerSession(authOptions);
if (!session) {
return redirect(`/auth/login`);
}
// if (!session) {
// return redirect(`/auth/login`);
// }
return (
<>
@@ -23,8 +23,13 @@ export default async function AppLayout({ children }) {
</Suspense>
<PHProvider>
<>
<PosthogIdentify session={session} />
<FormbricksClient session={session} />
{session ? (
<>
<PosthogIdentify session={session} />
<FormbricksClient session={session} />
</>
) : null}
{children}
</>
</PHProvider>

View File

@@ -0,0 +1,60 @@
import { cn } from "@formbricks/lib/cn";
import { PresentationChartLineIcon, InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import revalidateSurveyIdPath from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
interface SurveyResultsTabProps {
activeId: string;
environmentId: string;
surveyId: string;
sharingKey: string;
}
export default function SurveyResultsTab({
activeId,
environmentId,
surveyId,
sharingKey,
}: SurveyResultsTabProps) {
const tabs = [
{
id: "summary",
label: "Summary",
icon: <PresentationChartLineIcon />,
href: `/share/${sharingKey}/summary?referer=true`,
},
{
id: "responses",
label: "Responses",
icon: <InboxStackIcon />,
href: `/share/${sharingKey}/responses?referer=true`,
},
];
return (
<div>
<div className="mb-7 h-14 w-full border-b">
<nav className="flex h-full items-center space-x-4 justify-self-center" aria-label="Tabs">
{tabs.map((tab) => (
<Link
key={tab.id}
onClick={() => {
revalidateSurveyIdPath(environmentId, surveyId);
}}
href={tab.href}
className={cn(
tab.id === activeId
? " border-brand-dark text-brand-dark border-b-2 font-semibold"
: "text-slate-500 hover:text-slate-700",
"flex h-full items-center px-3 text-sm font-medium"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
{tab.icon && <div className="mr-2 h-5 w-5">{tab.icon}</div>}
{tab.label}
</Link>
))}
</nav>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use server";
import { createTag } from "@formbricks/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/errors";
import { canUserAccessTagOnResponse } from "@formbricks/lib/tagOnResponse/auth";
export const createTagAction = async (environmentId: string, tagName: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await createTag(environmentId, tagName);
};
export const createTagToResponeAction = async (responseId: string, tagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await addTagToRespone(responseId, tagId);
};
export const deleteTagOnResponseAction = async (responseId: string, tagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await deleteTagOnResponse(responseId, tagId);
};

View File

@@ -0,0 +1,119 @@
"use client";
import { PlusIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { cn } from "@formbricks/lib/cn";
import { timeSince } from "@formbricks/lib/time";
import { TResponseNote } from "@formbricks/types/responses";
interface ResponseNotesProps {
responseId: string;
notes: TResponseNote[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ResponseNotes({ notes, isOpen, setIsOpen }: ResponseNotesProps) {
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (divRef.current) {
divRef.current.scrollTop = divRef.current.scrollHeight;
}
}, [notes]);
const unresolvedNotes = useMemo(() => notes.filter((note) => !note.isResolved), [notes]);
return (
<div
className={clsx(
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
isOpen
? "-right-5 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
: unresolvedNotes.length
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
)}
onClick={() => {
if (!isOpen) setIsOpen(true);
}}>
{!isOpen ? (
<div className="flex h-full flex-col">
<div
className={clsx(
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
)}>
{!unresolvedNotes.length ? (
<div className="flex items-center justify-end">
<div className="group flex items-center">
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">Note</h3>
</div>
</div>
) : (
<div className="float-left mr-1.5">
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
</div>
)}
</div>
{!unresolvedNotes.length ? (
<div className="flex flex-1 items-center justify-end pr-3">
<span>
<PlusIcon className=" h-5 w-5 text-slate-400" />
</span>
</div>
) : null}
</div>
) : (
<div className="relative flex h-full flex-col">
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
<div className="flex items-center justify-between">
<div className="group flex items-center">
<h3 className="pb-1 text-sm text-amber-500">Note</h3>
</div>
<button
className="h-6 w-6 cursor-pointer"
onClick={() => {
setIsOpen(!isOpen);
}}>
<Minimize2Icon className="h-5 w-5 text-amber-500 hover:text-amber-600" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto px-4 pt-2" ref={divRef}>
{unresolvedNotes.map((note) => (
<div className="group/notetext mb-3" key={note.id}>
<span className="block font-semibold text-slate-700">
{note.user.name}
<time
className="ml-2 text-xs font-normal text-slate-500"
dateTime={timeSince(note.updatedAt.toISOString())}>
{timeSince(note.updatedAt.toISOString())}
</time>
{note.isEdited && (
<span className="ml-1 text-[12px] font-normal text-slate-500">{"(edited)"}</span>
)}
</span>
<div className="flex items-center">
<span className="block text-slate-700">{note.text}</span>
</div>
</div>
))}
</div>
<div className={cn("h-[120px] transition-all duration-300")}>
<div
className={clsx(
"absolute bottom-0 w-full px-3 pb-3",
!unresolvedNotes.length && "absolute bottom-0"
)}></div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import SurveyResultsTabs from "@/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs";
import ResponseTimeline from "@/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline";
import SummaryHeader from "@/app/(app)/share/[sharingKey]/components/SummaryHeader";
import { getFilterResponses } from "@/app/lib/surveys/surveys";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
interface ResponsePageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
responses: TResponse[];
webAppUrl: string;
product: TProduct;
sharingKey: string;
environmentTags: TTag[];
responsesPerPage: number;
}
const ResponsePage = ({
environment,
survey,
surveyId,
responses,
product,
sharingKey,
environmentTags,
responsesPerPage,
}: ResponsePageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const searchParams = useSearchParams();
useEffect(() => {
if (!searchParams?.get("referer")) {
resetState();
}
}, [searchParams]);
// get the filtered array when the selected filter value changes
const filterResponses: TResponse[] = useMemo(() => {
return getFilterResponses(responses, selectedFilter, survey, dateRange);
}, [selectedFilter, responses, survey, dateRange]);
return (
<ContentWrapper>
<SummaryHeader survey={survey} product={product} />
<CustomFilter
environmentTags={environmentTags}
responses={filterResponses}
survey={survey}
totalResponses={responses}
/>
<SurveyResultsTabs
activeId="responses"
environmentId={environment.id}
surveyId={surveyId}
sharingKey={sharingKey}
/>
<ResponseTimeline
environment={environment}
surveyId={surveyId}
responses={filterResponses}
survey={survey}
environmentTags={environmentTags}
responsesPerPage={responsesPerPage}
/>
</ContentWrapper>
);
};
export default ResponsePage;

View File

@@ -0,0 +1,91 @@
"use client";
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { useEffect, useRef, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import SingleResponseCard from "@formbricks/ui/SingleResponseCard";
interface ResponseTimelineProps {
environment: TEnvironment;
surveyId: string;
responses: TResponse[];
survey: TSurvey;
environmentTags: TTag[];
responsesPerPage: number;
}
export default function ResponseTimeline({
environment,
responses,
survey,
environmentTags,
responsesPerPage,
}: ResponseTimelineProps) {
const [displayedResponses, setDisplayedResponses] = useState<TResponse[]>([]);
const loadingRef = useRef(null);
useEffect(() => {
setDisplayedResponses(responses.slice(0, responsesPerPage));
}, [responses]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setDisplayedResponses((prevResponses) => [
...prevResponses,
...responses.slice(prevResponses.length, prevResponses.length + responsesPerPage),
]);
}
},
{ threshold: 0.8 }
);
if (loadingRef.current) {
observer.observe(loadingRef.current);
}
return () => {
if (loadingRef.current) {
observer.unobserve(loadingRef.current);
}
};
}, [responses]);
return (
<div className="space-y-4">
{survey.type === "web" && displayedResponses.length === 0 && !environment.widgetSetupCompleted ? (
<EmptyInAppSurveys environment={environment} />
) : displayedResponses.length === 0 ? (
<EmptySpaceFiller
type="response"
environment={environment}
noWidgetRequired={survey.type === "link"}
/>
) : (
<div>
{displayedResponses.map((response) => {
return (
<div key={response.id}>
<SingleResponseCard
survey={survey}
response={response}
environmentTags={environmentTags}
pageType="response"
environment={environment}
user={undefined}
/>
</div>
);
})}
<div ref={loadingRef}></div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
import ResponsePage from "@/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponsePage";
import { getResultShareUrlSurveyAction } from "@/app/(app)/share/[sharingKey]/action";
import { notFound } from "next/navigation";
import { RESPONSES_PER_PAGE, REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
export const revalidate = REVALIDATION_INTERVAL;
export default async function Page({ params }) {
const surveyId = await getResultShareUrlSurveyAction(params.sharingKey);
if (!surveyId) {
return notFound();
}
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
}
const [{ responses }, environment] = await Promise.all([
getAnalysisData(survey.id, survey.environmentId),
getEnvironment(survey.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
}
const tags = await getTagsByEnvironmentId(environment.id);
return (
<>
<ResponsePage
environment={environment}
responses={responses}
survey={survey}
surveyId={params.surveyId}
webAppUrl={WEBAPP_URL}
product={product}
sharingKey={params.sharingKey}
environmentTags={tags}
responsesPerPage={RESPONSES_PER_PAGE}
/>
</>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
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 SurveyResultsTabs from "@/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs";
import SummaryHeader from "@/app/(app)/share/[sharingKey]/components/SummaryHeader";
import { getFilterResponses } from "@/app/lib/surveys/surveys";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
interface SummaryPageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
responses: TResponse[];
product: TProduct;
sharingKey: string;
environmentTags: TTag[];
displayCount: number;
responsesPerPage: number;
}
const SummaryPage = ({
environment,
survey,
surveyId,
responses,
product,
sharingKey,
environmentTags,
displayCount,
responsesPerPage: openTextResponsesPerPage,
}: SummaryPageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
const searchParams = useSearchParams();
useEffect(() => {
if (!searchParams?.get("referer")) {
resetState();
}
}, [searchParams]);
// get the filtered array when the selected filter value changes
const filterResponses: TResponse[] = useMemo(() => {
return getFilterResponses(responses, selectedFilter, survey, dateRange);
}, [selectedFilter, responses, survey, dateRange]);
return (
<ContentWrapper>
<SummaryHeader survey={survey} product={product} />
<CustomFilter
environmentTags={environmentTags}
responses={filterResponses}
survey={survey}
totalResponses={responses}
/>
<SurveyResultsTabs
activeId="summary"
environmentId={environment.id}
surveyId={surveyId}
sharingKey={sharingKey}
/>
<SummaryMetadata
responses={filterResponses}
survey={survey}
displayCount={displayCount}
showDropOffs={showDropOffs}
setShowDropOffs={setShowDropOffs}
/>
{showDropOffs && <SummaryDropOffs survey={survey} responses={responses} displayCount={displayCount} />}
<SummaryList
responses={filterResponses}
survey={survey}
environment={environment}
responsesPerPage={openTextResponsesPerPage}
/>
</ContentWrapper>
);
};
export default SummaryPage;

View File

@@ -0,0 +1,58 @@
import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
import SummaryPage from "@/app/(app)/share/[sharingKey]/(analysis)/summary/components/SummaryPage";
import { getResultShareUrlSurveyAction } from "@/app/(app)/share/[sharingKey]/action";
import { notFound } from "next/navigation";
import { REVALIDATION_INTERVAL, TEXT_RESPONSES_PER_PAGE } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
export const revalidate = REVALIDATION_INTERVAL;
export default async function Page({ params }) {
const surveyId = await getResultShareUrlSurveyAction(params.sharingKey);
if (!surveyId) {
return notFound();
}
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
}
const [{ responses, displayCount }, environment] = await Promise.all([
getAnalysisData(survey.id, survey.environmentId),
getEnvironment(survey.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
}
const tags = await getTagsByEnvironmentId(environment.id);
return (
<>
<SummaryPage
environment={environment}
responses={responses}
survey={survey}
sharingKey={params.sharingKey}
surveyId={survey.id}
product={product}
environmentTags={tags}
displayCount={displayCount}
responsesPerPage={TEXT_RESPONSES_PER_PAGE}
/>
</>
);
}

View File

@@ -0,0 +1,7 @@
"use server";
import { getSurveyByResultShareKey } from "@formbricks/lib/survey/service";
export async function getResultShareUrlSurveyAction(key: string): Promise<string | null> {
return getSurveyByResultShareKey(key);
}

View File

@@ -0,0 +1,472 @@
"use client";
import {
DateRange,
useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
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 { getTodaysDateFormatted } from "@formbricks/lib/time";
import useClickOutside from "@formbricks/lib/useClickOutside";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { Calendar } from "@formbricks/ui/Calendar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
enum DateSelected {
FROM = "from",
TO = "to",
}
enum FilterDownload {
ALL = "all",
FILTER = "filter",
}
enum FilterDropDownLabels {
ALL_TIME = "All time",
LAST_7_DAYS = "Last 7 days",
LAST_30_DAYS = "Last 30 days",
CUSTOM_RANGE = "Custom range...",
}
interface CustomFilterProps {
environmentTags: TTag[];
survey: TSurvey;
responses: TResponse[];
totalResponses: TResponse[];
}
const getDifferenceOfDays = (from, to) => {
const days = differenceInDays(to, from);
if (days === 7) {
return FilterDropDownLabels.LAST_7_DAYS;
} else if (days === 30) {
return FilterDropDownLabels.LAST_30_DAYS;
} else {
return FilterDropDownLabels.CUSTOM_RANGE;
}
};
const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: CustomFilterProps) => {
const { setSelectedOptions, dateRange, setDateRange } = useResponseFilter();
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
dateRange.from && dateRange.to
? getDifferenceOfDays(dateRange.from, dateRange.to)
: FilterDropDownLabels.ALL_TIME
);
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
// when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options
useEffect(() => {
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
survey,
totalResponses,
environmentTags
);
setSelectedOptions({ questionFilterOptions, questionOptions });
}, [totalResponses, survey, setSelectedOptions, environmentTags]);
const datePickerRef = useRef<HTMLDivElement>(null);
const getMatchQandA = (responses: TResponse[], survey: TSurvey) => {
if (survey && responses) {
// Create a mapping of question IDs to their headlines
const questionIdToHeadline = {};
survey.questions.forEach((question) => {
questionIdToHeadline[question.id] = question.headline;
});
// Replace question IDs with question headlines in response data
const updatedResponses = responses.map((response) => {
const updatedResponse: Array<{
id: string;
question: string;
answer: string;
type: string;
scale?: "number" | "star" | "smiley";
range?: number;
}> = []; // Specify the type of updatedData
// iterate over survey questions and build the updated response
for (const question of survey.questions) {
const answer = response.data[question.id];
if (answer) {
updatedResponse.push({
id: createId(),
question: question.headline,
type: question.type,
scale: question.scale,
range: question.range,
answer: answer as string,
});
}
}
return { ...response, responses: updatedResponse };
});
const updatedResponsesWithTags = updatedResponses.map((response) => ({
...response,
tags: response.tags?.map((tag) => tag),
}));
return updatedResponsesWithTags;
}
return [];
};
const downloadFileName = useMemo(() => {
if (survey) {
const formattedDateString = getTodaysDateFormatted("_");
return `${survey.name.split(" ").join("_")}_responses_${formattedDateString}`.toLocaleLowerCase();
}
return "my_survey_responses";
}, [survey]);
function extracMetadataKeys(obj, parentKey = "") {
let keys: string[] = [];
for (let key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
keys = keys.concat(extracMetadataKeys(obj[key], parentKey + key + " - "));
} else {
keys.push(parentKey + key);
}
}
return keys;
}
const downloadResponses = useCallback(
async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
const downloadResponse = filter === FilterDownload.ALL ? totalResponses : 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,
"Survey ID": response.surveyId,
"Formbricks User ID": response.person?.id ?? "",
};
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",
"Formbricks 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, responses, totalResponses, survey]
);
const handleDateHoveredChange = (date: Date) => {
if (selectingDate === DateSelected.FROM) {
const startOfRange = new Date(date);
startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day
// Check if the selected date is after the current 'to' date
if (startOfRange > dateRange?.to!) {
return;
} else {
setHoveredRange({ from: startOfRange, to: dateRange.to });
}
} else {
const endOfRange = new Date(date);
endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day
// Check if the selected date is before the current 'from' date
if (endOfRange < dateRange?.from!) {
return;
} else {
setHoveredRange({ from: dateRange.from, to: endOfRange });
}
}
};
const handleDateChange = (date: Date) => {
if (selectingDate === DateSelected.FROM) {
const startOfRange = new Date(date);
startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day
// Check if the selected date is after the current 'to' date
if (startOfRange > dateRange?.to!) {
const nextDay = new Date(startOfRange);
nextDay.setDate(nextDay.getDate() + 1);
nextDay.setHours(23, 59, 59, 999);
setDateRange({ from: startOfRange, to: nextDay });
} else {
setDateRange((prevData) => ({ from: startOfRange, to: prevData.to }));
}
setSelectingDate(DateSelected.TO);
} else {
const endOfRange = new Date(date);
endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day
// Check if the selected date is before the current 'from' date
if (endOfRange < dateRange?.from!) {
const previousDay = new Date(endOfRange);
previousDay.setDate(previousDay.getDate() - 1);
previousDay.setHours(0, 0, 0, 0); // Set to the start of the selected day
setDateRange({ from: previousDay, to: endOfRange });
} else {
setDateRange((prevData) => ({ from: prevData?.from, to: endOfRange }));
}
setIsDatePickerOpen(false);
setSelectingDate(DateSelected.FROM);
}
};
const handleDatePickerClose = () => {
setIsDatePickerOpen(false);
setSelectingDate(DateSelected.FROM);
};
useClickOutside(datePickerRef, () => handleDatePickerClose());
return (
<>
<div className="relative mb-12 flex justify-between">
<div className="flex justify-stretch gap-x-1.5">
<ResponseFilter />
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsFilterDropDownOpen(value);
}}>
<DropdownMenuTrigger>
<div className="flex h-auto min-w-[8rem] items-center justify-between rounded-md border bg-white p-3 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span className="text-sm text-slate-700">
{filterRange === FilterDropDownLabels.CUSTOM_RANGE
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
}`
: filterRange}
</span>
{isFilterDropDownOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setFilterRange(FilterDropDownLabels.ALL_TIME);
setDateRange({ from: undefined, to: getTodayDate() });
}}>
<p className="text-slate-700">All time</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setFilterRange(FilterDropDownLabels.LAST_7_DAYS);
setDateRange({ from: subDays(new Date(), 7), to: getTodayDate() });
}}>
<p className="text-slate-700">Last 7 days</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setFilterRange(FilterDropDownLabels.LAST_30_DAYS);
setDateRange({ from: subDays(new Date(), 30), to: getTodayDate() });
}}>
<p className="text-slate-700">Last 30 days</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setIsDatePickerOpen(true);
setFilterRange(FilterDropDownLabels.CUSTOM_RANGE);
setSelectingDate(DateSelected.FROM);
}}>
<p className="text-sm text-slate-700 hover:ring-0">Custom range...</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsDownloadDropDownOpen(value);
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[11rem] sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">Download</span>
{isDownloadDropDownOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">All responses (CSV)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">All responses (Excel)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">Current selection (CSV)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">Current selection (Excel)</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isDatePickerOpen && (
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={hoveredRange ? hoveredRange : dateRange}
numberOfMonths={2}
onDayClick={(date) => handleDateChange(date)}
onDayMouseEnter={handleDateHoveredChange}
onDayMouseLeave={() => setHoveredRange(null)}
classNames={{
day_today: "hover:bg-slate-200 bg-white",
}}
/>
</div>
)}
</div>
</>
);
};
export default CustomFilter;

View File

@@ -0,0 +1,21 @@
"use client";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
interface SummaryHeaderProps {
survey: TSurvey;
product: TProduct;
}
const SummaryHeader = ({ survey, product }: SummaryHeaderProps) => {
return (
<div className="mb-11 mt-6 flex flex-wrap items-center justify-between">
<div>
<p className="text-3xl font-bold text-slate-800">{survey.name}</p>
<span className="text-base font-extralight text-slate-600">{product.name}</span>
</div>
</div>
);
};
export default SummaryHeader;

View File

@@ -0,0 +1,14 @@
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { Metadata } from "next";
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default async function EnvironmentLayout({ children }) {
return (
<div className="flex-1">
<ResponseFilterProvider>{children}</ResponseFilterProvider>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import Link from "next/link";
import { Button } from "@formbricks/ui/Button";
export default function NotFound() {
return (
<>
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">Page not found</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
Sorry, we couldnt find the responses sharing ID youre looking for.
</p>
<Link href={"/"}>
<Button className="mt-8">Back to home</Button>
</Link>
</div>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function EnvironmentPage({ params }) {
return redirect(`/share/${params.sharingKey}/summary`);
}

View File

@@ -148,6 +148,76 @@ export const generateQuestionAndFilterOptions = (
return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] };
};
export const generateQuestionAndFilterOptionsForResponseSharing = (
survey: TSurvey,
responses: TResponse[]
): {
questionOptions: QuestionOptions[];
questionFilterOptions: QuestionFilterOptions[];
} => {
let questionOptions: any = [];
let questionFilterOptions: any = [];
let questionsOptions: any = [];
survey.questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
questionsOptions.push({
label: q.headline,
questionType: q.type,
type: OptionsType.QUESTIONS,
id: q.id,
});
}
});
questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }];
survey.questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
if (
q.type === TSurveyQuestionType.MultipleChoiceMulti ||
q.type === TSurveyQuestionType.MultipleChoiceSingle
) {
questionFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""],
id: q.id,
});
} else {
questionFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: filterOptions[q.type],
id: q.id,
});
}
}
});
const attributes = getPersonAttributes(responses);
if (attributes) {
questionOptions = [
...questionOptions,
{
header: OptionsType.ATTRIBUTES,
option: Object.keys(attributes).map((a) => {
return { label: a, type: OptionsType.ATTRIBUTES, id: a };
}),
},
];
Object.keys(attributes).forEach((a) => {
questionFilterOptions.push({
type: "Attributes",
filterOptions: conditionOptions.userAttributes,
filterComboBoxOptions: attributes[a],
id: a,
});
});
}
return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] };
};
// get the filtered responses
export const getFilterResponses = (
responses: TResponse[],

View File

@@ -1,6 +1,11 @@
import rateLimit from "@/app/middleware/rateLimit";
import { CLIENT_SIDE_API_RATE_LIMIT, LOGIN_RATE_LIMIT, SIGNUP_RATE_LIMIT } from "@formbricks/lib/constants";
import {
CLIENT_SIDE_API_RATE_LIMIT,
LOGIN_RATE_LIMIT,
SHARE_RATE_LIMIT,
SIGNUP_RATE_LIMIT,
} from "@formbricks/lib/constants";
export const signUpLimiter = rateLimit({
interval: SIGNUP_RATE_LIMIT.interval,
@@ -14,3 +19,8 @@ export const clientSideApiEndpointsLimiter = rateLimit({
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
export const shareUrlLimiter = rateLimit({
interval: SHARE_RATE_LIMIT.interval,
allowedPerInterval: SHARE_RATE_LIMIT.allowedPerInterval,
});

View File

@@ -8,3 +8,8 @@ export const clientSideApiRoute = (url: string): boolean => {
const regex = /^\/api\/v\d+\/client\//;
return regex.test(url);
};
export const shareUrlRoute = (url: string): boolean => {
const regex = /\/share\/[A-Za-z0-9]+\/(summary|responses)/;
return regex.test(url);
};

View File

@@ -1,5 +1,15 @@
import { clientSideApiEndpointsLimiter, loginLimiter, signUpLimiter } from "@/app/middleware/bucket";
import { clientSideApiRoute, loginRoute, signupRoute } from "@/app/middleware/endpointValidator";
import {
clientSideApiEndpointsLimiter,
loginLimiter,
shareUrlLimiter,
signUpLimiter,
} from "@/app/middleware/bucket";
import {
clientSideApiRoute,
loginRoute,
shareUrlRoute,
signupRoute,
} from "@/app/middleware/endpointValidator";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
@@ -23,6 +33,8 @@ export async function middleware(request: NextRequest) {
await signUpLimiter.check(ip);
} else if (clientSideApiRoute(request.nextUrl.pathname)) {
await clientSideApiEndpointsLimiter.check(ip);
} else if (shareUrlRoute(request.nextUrl.pathname)) {
await shareUrlLimiter.check(ip);
}
return res;
} catch (_e) {
@@ -41,5 +53,6 @@ export const config = {
"/api/(.*)/client/:path*",
"/api/v1/js/actions",
"/api/v1/client/storage",
"/share/(.*)/:path",
],
};

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[resultShareKey]` on the table `Survey` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "resultShareKey" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Survey_resultShareKey_key" ON "Survey"("resultShareKey");

View File

@@ -299,6 +299,7 @@ model Survey {
/// [SurveyVerifyEmail]
verifyEmail Json?
pin String?
resultShareKey String? @unique
@@index([environmentId])
}

View File

@@ -123,6 +123,10 @@ export const CLIENT_SIDE_API_RATE_LIMIT = {
interval: 10 * 15 * 1000, // 15 minutes
allowedPerInterval: 60,
};
export const SHARE_RATE_LIMIT = {
interval: 60 * 60 * 1000, // 60 minutes
allowedPerInterval: 30,
};
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;

View File

@@ -49,6 +49,7 @@ export const selectSurvey = {
surveyClosedMessage: true,
singleUse: true,
pin: true,
resultShareKey: true,
triggers: {
select: {
actionClass: {
@@ -686,3 +687,25 @@ export const getSyncSurveys = async (environmentId: string, person: TPerson): Pr
)();
return surveys.map((survey) => formatDateFields(survey, ZSurvey));
};
export const getSurveyByResultShareKey = async (resultShareKey: string): Promise<string | null> => {
try {
const survey = await prisma.survey.findFirst({
where: {
resultShareKey,
},
});
if (!survey) {
return null;
}
return survey.id;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -430,6 +430,7 @@ export const ZSurvey = z.object({
singleUse: ZSurveySingleUse.nullable(),
verifyEmail: ZSurveyVerifyEmail.nullable(),
pin: z.string().nullable().optional(),
resultShareKey: z.string().nullable(),
});
export const ZSurveyInput = z.object({

View File

@@ -8,6 +8,7 @@ import { useRouter } from "next/navigation";
import { ReactNode, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
@@ -36,7 +37,7 @@ import ResponseTagsWrapper from "./components/ResponseTagsWrapper";
export interface SingleResponseCardProps {
survey: TSurvey;
response: TResponse;
user: TUser;
user?: TUser;
pageType: string;
environmentTags: TTag[];
environment: TEnvironment;
@@ -221,23 +222,38 @@ export default function SingleResponseCard({
className={clsx(
"relative z-10 my-6 rounded-lg border border-slate-200 bg-slate-50 shadow-sm transition-all",
pageType === "response" &&
(isOpen ? "w-3/4" : response.notes.length ? "w-[96.5%]" : "w-full group-hover:w-[96.5%]")
(isOpen
? "w-3/4"
: response.notes.length
? "w-[96.5%]"
: cn("w-full", user ? "group-hover:w-[96.5%]" : ""))
)}>
<div className="space-y-2 px-6 pb-5 pt-6">
<div className="flex items-center justify-between">
{pageType === "response" && (
<div>
{response.person?.id ? (
<Link
className="group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<PersonAvatar personId={response.person.id} />
</TooltipRenderer>
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
</Link>
user ? (
<Link
className="group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<PersonAvatar personId={response.person.id} />
</TooltipRenderer>
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
</Link>
) : (
<div className="group flex items-center">
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<PersonAvatar personId={response.person.id} />
</TooltipRenderer>
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600">
{displayIdentifier}
</h3>
</div>
)
) : (
<div className="group flex items-center">
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
@@ -266,7 +282,7 @@ export default function SingleResponseCard({
<time className="text-slate-500" dateTime={timeSince(response.updatedAt.toISOString())}>
{timeSince(response.updatedAt.toISOString())}
</time>
{!isViewer && (
{user && !isViewer && (
<TooltipRenderer shouldRender={isSubmissionFresh} tooltipContent={deleteSubmissionToolTip}>
<TrashIcon
onClick={() => {
@@ -378,16 +394,18 @@ export default function SingleResponseCard({
)}
</div>
<LoadingWrapper isLoading={isLoading} error={error}>
{!isViewer && (
<ResponseTagsWrapper
environmentId={environmentId}
responseId={response.id}
tags={response.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
environmentTags={environmentTags}
/>
)}
</LoadingWrapper>
{user && (
<LoadingWrapper isLoading={isLoading} error={error}>
{!isViewer && (
<ResponseTagsWrapper
environmentId={environmentId}
responseId={response.id}
tags={response.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
environmentTags={environmentTags}
/>
)}
</LoadingWrapper>
)}
<DeleteDialog
open={deleteDialogOpen}
@@ -397,7 +415,7 @@ export default function SingleResponseCard({
isDeleting={isDeleting}
/>
</div>
{pageType === "response" && (
{user && pageType === "response" && (
<ResponseNotes
user={user}
responseId={response.id}