mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 05:40:02 -06:00
Merge branch 'main' of https://github.com/RajuGangitla/formbricks
This commit is contained in:
@@ -8,9 +8,9 @@ import RideHailing from "./ride-hailing.webp";
|
||||
import UpsellMiro from "./upsell-miro.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Advanced Targeting in Surveys | Formbricks",
|
||||
title: "Advanced Targeting for In-app Surveys | Formbricks",
|
||||
description:
|
||||
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, user events, metadata , literally anything! This helps you get more relevant feedback and make data-driven decisions. All of this without writing a single line of code.",
|
||||
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, user events, and metadata. This helps you get more relevant feedback and make data-driven decisions.",
|
||||
};
|
||||
|
||||
#### App Surveys
|
||||
@@ -32,8 +32,7 @@ Advanced Targeting allows you to show surveys to the right group of people. You
|
||||
## How to setup Advanced Targeting
|
||||
|
||||
<Note>
|
||||
Advanced Targeting is available on the Pro plan! Don't worry, you just need to enter your credit card
|
||||
details to start the freemium plan.
|
||||
Advanced Targeting is available on the Pro plan!
|
||||
</Note>
|
||||
|
||||
1. On the Formbricks dashboard, click on **People** tab from the top navigation bar.
|
||||
@@ -72,25 +71,7 @@ Advanced Targeting allows you to show surveys to the right group of people. You
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. Target High Value users who have $100k+ in their bank account, own 20+ stocks, and have are an active user.
|
||||
|
||||
<MdxImage
|
||||
src={Hni}
|
||||
alt="Target Active High Net Worth Individuals"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
4. Target Germans on mobile phones who have regenerated chatGPT answers frequently in the last quarter and did so today.
|
||||
|
||||
<MdxImage
|
||||
src={GermansGpt}
|
||||
alt="Target Germans on Mobile Phones who have regenerated chatGPT answers frequently in the last quarter and did so today"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
5. Sneak Peak: How we at Formbricks automate inviting power users to chat with us
|
||||
3. Sneak Peak: How we at Formbricks automate inviting power users to chat with us
|
||||
|
||||
<MdxImage
|
||||
src={PowerUsers}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { createActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
|
||||
import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromProductId,
|
||||
@@ -227,13 +227,26 @@ export const getImagesFromUnsplashAction = actionClient
|
||||
});
|
||||
});
|
||||
|
||||
const isValidUnsplashUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol === "https:" && UNSPLASH_ALLOWED_DOMAINS.includes(parsedUrl.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ZTriggerDownloadUnsplashImageAction = z.object({
|
||||
downloadUrl: z.string(),
|
||||
downloadUrl: z.string().url(),
|
||||
});
|
||||
|
||||
export const triggerDownloadUnsplashImageAction = actionClient
|
||||
.schema(ZTriggerDownloadUnsplashImageAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
if (!isValidUnsplashUrl(parsedInput.downloadUrl)) {
|
||||
throw new Error("Invalid Unsplash URL");
|
||||
}
|
||||
|
||||
const response = await fetch(`${parsedInput.downloadUrl}/?client_id=${UNSPLASH_ACCESS_KEY}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -66,6 +66,8 @@ const mapResponsesToTableData = (responses: TResponse[], survey: TSurvey): TResp
|
||||
notes: response.notes,
|
||||
verifiedEmail: typeof response.data["verifiedEmail"] === "string" ? response.data["verifiedEmail"] : "",
|
||||
language: response.language,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { QUESTIONS_ICON_MAP } from "@/app/lib/questions";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { processResponseData } from "@formbricks/lib/responses";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
@@ -11,6 +13,7 @@ import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Checkbox } from "@formbricks/ui/Checkbox";
|
||||
import { ResponseBadges } from "@formbricks/ui/ResponseBadges";
|
||||
import { RenderResponse } from "@formbricks/ui/SingleResponseCard/components/RenderResponse";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
|
||||
const getAddressFieldLabel = (field: string) => {
|
||||
switch (field) {
|
||||
@@ -182,6 +185,45 @@ export const generateColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
const personColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "personId",
|
||||
header: () => (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
Person
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<CircleHelpIcon className="h-3 w-3 text-slate-500" strokeWidth={1.5} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="font-normal">
|
||||
How to identify users for{" "}
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-slate-900"
|
||||
href="https://formbricks.com/docs/link-surveys/user-identification"
|
||||
target="_blank">
|
||||
link surveys
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-slate-900"
|
||||
href="https://formbricks.com/docs/app-surveys/user-identification"
|
||||
target="_blank">
|
||||
in-app surveys.
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
),
|
||||
size: 275,
|
||||
cell: ({ row }) => {
|
||||
const personId = row.original.person
|
||||
? getPersonIdentifier(row.original.person, row.original.personAttributes)
|
||||
: "Anonymous";
|
||||
return <p className="truncate text-slate-900">{personId}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
const statusColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "status",
|
||||
size: 200,
|
||||
@@ -259,6 +301,7 @@ export const generateColumns = (
|
||||
// Combine the selection column with the dynamic question columns
|
||||
return [
|
||||
...(isViewer ? [] : [selectionColumn]),
|
||||
personColumn,
|
||||
dateColumn,
|
||||
statusColumn,
|
||||
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
||||
|
||||
@@ -44,9 +44,9 @@ export const ResponseTableHeader = ({ header, setIsTableSettingsModalOpen }: Res
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
key={header.id}
|
||||
className="group relative border border-slate-300 bg-slate-200 p-2 px-4 text-center">
|
||||
className="group relative h-10 border border-slate-300 bg-slate-200 px-2 text-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 truncate text-left">
|
||||
<div className="truncate text-left font-semibold">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,20 +6,25 @@ interface SummaryMetadataProps {
|
||||
setShowDropOffs: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showDropOffs: boolean;
|
||||
surveySummary: TSurveySummary["meta"];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const StatCard = ({ label, percentage, value, tooltipText }) => (
|
||||
const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<p className="flex items-center gap-1 text-sm text-slate-600">
|
||||
{label}
|
||||
{typeof percentage === "number" && !isNaN(percentage) && (
|
||||
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
|
||||
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{percentage}%</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">{value}</p>
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-slate-800">{value}</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -44,7 +49,12 @@ const formatTime = (ttc) => {
|
||||
return formattedValue;
|
||||
};
|
||||
|
||||
export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary }: SummaryMetadataProps) => {
|
||||
export const SummaryMetadata = ({
|
||||
setShowDropOffs,
|
||||
showDropOffs,
|
||||
surveySummary,
|
||||
isLoading,
|
||||
}: SummaryMetadataProps) => {
|
||||
const {
|
||||
completedPercentage,
|
||||
completedResponses,
|
||||
@@ -64,18 +74,21 @@ export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary }
|
||||
percentage={null}
|
||||
value={displayCount === 0 ? <span>-</span> : displayCount}
|
||||
tooltipText="Number of times the survey has been viewed."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label="Starts"
|
||||
percentage={Math.round(startsPercentage) > 100 ? null : Math.round(startsPercentage)}
|
||||
value={totalResponses === 0 ? <span>-</span> : totalResponses}
|
||||
tooltipText="Number of times the survey has been started."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label="Completed"
|
||||
percentage={Math.round(completedPercentage) > 100 ? null : Math.round(completedPercentage)}
|
||||
value={completedResponses === 0 ? <span>-</span> : completedResponses}
|
||||
tooltipText="Number of times the survey has been completed."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<TooltipProvider delayDuration={50}>
|
||||
@@ -86,21 +99,29 @@ export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary }
|
||||
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<span className="text-sm text-slate-600">
|
||||
Drop-Offs
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && (
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{`${Math.round(dropOffPercentage)}%`}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex w-full items-end justify-between">
|
||||
<span className="text-2xl font-bold text-slate-800">
|
||||
{dropOffCount === 0 ? <span>-</span> : dropOffCount}
|
||||
</span>
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : dropOffCount === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
dropOffCount
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -114,6 +135,7 @@ export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary }
|
||||
percentage={null}
|
||||
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
||||
tooltipText="Average time to complete the survey."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,7 @@ export const SummaryPage = ({
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
|
||||
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
@@ -104,28 +105,39 @@ export const SummaryPage = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleInitialData = async () => {
|
||||
const handleInitialData = async (isInitialLoad = false) => {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedResponseCountData = await getResponseCount();
|
||||
const updatedSurveySummary = await getSummary();
|
||||
const [updatedResponseCountData, updatedSurveySummary] = await Promise.all([
|
||||
getResponseCount(),
|
||||
getSummary(),
|
||||
]);
|
||||
|
||||
const responseCount = updatedResponseCountData?.data ?? 0;
|
||||
const surveySummary = updatedSurveySummary?.data ?? initialSurveySummary;
|
||||
|
||||
// Update the state with new data
|
||||
setResponseCount(responseCount);
|
||||
setSurveySummary(surveySummary);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleInitialData();
|
||||
handleInitialData(true);
|
||||
}, [JSON.stringify(filters), isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
useIntervalWhenFocused(
|
||||
() => {
|
||||
handleInitialData();
|
||||
handleInitialData(false);
|
||||
},
|
||||
10000,
|
||||
!isShareEmbedModalOpen,
|
||||
@@ -148,6 +160,7 @@ export const SummaryPage = ({
|
||||
surveySummary={surveySummary.meta}
|
||||
showDropOffs={showDropOffs}
|
||||
setShowDropOffs={setShowDropOffs}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} />}
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@@ -2,54 +2,15 @@ import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
import { SkeletonLoader } from "@formbricks/ui/SkeletonLoader";
|
||||
|
||||
const LoadingCard = ({ title, percentage }) => {
|
||||
return (
|
||||
<div className="flex h-full animate-pulse cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<p className="flex items-center gap-1 text-sm text-slate-600">
|
||||
{title}
|
||||
{percentage && <div className="ml-1 h-4 w-6 rounded-md bg-slate-300"></div>}
|
||||
</p>
|
||||
<div className="h-6 w-10 rounded-md bg-slate-300"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Loading = () => {
|
||||
const cards = [
|
||||
{ title: "Impressions", percentage: false },
|
||||
{ title: "Starts", percentage: true },
|
||||
{ title: "Completed", percentage: true },
|
||||
{ title: "Drop-Offs", percentage: true },
|
||||
{ title: "Time to Complete", percentage: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="Summary" />
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr]">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{[1, 2].map((index) => (
|
||||
<span
|
||||
key={index}
|
||||
aria-disabled="true"
|
||||
className="flex h-full w-28 animate-pulse items-center rounded-md border-b-2 bg-slate-300 px-3 text-sm font-medium text-slate-500">
|
||||
{/* Simulate a tab label */}
|
||||
<div className="h-4 w-full bg-slate-300"></div>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-4">
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex h-9 animate-pulse gap-2">
|
||||
<div className="h-9 w-36 rounded-md bg-slate-300"></div>
|
||||
<div className="h-9 w-36 rounded-md bg-slate-300"></div>
|
||||
<div className="h-9 w-36 rounded-md bg-slate-300"></div>
|
||||
<div className="h-9 w-36 rounded-md bg-slate-300"></div>
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200"></div>
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200"></div>
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200"></div>
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<SkeletonLoader type="summary" />
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -4,7 +4,14 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes): TSurvey => {
|
||||
const surveyTemp = structuredClone(survey);
|
||||
const languages = Object.keys(survey.questions[0].headline);
|
||||
const languages = surveyTemp.languages.map((surveyLanguage) => {
|
||||
if (surveyLanguage.default) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
return surveyLanguage.language.code;
|
||||
});
|
||||
|
||||
surveyTemp.questions.forEach((question) => {
|
||||
languages.forEach((language) => {
|
||||
if (question.headline[language].includes("recall:")) {
|
||||
|
||||
@@ -50,7 +50,6 @@ export const questionTypes: TQuestion[] = [
|
||||
icon: MessageSquareTextIcon,
|
||||
preset: {
|
||||
headline: { default: "Who let the dogs out?" },
|
||||
subheader: { default: "Who? Who? Who?" },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
longAnswer: true,
|
||||
inputType: "text",
|
||||
@@ -63,7 +62,6 @@ export const questionTypes: TQuestion[] = [
|
||||
icon: Rows3Icon,
|
||||
preset: {
|
||||
headline: { default: "What do you do?" },
|
||||
subheader: { default: "Can't do both." },
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Eat the cake 🍰" } },
|
||||
{ id: createId(), label: { default: "Have the cake 🎂" } },
|
||||
@@ -93,7 +91,6 @@ export const questionTypes: TQuestion[] = [
|
||||
icon: ImageIcon,
|
||||
preset: {
|
||||
headline: { default: "Which is the cutest puppy?" },
|
||||
subheader: { default: "You can also pick both." },
|
||||
allowMulti: true,
|
||||
choices: [
|
||||
{
|
||||
@@ -114,7 +111,6 @@ export const questionTypes: TQuestion[] = [
|
||||
icon: StarIcon,
|
||||
preset: {
|
||||
headline: { default: "How would you rate {{productName}}" },
|
||||
subheader: { default: "Don't worry, be honest." },
|
||||
scale: "star",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Not good" },
|
||||
@@ -155,8 +151,7 @@ export const questionTypes: TQuestion[] = [
|
||||
icon: Grid3X3Icon,
|
||||
preset: {
|
||||
headline: { default: "How much do you love these flowers?" },
|
||||
subheader: { default: "0: Not at all, 3: Love it" },
|
||||
rows: [{ default: "Rose 🌹" }, { default: "Sunflower 🌻" }, { default: "Hibiscus 🌺" }],
|
||||
rows: [{ default: "Roses" }, { default: "Trees" }, { default: "Ocean" }],
|
||||
columns: [{ default: "0" }, { default: "1" }, { default: "2" }, { default: "3" }],
|
||||
} as Partial<TSurveyMatrixQuestion>,
|
||||
},
|
||||
|
||||
@@ -169,9 +169,9 @@ test.describe("Survey Create & Submit Response", async () => {
|
||||
await expect(page.getByRole("cell", { name: surveys.createAndSubmit.matrix.columns[3] })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("row", { name: "Rose 🌹" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Sunflower 🌻" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Hibiscus 🌺" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Roses" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Trees" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Ocean" }).getByRole("cell").nth(1).click();
|
||||
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Address Question
|
||||
@@ -309,10 +309,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.openTextQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").press("Tab");
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").click();
|
||||
await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder);
|
||||
|
||||
@@ -322,10 +318,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.singleSelectQuestion.question);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").click();
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").click();
|
||||
@@ -351,10 +343,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.pictureSelectQuestion.question);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.pictureSelectQuestion.description);
|
||||
|
||||
// Fill Rating question in german
|
||||
await page.getByRole("main").getByText("Rating").click();
|
||||
@@ -362,10 +350,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.ratingQuestion.question);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").click();
|
||||
await page.getByPlaceholder("Not good").fill(surveys.germanCreate.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").click();
|
||||
@@ -398,10 +382,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByRole("main").getByText("Matrix").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.matrix.question);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.matrix.description);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(surveys.germanCreate.matrix.rows[0]);
|
||||
await page.locator("#row-1").click();
|
||||
|
||||
@@ -153,6 +153,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
await page.getByRole("main").getByText("What would you like to know?").click();
|
||||
|
||||
await page.getByLabel("Question*").fill(params.openTextQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
|
||||
@@ -166,6 +167,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Single-Select" }).click();
|
||||
await page.getByLabel("Question*").fill(params.singleSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||
@@ -193,6 +195,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await page.getByLabel("Question*").fill(params.ratingQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
||||
@@ -236,6 +239,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||
await page.getByLabel("Question*").fill(params.pictureSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.pictureSelectQuestion.description);
|
||||
|
||||
// File Upload Question
|
||||
@@ -255,6 +259,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Matrix" }).click();
|
||||
await page.getByLabel("Question*").fill(params.matrix.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.matrix.description);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
||||
|
||||
@@ -150,7 +150,7 @@ export const surveys = {
|
||||
matrix: {
|
||||
question: "How much do you love these flowers?",
|
||||
description: "0: Not at all, 3: Love it",
|
||||
rows: ["Rose 🌹", "Sunflower 🌻", "Hibiscus 🌺"],
|
||||
rows: ["Roses", "Trees", "Ocean"],
|
||||
columns: ["0", "1", "2", "3"],
|
||||
},
|
||||
address: {
|
||||
|
||||
@@ -168,6 +168,7 @@ export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const CUSTOMER_IO_SITE_ID = env.CUSTOMER_IO_SITE_ID;
|
||||
export const CUSTOMER_IO_API_KEY = env.CUSTOMER_IO_API_KEY;
|
||||
export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY;
|
||||
export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
|
||||
|
||||
export const STRIPE_API_VERSION = "2024-06-20";
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ZResponseInput,
|
||||
ZResponseUpdateInput,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getAttributes } from "../attribute/service";
|
||||
import { cache } from "../cache";
|
||||
@@ -28,7 +28,7 @@ import { createPerson, getPersonByUserId } from "../person/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "../posthogServer";
|
||||
import { responseNoteCache } from "../responseNote/cache";
|
||||
import { getResponseNotes } from "../responseNote/service";
|
||||
import { putFile } from "../storage/service";
|
||||
import { deleteFile, putFile } from "../storage/service";
|
||||
import { getSurvey } from "../survey/service";
|
||||
import { captureTelemetry } from "../telemetry";
|
||||
import { convertToCsv, convertToXlsxBuffer } from "../utils/fileConversion";
|
||||
@@ -697,6 +697,35 @@ export const updateResponse = async (
|
||||
}
|
||||
};
|
||||
|
||||
const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey: TSurvey): Promise<void> => {
|
||||
const fileUploadQuestions = new Set(
|
||||
survey.questions
|
||||
.filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload)
|
||||
.map((q) => q.id)
|
||||
);
|
||||
|
||||
const fileUrls = Object.entries(response.data)
|
||||
.filter(([questionId]) => fileUploadQuestions.has(questionId))
|
||||
.flatMap(([, questionResponse]) => questionResponse as string[]);
|
||||
|
||||
const deletionPromises = fileUrls.map(async (fileUrl) => {
|
||||
try {
|
||||
const { pathname } = new URL(fileUrl);
|
||||
const [, environmentId, accessType, fileName] = pathname.split("/").filter(Boolean);
|
||||
|
||||
if (!environmentId || !accessType || !fileName) {
|
||||
throw new Error(`Invalid file path: ${pathname}`);
|
||||
}
|
||||
|
||||
return deleteFile(environmentId, accessType as "private" | "public", fileName);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete file ${fileUrl}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(deletionPromises);
|
||||
};
|
||||
|
||||
export const deleteResponse = async (responseId: string): Promise<TResponse> => {
|
||||
validateInputs([responseId, ZId]);
|
||||
try {
|
||||
@@ -718,6 +747,16 @@ export const deleteResponse = async (responseId: string): Promise<TResponse> =>
|
||||
|
||||
const survey = await getSurvey(response.surveyId);
|
||||
|
||||
if (survey) {
|
||||
await findAndDeleteUploadedFilesInResponse(
|
||||
{
|
||||
...responsePrisma,
|
||||
tags: responsePrisma.tags.map((tag) => tag.tag),
|
||||
},
|
||||
survey
|
||||
);
|
||||
}
|
||||
|
||||
responseCache.revalidate({
|
||||
environmentId: survey?.environmentId,
|
||||
id: response.id,
|
||||
|
||||
@@ -571,7 +571,42 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
id: segment.id,
|
||||
environmentId: segment.environmentId,
|
||||
});
|
||||
} else if (type === "app") {
|
||||
if (!currentSurvey.segment) {
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
environmentId_title: {
|
||||
environmentId,
|
||||
title: surveyId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
title: surveyId,
|
||||
isPrivate: true,
|
||||
filters: [],
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
segmentCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
data.questions = questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
|
||||
@@ -2526,7 +2526,6 @@ export const customSurvey = {
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "What would you like to know?" },
|
||||
subheader: { default: "This is an example survey." },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
|
||||
@@ -120,7 +120,7 @@ export const MatrixQuestion = ({
|
||||
// Table rows
|
||||
<tr className={`${rowIndex % 2 === 0 ? "bg-input-bg" : ""}`}>
|
||||
<td
|
||||
className="fb-text-heading fb-rounded-l-custom fb-max-w-40 fb-break-words fb-px-4 fb-py-2"
|
||||
className="fb-text-heading fb-rounded-l-custom fb-max-w-40 fb-break-words fb-pr-4 fb-pl-2 fb-py-2"
|
||||
dir="auto">
|
||||
{getLocalizedValue(row, languageCode)}
|
||||
</td>
|
||||
@@ -151,7 +151,7 @@ export const MatrixQuestion = ({
|
||||
dir="auto"
|
||||
type="radio"
|
||||
tabIndex={-1}
|
||||
required={true}
|
||||
required={question.required}
|
||||
id={`${row}-${column}`}
|
||||
name={getLocalizedValue(row, languageCode)}
|
||||
value={getLocalizedValue(column, languageCode)}
|
||||
|
||||
@@ -314,6 +314,8 @@ export const ZResponseTableData = z.object({
|
||||
notes: z.array(ZResponseNote),
|
||||
language: z.string().nullable(),
|
||||
responseData: ZResponseData,
|
||||
person: ZResponsePerson.nullable(),
|
||||
personAttributes: ZResponsePersonAttributes,
|
||||
});
|
||||
|
||||
export type TResponseTableData = z.infer<typeof ZResponseTableData>;
|
||||
|
||||
@@ -11,16 +11,14 @@ export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
|
||||
className="rounded-xl border border-slate-200 bg-white shadow-sm"
|
||||
data-testid="skeleton-loader-summary">
|
||||
<Skeleton className="group space-y-4 rounded-xl bg-white p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-6 w-full rounded-full bg-slate-100"></div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="h-6 w-24 rounded-full bg-slate-100"></div>
|
||||
<div className="h-6 w-24 rounded-full bg-slate-100"></div>
|
||||
<div className="h-6 w-24 rounded-full bg-slate-200"></div>
|
||||
<div className="h-6 w-24 rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100"></div>
|
||||
<div className="h-12 w-full rounded-full bg-slate-50/50"></div>
|
||||
<div className="flex h-12 w-full items-center justify-center rounded-full bg-slate-200 text-sm text-slate-500"></div>
|
||||
<div className="h-12 w-full rounded-full bg-slate-200"></div>
|
||||
<div className="h-12 w-full rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
@@ -31,13 +29,13 @@ export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
|
||||
return (
|
||||
<div className="group space-y-4 rounded-lg bg-white p-6" data-testid="skeleton-loader-response">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full bg-slate-100"></Skeleton>
|
||||
<Skeleton className="h-6 w-full rounded-full bg-slate-100"></Skeleton>
|
||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full bg-slate-200"></Skeleton>
|
||||
<Skeleton className="h-6 w-full rounded-full bg-slate-200"></Skeleton>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-full rounded-full bg-slate-100"></Skeleton>
|
||||
<Skeleton className="flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100"></Skeleton>
|
||||
<Skeleton className="h-12 w-full rounded-full bg-slate-50/50"></Skeleton>
|
||||
<Skeleton className="h-12 w-full rounded-full bg-slate-200"></Skeleton>
|
||||
<Skeleton className="flex h-12 w-full items-center justify-center rounded-full bg-slate-200 text-sm text-slate-500"></Skeleton>
|
||||
<Skeleton className="h-12 w-full rounded-full bg-slate-200"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user