Compare commits

...

38 Commits

Author SHA1 Message Date
Piyush Gupta
435dbf2261 Merge branch 'main' of https://github.com/formbricks/formbricks into version-fix 2024-09-17 12:55:14 +05:30
Piyush Gupta
df74510756 fix: version check API calls 2024-09-17 12:54:20 +05:30
Dhruwang Jariwala
c9b8ffa9ef fix: billing tab (#3138)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-09-16 10:31:54 +00:00
pandeymangg
57135d167b fix: github api call cache 2024-09-13 11:49:53 +05:30
Anshuman Pandey
916e47c55d Merge branch 'main' into main 2024-09-13 10:35:30 +05:30
Anshuman Pandey
04e16d44a1 fix: release 2.5.3 (#3139) 2024-09-12 16:07:39 +02:00
Jonas Höbenreich
29131f93c2 fix: increase timeout (#3120)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-09-12 15:35:12 +02:00
Anshuman Pandey
1e2fe7b066 fix: validation UI (#3137)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-09-12 11:52:58 +00:00
Johannes
426a0a3847 fix: tweak action UI (#3136) 2024-09-12 11:50:58 +00:00
Jonas Höbenreich
80ef504bef fix: Device Info Overflowing for Long URLs (#3135)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-09-12 11:44:41 +00:00
Anshuman Pandey
6ab83e25d3 fix: multi lang issue sync (#3132)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-09-12 10:59:15 +00:00
Jonas Höbenreich
688dc25990 fix: cuid validation 🚨 (#3130) 2024-09-12 10:26:33 +00:00
RajuGangitla
5faccf1063 resolve merge conflicts 2024-09-11 10:22:45 +00:00
RajuGangitla
ebbc5abd3c IS_FORMBRICKS_CLOUD condtion added 2024-09-11 10:21:46 +00:00
Johannes
c53b58c64f fix: update RN docs (#3127) 2024-09-11 12:00:04 +02:00
Johannes
ad445d1915 tweaks 2024-09-11 11:35:52 +02:00
Piyush Gupta
61d18edb5d feat: add support for webp file format in file inputs (#3117)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-09-11 08:53:05 +00:00
RajuGangitla
756d0c84af Merge branch 'formbricks:main' into main 2024-09-11 11:07:13 +05:30
RajuGangitla
8f6b356870 Merge branch 'main' of https://github.com/RajuGangitla/formbricks 2024-09-11 05:35:33 +00:00
RajuGangitla
e0ba9cb998 feat: info about new formbricks version 2024-09-11 05:34:36 +00:00
Anshuman Pandey
07b5dfe28a fix: trigger (#3125) 2024-09-11 04:31:37 +00:00
RajuGangitla
c6f1e9e7d5 Merge branch 'formbricks:main' into main 2024-09-11 09:58:58 +05:30
Anshuman Pandey
f55cad0121 fix: multi lang sync issue (#3121)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-09-10 12:37:55 +00:00
Dhruwang Jariwala
494299cd89 fix: optional behaviour for matrix question (#3118) 2024-09-10 12:32:42 +00:00
RajuGangitla
5cc071e5a8 fix: Loading Skeleton for survey summary page (#3108)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-09-09 15:11:56 +00:00
Anshuman Pandey
0532f2744b feat: delete uploaded files with response deletion (#3114) 2024-09-09 12:24:25 +00:00
Anshuman Pandey
f025a70d20 Merge branch 'main' into main 2024-09-09 17:44:12 +05:30
Anshuman Pandey
43ea26a33a fix: segment update (#3115) 2024-09-09 11:47:38 +00:00
pandeymangg
ba1e478d68 Merge remote-tracking branch 'origin/main' into raju-gangitla 2024-09-09 16:42:35 +05:30
Piyush Gupta
ec54e40a8b fix: SSRF vulnerability in unsplash image fetching (#3111)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-09-09 10:46:07 +00:00
Dhruwang Jariwala
4b508f02e3 fix: added people column to data table (#3110)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-09-09 04:30:35 +00:00
Johannes
eec7e1b62a fix: update docs (#3113) 2024-09-08 15:29:32 +02:00
Johannes
8e57082376 Merge branch 'main' into main 2024-09-08 15:25:29 +02:00
Johannes
2681d03e15 move loading to StatCard, parallelize API calls 2024-09-08 15:23:31 +02:00
Johannes
39e87eb8d3 fix: remove description from new questions (#3112) 2024-09-07 14:25:55 +02:00
RajuGangitla
72e023c530 Merge branch 'main' of https://github.com/RajuGangitla/formbricks 2024-09-06 07:40:34 +00:00
RajuGangitla
7088f9bd26 fix :Loading Skeleton for survey summary page #3085 2024-09-06 07:36:06 +00:00
Dhruwang Jariwala
780115ffb8 fix: infinite loading in survey animation (#3107) 2024-09-06 07:02:57 +00:00
45 changed files with 498 additions and 190 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

@@ -14,10 +14,6 @@ export const metadata = {
The Formbricks React Native SDK can be used for seamlessly integrating App Surveys into your React Native Apps. Here, w'll explore how to leverage the SDK for in app surveys. The SDK is [available on npm.](https://www.npmjs.com/package/@formbricks/react-native)
<Note>
Our React Native SDK is now in public beta. We're happy to **offer you a free 3-month trial for our Scale plan** if you're providing feedback on your developer experience. Reach out on Discord and shoot Johannes a message to get the extended trial started.
</Note>
### Install
<Col>

View File

@@ -139,9 +139,9 @@ export const navigation: Array<NavGroup> = [
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
],
},
{ title: "SDK: React Native", href: "/developer-docs/react-native-in-app-surveys" },
{ title: "SDK: Web Apps", href: "/developer-docs/app-survey-sdk" },
{ title: "SDK: Public Websites", href: "/developer-docs/website-survey-sdk" },
{ title: "SDK: React Native", href: "/developer-docs/react-native-in-app-surveys" },
{ title: "SDK: Formbricks API", href: "/developer-docs/api-sdk" },
{ title: "REST API", href: "/developer-docs/rest-api" },
{ title: "Webhooks", href: "/developer-docs/webhooks" },

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

@@ -56,6 +56,7 @@ export const AddActionModal = ({
return (
<ModalWithTabs
label="Add action"
description="Capture a new action to trigger a survey on."
open={open}
setOpen={setOpen}
tabs={tabs}

View File

@@ -171,14 +171,14 @@ export const CreateNewActionTab = ({
<div>
<FormProvider {...form}>
<form onSubmit={handleSubmit(submitHandler)}>
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
<div className="max-h-[500px] w-full space-y-4 overflow-y-auto pr-4">
<div className="w-3/5">
<FormField
name={`type`}
control={control}
render={({ field }) => (
<div>
<Label className="font-semibold">Type</Label>
<Label className="font-semibold">Action Type</Label>
<TabToggle
id="type"
options={[

View File

@@ -110,7 +110,7 @@ export const EditWelcomeCard = ({
<div className="mt-3 flex w-full items-center justify-center">
<FileInput
id="welcome-card-image"
allowedFileExtensions={["png", "jpeg", "jpg"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={environmentId}
onFileUpload={(url: string[]) => {
updateSurvey({ fileUrl: url[0] });

View File

@@ -16,7 +16,7 @@ export const UploadImageSurveyBg = ({
<div className="flex w-full items-center justify-center">
<FileInput
id="survey-bg-file-input"
allowedFileExtensions={["png", "jpeg", "jpg"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={environmentId}
onFileUpload={(url: string[]) => {
if (url.length > 0) {

View File

@@ -139,7 +139,7 @@ export const PictureSelectionForm = ({
<div className="mt-3 flex w-full items-center justify-center">
<FileInput
id="choices-file-input"
allowedFileExtensions={["png", "jpeg", "jpg"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={environmentId}
onFileUpload={handleFileInputChanges}
fileUrl={question?.choices?.map((choice) => choice.imageUrl)}

View File

@@ -179,7 +179,9 @@ export const SurveyMenuBar = ({
(invalidLanguage: string) => getLanguageLabel(invalidLanguage) ?? invalidLanguage
);
toast.error(`${currentError.message} ${invalidLanguageLabels.join(", ")}`);
const messageSplit = currentError.message.split("-fLang-")[0];
toast.error(`${messageSplit} ${invalidLanguageLabels.join(", ")}`);
} else {
toast.error(currentError.message);
}
@@ -224,6 +226,12 @@ export const SurveyMenuBar = ({
}
});
if (localSurvey.type !== "link" && !localSurvey.triggers?.length) {
toast.error("Please set a survey trigger");
setIsSurveySaving(false);
return false;
}
const segment = await handleSegmentUpdate();
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });

View File

@@ -369,7 +369,7 @@ export const TargetingCard = ({
{isFormbricksCloud ? (
<UpgradePlanNotice
message="For advanced targeting, please"
textForUrl="upgrade to the User Identification plan."
textForUrl="upgrade to the Scale plan."
url={`/environments/${environmentId}/settings/billing`}
/>
) : (

View File

@@ -2,8 +2,9 @@
import { z } from "zod";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { cache } from "@formbricks/lib/cache";
import { getOrganizationIdFromActionClassId } from "@formbricks/lib/organization/utils";
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
import { ZActionClassInput } from "@formbricks/types/action-classes";
@@ -72,3 +73,33 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient
};
return response;
});
const getLatestStableFbRelease = async (): Promise<string | null> =>
cache(
async () => {
try {
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
const releases = await res.json();
if (Array.isArray(releases)) {
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
?.tag_name as string;
if (latestStableReleaseTag) {
return latestStableReleaseTag;
}
}
return null;
} catch (err) {
return null;
}
},
["latest-fb-release"],
{
revalidate: 60 * 60 * 24, // 24 hours
}
)();
export const getLatestStableFbReleaseAction = actionClient.action(async () => {
return await getLatestStableFbRelease();
});

View File

@@ -1,5 +1,6 @@
"use client";
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
@@ -20,6 +21,7 @@ import {
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
UserCircleIcon,
UserIcon,
UsersIcon,
@@ -54,6 +56,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
import { version } from "../../../../../package.json";
interface NavigationProps {
environment: TEnvironment;
@@ -61,9 +64,9 @@ interface NavigationProps {
user: TUser;
organization: TOrganization;
products: TProduct[];
isFormbricksCloud: boolean;
membershipRole?: TMembershipRole;
isMultiOrgEnabled: boolean;
isFormbricksCloud?: boolean;
membershipRole?: TMembershipRole;
}
export const MainNavigation = ({
@@ -72,9 +75,9 @@ export const MainNavigation = ({
organization,
user,
products,
isFormbricksCloud,
membershipRole,
isMultiOrgEnabled,
isFormbricksCloud = true,
membershipRole,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -84,6 +87,7 @@ export const MainNavigation = ({
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const product = products.find((product) => product.id === environment.productId);
const { isAdmin, isOwner, isViewer } = getAccessFlags(membershipRole);
@@ -248,6 +252,21 @@ export const MainNavigation = ({
},
];
useEffect(() => {
async function loadReleases() {
const res = await getLatestStableFbReleaseAction();
if (res?.data) {
const latestVersionTag = res.data;
const currentVersionTag = `v${version}`;
if (currentVersionTag !== latestVersionTag) {
setLatestVersion(latestVersionTag);
}
}
}
if (isOwnerOrAdmin) loadReleases();
}, [isOwnerOrAdmin]);
return (
<>
{product && (
@@ -305,8 +324,22 @@ export const MainNavigation = ({
)}
</ul>
</div>
{/* Product Switch */}
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrAdmin && latestVersion && !isFormbricksCloud && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
Formbricks {latestVersion} is here. Upgrade now!
</p>
</Link>
)}
<DropdownMenu>
<DropdownMenuTrigger
asChild

View File

@@ -117,7 +117,7 @@ export const EditLogo = ({ product, environmentId, isViewer }: EditLogoProps) =>
) : (
<FileInput
id="logo-input"
allowedFileExtensions={["png", "jpeg", "jpg"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={environmentId}
onFileUpload={(files: string[]) => {
setLogoUrl(files[0]);
@@ -129,7 +129,7 @@ export const EditLogo = ({ product, environmentId, isViewer }: EditLogoProps) =>
<Input
ref={fileInputRef}
type="file"
accept="image/jpeg, image/png"
accept="image/jpeg, image/png, image/webp"
className="hidden"
onChange={handleFileChange}
/>

View File

@@ -34,9 +34,9 @@ export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: Edit
.refine((files) => files.length === 1, "You must select a file.")
.refine((files) => {
const file = files[0];
const allowedTypes = ["image/jpeg", "image/png"];
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
return allowedTypes.includes(file.type);
}, "Invalid file type. Only JPEG and PNG are allowed.")
}, "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.")
.refine((files) => {
const file = files[0];
const maxSize = 10 * 1024 * 1024;
@@ -145,7 +145,7 @@ export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: Edit
inputRef.current = e;
}}
className="hidden"
accept="image/jpeg, image/png"
accept="image/jpeg, image/png, image/webp"
onChange={(e) => {
field.onChange(e.target.files);
form.handleSubmit(onSubmit)();

View File

@@ -35,7 +35,7 @@ export const OrganizationSettingsNavbar = ({
label: "Billing & Plan",
href: `/environments/${environmentId}/settings/billing`,
icon: <CreditCardIcon className="h-5 w-5" />,
hidden: !isFormbricksCloud || !isOwner,
hidden: !isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/billing"),
},
{

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) {
@@ -133,7 +136,7 @@ export const generateColumns = (
size: 75,
enableResizing: false,
header: ({ table }) => (
<div className="flex w-full items-center justify-center pr-4">
<div className="flex w-full items-center justify-center pr-2">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
@@ -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="w-full 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

@@ -0,0 +1,20 @@
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
import { SkeletonLoader } from "@formbricks/ui/SkeletonLoader";
const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle="Summary" />
<div className="flex h-9 animate-pulse gap-2">
<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>
);
};
export default Loading;

View File

@@ -4,20 +4,33 @@ 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";
}
if (surveyLanguage.enabled) {
return surveyLanguage.language.code;
}
return null;
})
.filter((language) => language !== null);
surveyTemp.questions.forEach((question) => {
languages.forEach((language) => {
if (question.headline[language].includes("recall:")) {
if (question.headline[language]?.includes("recall:")) {
question.headline[language] = parseRecallInfo(question.headline[language], attributes);
}
if (question.subheader && question.subheader[language].includes("recall:")) {
if (question.subheader && question.subheader[language]?.includes("recall:")) {
question.subheader[language] = parseRecallInfo(question.subheader[language], attributes);
}
});
});
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
languages.forEach((language) => {
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline[language].includes("recall:")) {
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline[language]?.includes("recall:")) {
surveyTemp.welcomeCard.headline[language] = parseRecallInfo(
surveyTemp.welcomeCard.headline[language],
attributes
@@ -28,9 +41,9 @@ export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes)
surveyTemp.endings.forEach((ending) => {
if (ending.type === "endScreen") {
languages.forEach((language) => {
if (ending.headline && ending.headline[language].includes("recall:")) {
if (ending.headline && ending.headline[language]?.includes("recall:")) {
ending.headline[language] = parseRecallInfo(ending.headline[language], attributes);
if (ending.subheader && ending.subheader[language].includes("recall:")) {
if (ending.subheader && ending.subheader[language]?.includes("recall:")) {
ending.subheader[language] = parseRecallInfo(ending.subheader[language], attributes);
}
}

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

@@ -16,7 +16,7 @@ export const SurveyLoadingAnimation = ({
}: SurveyLoadingAnimationProps) => {
const [isHidden, setIsHidden] = useState(false);
const [minTimePassed, setMinTimePassed] = useState(false);
const [isMediaLoaded, setIsMediaLoaded] = useState(false); // Tracks if all media (images, iframes) are fully loaded
const [isMediaLoaded, setIsMediaLoaded] = useState(false); // Tracks if all media are fully loaded
const [isSurveyPackageLoaded, setIsSurveyPackageLoaded] = useState(false); // Tracks if the survey package has been loaded into the DOM
const isReadyToTransition = isMediaLoaded && minTimePassed && isBackgroundLoaded;
const cardId = survey.welcomeCard.enabled ? `questionCard--1` : `questionCard-0`;
@@ -25,32 +25,42 @@ export const SurveyLoadingAnimation = ({
const checkMediaLoaded = useCallback(() => {
const cardElement = document.getElementById(cardId);
const images = cardElement ? Array.from(cardElement.getElementsByTagName("img")) : [];
const iframes = cardElement ? Array.from(cardElement.getElementsByTagName("iframe")) : [];
const allImagesLoaded = images.every((img) => img.complete && img.naturalHeight !== 0);
const allIframesLoaded = iframes.every((iframe) => {
const contentWindow = iframe.contentWindow;
return contentWindow && contentWindow.document.readyState === "complete";
});
if (allImagesLoaded && allIframesLoaded) {
if (allImagesLoaded) {
setIsMediaLoaded(true);
}
}, [cardId]);
// Effect to monitor when the survey package is loaded and media elements are fully loaded
useEffect(() => {
if (!isSurveyPackageLoaded) return; // Exit early if the survey package is not yet loaded
if (!isSurveyPackageLoaded) return;
checkMediaLoaded(); // Initial check when the survey package is loaded
checkMediaLoaded();
// Add event listeners to detect when individual media elements finish loading
const mediaElements = document.querySelectorAll(`#${cardId} img, #${cardId} iframe`);
mediaElements.forEach((element) => element.addEventListener("load", checkMediaLoaded));
const handleLoad = () => checkMediaLoaded();
const handleError = () => {
console.error("Media failed to load");
setIsMediaLoaded(true);
};
mediaElements.forEach((element) => {
element.addEventListener("load", handleLoad);
element.addEventListener("error", handleError);
});
// Set a 3-second timeout to prevent infinite loading
const timeoutId = setTimeout(() => {
setIsMediaLoaded(true);
}, 3000);
return () => {
// Cleanup event listeners when the component unmounts or dependencies change
mediaElements.forEach((element) => element.removeEventListener("load", checkMediaLoaded));
mediaElements.forEach((element) => {
element.removeEventListener("load", handleLoad);
element.removeEventListener("error", handleError);
});
clearTimeout(timeoutId);
};
}, [isSurveyPackageLoaded, checkMediaLoaded, cardId]);

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "2.5.1",
"version": "2.5.3",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",

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

@@ -77,7 +77,7 @@ async function runMigration(): Promise<void> {
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`);
},
{
timeout: 180000, // 3 minutes
timeout: 900000, // 15 minutes
}
);
}

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

@@ -16,6 +16,7 @@ export const ZAllowedFileExtension = z.enum([
"png",
"jpeg",
"jpg",
"webp",
"pdf",
"doc",
"docx",

View File

@@ -1,30 +1,30 @@
import { z } from "zod";
export const ZDisplay = z.object({
id: z.string().cuid(),
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
personId: z.string().cuid().nullable(),
surveyId: z.string().cuid(),
responseId: z.string().cuid().nullable(),
personId: z.string().cuid2().nullable(),
surveyId: z.string().cuid2(),
responseId: z.string().cuid2().nullable(),
status: z.enum(["seen", "responded"]).nullable(),
});
export type TDisplay = z.infer<typeof ZDisplay>;
export const ZDisplayCreateInput = z.object({
environmentId: z.string().cuid(),
surveyId: z.string().cuid(),
environmentId: z.string().cuid2(),
surveyId: z.string().cuid2(),
userId: z.string().optional(),
responseId: z.string().cuid().optional(),
responseId: z.string().cuid2().optional(),
});
export type TDisplayCreateInput = z.infer<typeof ZDisplayCreateInput>;
export const ZDisplayUpdateInput = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
userId: z.string().optional(),
responseId: z.string().cuid().optional(),
responseId: z.string().cuid2().optional(),
});
export type TDisplayUpdateInput = z.infer<typeof ZDisplayUpdateInput>;
@@ -42,7 +42,7 @@ export const ZDisplayFilters = z.object({
max: z.date().optional(),
})
.optional(),
responseIds: z.array(z.string().cuid()).optional(),
responseIds: z.array(z.string().cuid2()).optional(),
});
export type TDisplayFilters = z.infer<typeof ZDisplayFilters>;

View File

@@ -24,7 +24,7 @@ export type TSurveyWithTriggers = z.infer<typeof ZSurveyWithTriggers>;
export const ZJSWebsiteStateDisplay = z.object({
createdAt: z.date(),
surveyId: z.string().cuid(),
surveyId: z.string().cuid2(),
responded: z.boolean(),
});
@@ -65,14 +65,14 @@ export const ZJsWebsiteState = z.object({
export type TJsWebsiteState = z.infer<typeof ZJsWebsiteState>;
export const ZJsWebsiteSyncInput = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
version: z.string().optional(),
});
export type TJsWebsiteSyncInput = z.infer<typeof ZJsWebsiteSyncInput>;
export const ZJsWebsiteConfig = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
apiHost: z.string(),
state: ZJsWebsiteState,
expiresAt: z.date(),
@@ -82,7 +82,7 @@ export const ZJsWebsiteConfig = z.object({
export type TJsWebsiteConfig = z.infer<typeof ZJsWebsiteConfig>;
export const ZJSAppConfig = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
apiHost: z.string(),
userId: z.string(),
state: ZJsAppState,
@@ -93,7 +93,7 @@ export const ZJSAppConfig = z.object({
export type TJSAppConfig = z.infer<typeof ZJSAppConfig>;
export const ZJsWebsiteConfigUpdateInput = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
apiHost: z.string(),
state: ZJsWebsiteState,
expiresAt: z.date(),
@@ -103,7 +103,7 @@ export const ZJsWebsiteConfigUpdateInput = z.object({
export type TJsWebsiteConfigUpdateInput = z.infer<typeof ZJsWebsiteConfigUpdateInput>;
export const ZJsAppConfigUpdateInput = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
apiHost: z.string(),
userId: z.string(),
state: ZJsAppState,
@@ -114,7 +114,7 @@ export const ZJsAppConfigUpdateInput = z.object({
export type TJsAppConfigUpdateInput = z.infer<typeof ZJsAppConfigUpdateInput>;
export const ZJsWebsiteConfigInput = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
apiHost: z.string(),
errorHandler: z.function().args(z.any()).returns(z.void()).optional(),
attributes: z.record(z.string()).optional(),
@@ -123,7 +123,7 @@ export const ZJsWebsiteConfigInput = z.object({
export type TJsWebsiteConfigInput = z.infer<typeof ZJsWebsiteConfigInput>;
export const ZJsAppConfigInput = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
apiHost: z.string(),
errorHandler: z.function().args(z.any()).returns(z.void()).optional(),
userId: z.string(),
@@ -133,7 +133,7 @@ export const ZJsAppConfigInput = z.object({
export type TJsAppConfigInput = z.infer<typeof ZJsAppConfigInput>;
export const ZJsPeopleUserIdInput = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
userId: z.string().min(1).max(255),
version: z.string().optional(),
});
@@ -154,7 +154,7 @@ export const ZJsPeopleAttributeInput = z.object({
export type TJsPeopleAttributeInput = z.infer<typeof ZJsPeopleAttributeInput>;
export const ZJsActionInput = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
userId: z.string().optional(),
name: z.string(),
});
@@ -166,7 +166,7 @@ export const ZJsWesbiteActionInput = ZJsActionInput.omit({ userId: true });
export type TJsWesbiteActionInput = z.infer<typeof ZJsWesbiteActionInput>;
export const ZJsAppSyncParams = z.object({
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
apiHost: z.string(),
userId: z.string(),
attributes: ZAttributes.optional(),

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

@@ -5,7 +5,7 @@ export type TAccessType = z.infer<typeof ZAccessType>;
export const ZStorageRetrievalParams = z.object({
fileName: z.string(),
environmentId: z.string().cuid(),
environmentId: z.string().cuid2(),
accessType: ZAccessType,
});

View File

@@ -62,6 +62,23 @@ export const validateQuestionLabels = (
questionIndex: number,
skipArticle = false
): z.IssueData | null => {
// fieldLabel should contain all the keys present in languages
// even if one of the keys is an empty string, its okay but it shouldn't be undefined
for (const language of languages) {
if (
!language.default &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- could be undefined
fieldLabel[language.language.code] === undefined
) {
return {
code: z.ZodIssueCode.custom,
message: `The ${field} in question ${String(questionIndex + 1)} is not present for the following languages: ${language.language.code}`,
path: ["questions", questionIndex, field],
};
}
}
const invalidLanguageCodes = validateLabelForAllLanguages(fieldLabel, languages);
const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default";
@@ -69,10 +86,14 @@ export const validateQuestionLabels = (
const messageField = FIELD_TO_LABEL_MAP[field] ? FIELD_TO_LABEL_MAP[field] : field;
const messageSuffix = isDefaultOnly ? " is missing" : " is missing for the following languages: ";
const message = isDefaultOnly
? `${messagePrefix}${messageField} in question ${String(questionIndex + 1)}${messageSuffix}`
: `${messagePrefix}${messageField} in question ${String(questionIndex + 1)}${messageSuffix} -fLang- ${invalidLanguageCodes.join()}`;
if (invalidLanguageCodes.length) {
return {
code: z.ZodIssueCode.custom,
message: `${messagePrefix}${messageField} in question ${String(questionIndex + 1)}${messageSuffix}`,
message,
path: ["questions", questionIndex, field],
params: isDefaultOnly ? undefined : { invalidLanguageCodes },
};
@@ -89,6 +110,27 @@ export const validateCardFieldsForAllLanguages = (
endingCardIndex?: number,
skipArticle = false
): z.IssueData | null => {
// fieldLabel should contain all the keys present in languages
// even if one of the keys is an empty string, its okay but it shouldn't be undefined
const cardTypeLabel =
cardType === "welcome" ? "Welcome card" : `Redirect to Url ${((endingCardIndex ?? -1) + 1).toString()}`;
const path = cardType === "welcome" ? ["welcomeCard", field] : ["endings", endingCardIndex ?? -1, field];
for (const language of languages) {
if (
!language.default &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- could be undefined
fieldLabel[language.language.code] === undefined
) {
return {
code: z.ZodIssueCode.custom,
message: `The ${field} in ${cardTypeLabel} is not present for the following languages: ${language.language.code}`,
path,
};
}
}
const invalidLanguageCodes = validateLabelForAllLanguages(fieldLabel, languages);
const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default";
@@ -96,15 +138,15 @@ export const validateCardFieldsForAllLanguages = (
const messageField = FIELD_TO_LABEL_MAP[field] ? FIELD_TO_LABEL_MAP[field] : field;
const messageSuffix = isDefaultOnly ? " is missing" : " is missing for the following languages: ";
const message = isDefaultOnly
? `${messagePrefix}${messageField} on the ${cardTypeLabel}${messageSuffix}`
: `${messagePrefix}${messageField} on the ${cardTypeLabel}${messageSuffix} -fLang- ${invalidLanguageCodes.join()}`;
if (invalidLanguageCodes.length) {
return {
code: z.ZodIssueCode.custom,
message: `${messagePrefix}${messageField} on the ${
cardType === "welcome"
? "Welcome card"
: `Redirect to Url ${((endingCardIndex ?? -1) + 1).toString()}`
} ${messageSuffix}`,
path: cardType === "welcome" ? ["welcomeCard", field] : ["endings", endingCardIndex ?? -1, field],
message,
path,
params: isDefaultOnly ? undefined : { invalidLanguageCodes },
};
}

View File

@@ -428,7 +428,7 @@ export const QuestionFormInput = ({
{showImageUploader && id === "headline" && (
<FileInput
id="question-image"
allowedFileExtensions={["png", "jpeg", "jpg"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={localSurvey.environmentId}
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
if (url) {

View File

@@ -51,7 +51,7 @@ export const SingleResponseCardHeader = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent avoidCollisions align="start" side="bottom">
<TooltipContent avoidCollisions align="start" side="bottom" className="max-w-[75vw]">
{tooltipContent}
</TooltipContent>
</Tooltip>
@@ -78,7 +78,10 @@ export const SingleResponseCardHeader = ({
<div>
<p className="py-1 font-bold text-slate-700">Person attributes:</p>
{Object.keys(response.personAttributes).map((key) => (
<p key={key}>
<p
key={key}
className="truncate"
title={`${key}: ${response.personAttributes && response.personAttributes[key]}`}>
{key}:{" "}
<span className="font-bold">{response.personAttributes && response.personAttributes[key]}</span>
</p>
@@ -92,18 +95,44 @@ export const SingleResponseCardHeader = ({
<hr className="my-2 border-slate-200" />
)}
<p className="py-1 font-bold text-slate-700">Device info:</p>
{response.meta.userAgent?.browser && <p>Browser: {response.meta.userAgent.browser}</p>}
{response.meta.userAgent?.os && <p>OS: {response.meta.userAgent.os}</p>}
{response.meta.userAgent?.browser && (
<p className="truncate" title={`Browser: ${response.meta.userAgent.browser}`}>
Browser: {response.meta.userAgent.browser}
</p>
)}
{response.meta.userAgent?.os && (
<p className="truncate" title={`OS: ${response.meta.userAgent.os}`}>
OS: {response.meta.userAgent.os}
</p>
)}
{response.meta.userAgent && (
<p>
<p
className="truncate"
title={`Device: ${response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}`}>
Device:{" "}
{response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}
</p>
)}
{response.meta.url && <p>URL: {response.meta.url}</p>}
{response.meta.action && <p>Action: {response.meta.action}</p>}
{response.meta.source && <p>Source: {response.meta.source}</p>}
{response.meta.country && <p>Country: {response.meta.country}</p>}
{response.meta.url && (
<p className="truncate" title={`URL: ${response.meta.url}`}>
URL: {response.meta.url}
</p>
)}
{response.meta.action && (
<p className="truncate" title={`Action: ${response.meta.action}`}>
Action: {response.meta.action}
</p>
)}
{response.meta.source && (
<p className="truncate" title={`Source: ${response.meta.source}`}>
Source: {response.meta.source}
</p>
)}
{response.meta.country && (
<p className="truncate" title={`Country: ${response.meta.country}`}>
Country: {response.meta.country}
</p>
)}
</div>
)}
</>

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

View File

@@ -1,12 +1,16 @@
import { Globe, PlusIcon, TrashIcon } from "lucide-react";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { Control, FieldArrayWithId, UseFieldArrayRemove, useFieldArray } from "react-hook-form";
import { UseFormReturn } from "react-hook-form";
import {
Control,
FieldArrayWithId,
UseFieldArrayRemove,
UseFormReturn,
useFieldArray,
} from "react-hook-form";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { testURLmatch } from "@formbricks/lib/utils/url";
import { TActionClassInput, TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import { Alert, AlertDescription, AlertTitle } from "../../../Alert";
import { Button } from "../../../Button";
import { FormControl, FormField, FormItem } from "../../../Form";
import { Input } from "../../../Input";
@@ -64,7 +68,7 @@ export const PageUrlSelector = ({ form }: PageUrlSelectorProps) => {
name="noCodeConfig.urlFilters"
render={() => (
<div>
<Label className="font-semibold">Filter</Label>
<Label className="font-semibold">Page Filter</Label>
<p className="text-sm font-normal text-slate-500">
Limit the pages on which this action gets captured
</p>
@@ -83,7 +87,7 @@ export const PageUrlSelector = ({ form }: PageUrlSelectorProps) => {
)}
/>
</div>
{filterType === "specific" ? (
{filterType === "specific" && (
<div className="mb-2 mt-4 w-full space-y-3 pe-2">
<Label>URL</Label>
<UrlInput control={form.control} fields={fields} removeUrlRule={removeUrlRule} />
@@ -130,14 +134,6 @@ export const PageUrlSelector = ({ form }: PageUrlSelectorProps) => {
</div>
</div>
</div>
) : (
<div className="mr-2">
<Alert className="my-2">
<Globe className="h-4 w-4" />
<AlertTitle>Visible on all pages</AlertTitle>
<AlertDescription>This action will be captured on all pages of your website</AlertDescription>
</Alert>
</div>
)}
</>
);