This commit is contained in:
RajuGangitla
2024-09-11 05:35:33 +00:00
20 changed files with 231 additions and 135 deletions

View File

@@ -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}

View File

@@ -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" },

View File

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

View File

@@ -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] : []),

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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:")) {

View File

@@ -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>,
},

View File

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

View File

@@ -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]);

View File

@@ -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: {

View File

@@ -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";

View File

@@ -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,

View File

@@ -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;

View File

@@ -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",

View File

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

View File

@@ -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>;

View File

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