diff --git a/apps/docs/app/app-surveys/advanced-targeting/page.mdx b/apps/docs/app/app-surveys/advanced-targeting/page.mdx index 51e245706d..890adf9b16 100644 --- a/apps/docs/app/app-surveys/advanced-targeting/page.mdx +++ b/apps/docs/app/app-surveys/advanced-targeting/page.mdx @@ -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 - 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! 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. - - - -4. Target Germans on mobile phones who have regenerated chatGPT answers frequently in the last quarter and did so today. - - - -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 { + 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" }, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx index 322a7b3769..544bb35848 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx @@ -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, })); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx index dcb1c7bc4d..d40be4c10d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx @@ -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 = { + accessorKey: "personId", + header: () => ( +
+ Person + + + + + + + How to identify users for{" "} + + link surveys + {" "} + or{" "} + + in-app surveys. + + + + +
+ ), + size: 275, + cell: ({ row }) => { + const personId = row.original.person + ? getPersonIdentifier(row.original.person, row.original.personAttributes) + : "Anonymous"; + return

{personId}

; + }, + }; + const statusColumn: ColumnDef = { 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] : []), diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader.tsx index 7382008add..63afe40b1c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader.tsx @@ -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">
-
+
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx index 75245077a6..04d1e51f5c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx @@ -6,20 +6,25 @@ interface SummaryMetadataProps { setShowDropOffs: React.Dispatch>; showDropOffs: boolean; surveySummary: TSurveySummary["meta"]; + isLoading: boolean; } -const StatCard = ({ label, percentage, value, tooltipText }) => ( +const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => (

{label} - {typeof percentage === "number" && !isNaN(percentage) && ( + {typeof percentage === "number" && !isNaN(percentage) && !isLoading && ( {percentage}% )}

-

{value}

+ {isLoading ? ( +
+ ) : ( +

{value}

+ )}
@@ -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 ? - : displayCount} tooltipText="Number of times the survey has been viewed." + isLoading={isLoading} /> 100 ? null : Math.round(startsPercentage)} value={totalResponses === 0 ? - : totalResponses} tooltipText="Number of times the survey has been started." + isLoading={isLoading} /> 100 ? null : Math.round(completedPercentage)} value={completedResponses === 0 ? - : completedResponses} tooltipText="Number of times the survey has been completed." + isLoading={isLoading} /> @@ -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"> Drop-Offs - {`${Math.round(dropOffPercentage)}%` !== "NaN%" && ( + {`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && ( {`${Math.round(dropOffPercentage)}%`} )}
- {dropOffCount === 0 ? - : dropOffCount} - - - {showDropOffs ? ( - + {isLoading ? ( +
+ ) : dropOffCount === 0 ? ( + - ) : ( - + dropOffCount )}
+ {!isLoading && ( + + {showDropOffs ? ( + + ) : ( + + )} + + )}
@@ -114,6 +135,7 @@ export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary } percentage={null} value={ttcAverage === 0 ? - : `${formatTime(ttcAverage)}`} tooltipText="Average time to complete the survey." + isLoading={isLoading} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx index 0e03d4d3a3..2094ea430b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx @@ -67,6 +67,7 @@ export const SummaryPage = ({ const [responseCount, setResponseCount] = useState(null); const [surveySummary, setSurveySummary] = useState(initialSurveySummary); const [showDropOffs, setShowDropOffs] = useState(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 && }
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/loading.tsx index 2ad0291e38..741456590b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/loading.tsx @@ -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 ( -
-

- {title} - {percentage &&

} -

-
-
- ); -}; - 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 ( -
- -
-
-
- {cards.map((card, index) => ( - - ))} -
-
-
-
-
+
+
+
+
diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts index 696ef1f4b4..5becd5e05e 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts @@ -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:")) { diff --git a/apps/web/app/lib/questions.tsx b/apps/web/app/lib/questions.tsx index 0749503154..0247cbdfb4 100644 --- a/apps/web/app/lib/questions.tsx +++ b/apps/web/app/lib/questions.tsx @@ -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, }, diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts index ac0374af44..ff83096e06 100644 --- a/apps/web/playwright/survey.spec.ts +++ b/apps/web/playwright/survey.spec.ts @@ -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(); diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts index f6dcc7f083..e1fbd141a1 100644 --- a/apps/web/playwright/utils/helper.ts +++ b/apps/web/playwright/utils/helper.ts @@ -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]); diff --git a/apps/web/playwright/utils/mock.ts b/apps/web/playwright/utils/mock.ts index 497c7f48cb..f4bf72bfd0 100644 --- a/apps/web/playwright/utils/mock.ts +++ b/apps/web/playwright/utils/mock.ts @@ -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: { diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index fc0829fbb7..5d84da7bc4 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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"; diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index ce7297c5dd..2ccfe3b524 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -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 => { + 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 => { validateInputs([responseId, ZId]); try { @@ -718,6 +747,16 @@ export const deleteResponse = async (responseId: string): Promise => 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, diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index 57e17e40af..61c73abf38 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -571,7 +571,42 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => 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; diff --git a/packages/lib/templates.ts b/packages/lib/templates.ts index 7547c4a25f..672c45ec41 100644 --- a/packages/lib/templates.ts +++ b/packages/lib/templates.ts @@ -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", diff --git a/packages/surveys/src/components/questions/MatrixQuestion.tsx b/packages/surveys/src/components/questions/MatrixQuestion.tsx index bfb8431e76..a671a1082a 100644 --- a/packages/surveys/src/components/questions/MatrixQuestion.tsx +++ b/packages/surveys/src/components/questions/MatrixQuestion.tsx @@ -120,7 +120,7 @@ export const MatrixQuestion = ({ // Table rows {getLocalizedValue(row, languageCode)} @@ -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)} diff --git a/packages/types/responses.ts b/packages/types/responses.ts index c811ad5b43..5586053b1b 100644 --- a/packages/types/responses.ts +++ b/packages/types/responses.ts @@ -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; diff --git a/packages/ui/SkeletonLoader/index.tsx b/packages/ui/SkeletonLoader/index.tsx index 5903e2e3a2..9f3ba545a0 100644 --- a/packages/ui/SkeletonLoader/index.tsx +++ b/packages/ui/SkeletonLoader/index.tsx @@ -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"> -
-
-
-
-
+
+
-
-
+
+
+
@@ -31,13 +29,13 @@ export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => { return (
- - + +
- - - + + +
);