mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 16:19:55 -06:00
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:
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -2528,4 +2528,5 @@ export const minimalSurvey: TSurvey = {
|
||||
productOverwrites: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
apps/web/app/(app)/share/[sharingKey]/action.ts
Normal file
7
apps/web/app/(app)/share/[sharingKey]/action.ts
Normal 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);
|
||||
}
|
||||
472
apps/web/app/(app)/share/[sharingKey]/components/CustomFilter.tsx
Executable file
472
apps/web/app/(app)/share/[sharingKey]/components/CustomFilter.tsx
Executable 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;
|
||||
@@ -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;
|
||||
14
apps/web/app/(app)/share/[sharingKey]/layout.tsx
Normal file
14
apps/web/app/(app)/share/[sharingKey]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/web/app/(app)/share/[sharingKey]/not-found.tsx
Normal file
20
apps/web/app/(app)/share/[sharingKey]/not-found.tsx
Normal 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 couldn’t find the responses sharing ID you’re looking for.
|
||||
</p>
|
||||
<Link href={"/"}>
|
||||
<Button className="mt-8">Back to home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/(app)/share/[sharingKey]/page.tsx
Normal file
5
apps/web/app/(app)/share/[sharingKey]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function EnvironmentPage({ params }) {
|
||||
return redirect(`/share/${params.sharingKey}/summary`);
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
@@ -299,6 +299,7 @@ model Survey {
|
||||
/// [SurveyVerifyEmail]
|
||||
verifyEmail Json?
|
||||
pin String?
|
||||
resultShareKey String? @unique
|
||||
|
||||
@@index([environmentId])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user