mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-21 00:46:39 -05:00
Compare commits
38 Commits
v2.5.1
...
version-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
435dbf2261 | ||
|
|
df74510756 | ||
|
|
c9b8ffa9ef | ||
|
|
57135d167b | ||
|
|
916e47c55d | ||
|
|
04e16d44a1 | ||
|
|
29131f93c2 | ||
|
|
1e2fe7b066 | ||
|
|
426a0a3847 | ||
|
|
80ef504bef | ||
|
|
6ab83e25d3 | ||
|
|
688dc25990 | ||
|
|
5faccf1063 | ||
|
|
ebbc5abd3c | ||
|
|
c53b58c64f | ||
|
|
ad445d1915 | ||
|
|
61d18edb5d | ||
|
|
756d0c84af | ||
|
|
8f6b356870 | ||
|
|
e0ba9cb998 | ||
|
|
07b5dfe28a | ||
|
|
c6f1e9e7d5 | ||
|
|
f55cad0121 | ||
|
|
494299cd89 | ||
|
|
5cc071e5a8 | ||
|
|
0532f2744b | ||
|
|
f025a70d20 | ||
|
|
43ea26a33a | ||
|
|
ba1e478d68 | ||
|
|
ec54e40a8b | ||
|
|
4b508f02e3 | ||
|
|
eec7e1b62a | ||
|
|
8e57082376 | ||
|
|
2681d03e15 | ||
|
|
39e87eb8d3 | ||
|
|
72e023c530 | ||
|
|
7088f9bd26 | ||
|
|
780115ffb8 |
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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`}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)();
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -66,6 +66,8 @@ const mapResponsesToTableData = (responses: TResponse[], survey: TSurvey): TResp
|
||||
notes: response.notes,
|
||||
verifiedEmail: typeof response.data["verifiedEmail"] === "string" ? response.data["verifiedEmail"] : "",
|
||||
language: response.language,
|
||||
person: response.person,
|
||||
personAttributes: response.personAttributes,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { QUESTIONS_ICON_MAP } from "@/app/lib/questions";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { processResponseData } from "@formbricks/lib/responses";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
@@ -11,6 +13,7 @@ import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Checkbox } from "@formbricks/ui/Checkbox";
|
||||
import { ResponseBadges } from "@formbricks/ui/ResponseBadges";
|
||||
import { RenderResponse } from "@formbricks/ui/SingleResponseCard/components/RenderResponse";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
|
||||
const getAddressFieldLabel = (field: string) => {
|
||||
switch (field) {
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -6,20 +6,25 @@ interface SummaryMetadataProps {
|
||||
setShowDropOffs: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showDropOffs: boolean;
|
||||
surveySummary: TSurveySummary["meta"];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const StatCard = ({ label, percentage, value, tooltipText }) => (
|
||||
const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<p className="flex items-center gap-1 text-sm text-slate-600">
|
||||
{label}
|
||||
{typeof percentage === "number" && !isNaN(percentage) && (
|
||||
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
|
||||
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{percentage}%</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">{value}</p>
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-slate-800">{value}</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -44,7 +49,12 @@ const formatTime = (ttc) => {
|
||||
return formattedValue;
|
||||
};
|
||||
|
||||
export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary }: SummaryMetadataProps) => {
|
||||
export const SummaryMetadata = ({
|
||||
setShowDropOffs,
|
||||
showDropOffs,
|
||||
surveySummary,
|
||||
isLoading,
|
||||
}: SummaryMetadataProps) => {
|
||||
const {
|
||||
completedPercentage,
|
||||
completedResponses,
|
||||
@@ -64,18 +74,21 @@ export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary }
|
||||
percentage={null}
|
||||
value={displayCount === 0 ? <span>-</span> : displayCount}
|
||||
tooltipText="Number of times the survey has been viewed."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label="Starts"
|
||||
percentage={Math.round(startsPercentage) > 100 ? null : Math.round(startsPercentage)}
|
||||
value={totalResponses === 0 ? <span>-</span> : totalResponses}
|
||||
tooltipText="Number of times the survey has been started."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label="Completed"
|
||||
percentage={Math.round(completedPercentage) > 100 ? null : Math.round(completedPercentage)}
|
||||
value={completedResponses === 0 ? <span>-</span> : completedResponses}
|
||||
tooltipText="Number of times the survey has been completed."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<TooltipProvider delayDuration={50}>
|
||||
@@ -86,21 +99,29 @@ export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary }
|
||||
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<span className="text-sm text-slate-600">
|
||||
Drop-Offs
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && (
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{`${Math.round(dropOffPercentage)}%`}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex w-full items-end justify-between">
|
||||
<span className="text-2xl font-bold text-slate-800">
|
||||
{dropOffCount === 0 ? <span>-</span> : dropOffCount}
|
||||
</span>
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : dropOffCount === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
dropOffCount
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -114,6 +135,7 @@ export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary }
|
||||
percentage={null}
|
||||
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
||||
tooltipText="Average time to complete the survey."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,7 @@ export const SummaryPage = ({
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
|
||||
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
@@ -104,28 +105,39 @@ export const SummaryPage = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleInitialData = async () => {
|
||||
const handleInitialData = async (isInitialLoad = false) => {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedResponseCountData = await getResponseCount();
|
||||
const updatedSurveySummary = await getSummary();
|
||||
const [updatedResponseCountData, updatedSurveySummary] = await Promise.all([
|
||||
getResponseCount(),
|
||||
getSummary(),
|
||||
]);
|
||||
|
||||
const responseCount = updatedResponseCountData?.data ?? 0;
|
||||
const surveySummary = updatedSurveySummary?.data ?? initialSurveySummary;
|
||||
|
||||
// Update the state with new data
|
||||
setResponseCount(responseCount);
|
||||
setSurveySummary(surveySummary);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleInitialData();
|
||||
handleInitialData(true);
|
||||
}, [JSON.stringify(filters), isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
useIntervalWhenFocused(
|
||||
() => {
|
||||
handleInitialData();
|
||||
handleInitialData(false);
|
||||
},
|
||||
10000,
|
||||
!isShareEmbedModalOpen,
|
||||
@@ -148,6 +160,7 @@ export const SummaryPage = ({
|
||||
surveySummary={surveySummary.meta}
|
||||
showDropOffs={showDropOffs}
|
||||
setShowDropOffs={setShowDropOffs}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} />}
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "2.5.1",
|
||||
"version": "2.5.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
|
||||
@@ -169,9 +169,9 @@ test.describe("Survey Create & Submit Response", async () => {
|
||||
await expect(page.getByRole("cell", { name: surveys.createAndSubmit.matrix.columns[3] })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("row", { name: "Rose 🌹" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Sunflower 🌻" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Hibiscus 🌺" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Roses" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Trees" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Ocean" }).getByRole("cell").nth(1).click();
|
||||
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Address Question
|
||||
@@ -309,10 +309,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.openTextQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").press("Tab");
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").click();
|
||||
await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder);
|
||||
|
||||
@@ -322,10 +318,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.singleSelectQuestion.question);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").click();
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").click();
|
||||
@@ -351,10 +343,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.pictureSelectQuestion.question);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.pictureSelectQuestion.description);
|
||||
|
||||
// Fill Rating question in german
|
||||
await page.getByRole("main").getByText("Rating").click();
|
||||
@@ -362,10 +350,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.ratingQuestion.question);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").click();
|
||||
await page.getByPlaceholder("Not good").fill(surveys.germanCreate.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").click();
|
||||
@@ -398,10 +382,6 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByRole("main").getByText("Matrix").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.matrix.question);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.matrix.description);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(surveys.germanCreate.matrix.rows[0]);
|
||||
await page.locator("#row-1").click();
|
||||
|
||||
@@ -153,6 +153,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
await page.getByRole("main").getByText("What would you like to know?").click();
|
||||
|
||||
await page.getByLabel("Question*").fill(params.openTextQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
|
||||
@@ -166,6 +167,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Single-Select" }).click();
|
||||
await page.getByLabel("Question*").fill(params.singleSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||
@@ -193,6 +195,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await page.getByLabel("Question*").fill(params.ratingQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
||||
@@ -236,6 +239,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||
await page.getByLabel("Question*").fill(params.pictureSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.pictureSelectQuestion.description);
|
||||
|
||||
// File Upload Question
|
||||
@@ -255,6 +259,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Matrix" }).click();
|
||||
await page.getByLabel("Question*").fill(params.matrix.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.matrix.description);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
||||
|
||||
@@ -150,7 +150,7 @@ export const surveys = {
|
||||
matrix: {
|
||||
question: "How much do you love these flowers?",
|
||||
description: "0: Not at all, 3: Love it",
|
||||
rows: ["Rose 🌹", "Sunflower 🌻", "Hibiscus 🌺"],
|
||||
rows: ["Roses", "Trees", "Ocean"],
|
||||
columns: ["0", "1", "2", "3"],
|
||||
},
|
||||
address: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const CUSTOMER_IO_SITE_ID = env.CUSTOMER_IO_SITE_ID;
|
||||
export const CUSTOMER_IO_API_KEY = env.CUSTOMER_IO_API_KEY;
|
||||
export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY;
|
||||
export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
|
||||
|
||||
export const STRIPE_API_VERSION = "2024-06-20";
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ZResponseInput,
|
||||
ZResponseUpdateInput,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getAttributes } from "../attribute/service";
|
||||
import { cache } from "../cache";
|
||||
@@ -28,7 +28,7 @@ import { createPerson, getPersonByUserId } from "../person/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "../posthogServer";
|
||||
import { responseNoteCache } from "../responseNote/cache";
|
||||
import { getResponseNotes } from "../responseNote/service";
|
||||
import { putFile } from "../storage/service";
|
||||
import { deleteFile, putFile } from "../storage/service";
|
||||
import { getSurvey } from "../survey/service";
|
||||
import { captureTelemetry } from "../telemetry";
|
||||
import { convertToCsv, convertToXlsxBuffer } from "../utils/fileConversion";
|
||||
@@ -697,6 +697,35 @@ export const updateResponse = async (
|
||||
}
|
||||
};
|
||||
|
||||
const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey: TSurvey): Promise<void> => {
|
||||
const fileUploadQuestions = new Set(
|
||||
survey.questions
|
||||
.filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload)
|
||||
.map((q) => q.id)
|
||||
);
|
||||
|
||||
const fileUrls = Object.entries(response.data)
|
||||
.filter(([questionId]) => fileUploadQuestions.has(questionId))
|
||||
.flatMap(([, questionResponse]) => questionResponse as string[]);
|
||||
|
||||
const deletionPromises = fileUrls.map(async (fileUrl) => {
|
||||
try {
|
||||
const { pathname } = new URL(fileUrl);
|
||||
const [, environmentId, accessType, fileName] = pathname.split("/").filter(Boolean);
|
||||
|
||||
if (!environmentId || !accessType || !fileName) {
|
||||
throw new Error(`Invalid file path: ${pathname}`);
|
||||
}
|
||||
|
||||
return deleteFile(environmentId, accessType as "private" | "public", fileName);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete file ${fileUrl}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(deletionPromises);
|
||||
};
|
||||
|
||||
export const deleteResponse = async (responseId: string): Promise<TResponse> => {
|
||||
validateInputs([responseId, ZId]);
|
||||
try {
|
||||
@@ -718,6 +747,16 @@ export const deleteResponse = async (responseId: string): Promise<TResponse> =>
|
||||
|
||||
const survey = await getSurvey(response.surveyId);
|
||||
|
||||
if (survey) {
|
||||
await findAndDeleteUploadedFilesInResponse(
|
||||
{
|
||||
...responsePrisma,
|
||||
tags: responsePrisma.tags.map((tag) => tag.tag),
|
||||
},
|
||||
survey
|
||||
);
|
||||
}
|
||||
|
||||
responseCache.revalidate({
|
||||
environmentId: survey?.environmentId,
|
||||
id: response.id,
|
||||
|
||||
@@ -571,7 +571,42 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
id: segment.id,
|
||||
environmentId: segment.environmentId,
|
||||
});
|
||||
} else if (type === "app") {
|
||||
if (!currentSurvey.segment) {
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
environmentId_title: {
|
||||
environmentId,
|
||||
title: surveyId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
title: surveyId,
|
||||
isPrivate: true,
|
||||
filters: [],
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
segmentCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
data.questions = questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
|
||||
@@ -2526,7 +2526,6 @@ export const customSurvey = {
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "What would you like to know?" },
|
||||
subheader: { default: "This is an example survey." },
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
|
||||
@@ -120,7 +120,7 @@ export const MatrixQuestion = ({
|
||||
// Table rows
|
||||
<tr className={`${rowIndex % 2 === 0 ? "bg-input-bg" : ""}`}>
|
||||
<td
|
||||
className="fb-text-heading fb-rounded-l-custom fb-max-w-40 fb-break-words fb-px-4 fb-py-2"
|
||||
className="fb-text-heading fb-rounded-l-custom fb-max-w-40 fb-break-words fb-pr-4 fb-pl-2 fb-py-2"
|
||||
dir="auto">
|
||||
{getLocalizedValue(row, languageCode)}
|
||||
</td>
|
||||
@@ -151,7 +151,7 @@ export const MatrixQuestion = ({
|
||||
dir="auto"
|
||||
type="radio"
|
||||
tabIndex={-1}
|
||||
required={true}
|
||||
required={question.required}
|
||||
id={`${row}-${column}`}
|
||||
name={getLocalizedValue(row, languageCode)}
|
||||
value={getLocalizedValue(column, languageCode)}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const ZAllowedFileExtension = z.enum([
|
||||
"png",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"webp",
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user