feat: Advanced Targeting (#758)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-02-13 00:38:47 +05:30
committed by GitHub
parent 000a11c8bc
commit bf51e578b2
111 changed files with 9128 additions and 1041 deletions

View File

@@ -23,8 +23,8 @@ export default function AppPage({}) {
useEffect(() => {
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const isUserId = window.location.href.includes("userId=true");
const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined;
const attributes = isUserId ? { "Init Attribute 1": "eight", "Init Attribute 2": "two" } : undefined;
const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined;
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,

View File

@@ -23,7 +23,7 @@ const inProductSurveys = {
{ name: "30+ Templates", free: true, paid: true },
{ name: "Unlimited Responses per Survey", free: false, paid: true },
{ name: "Team Role Management", free: false, paid: true },
{ name: "Advanced User Targeting", free: false, paid: true, comingSoon: true },
{ name: "Advanced Targeting", free: false, paid: true, comingSoon: false },
{ name: "Multi Language Surveys", free: false, paid: true, comingSoon: true },
],
endRow: {
@@ -58,8 +58,8 @@ const userSegmentation = {
{ name: "Identify Users", free: true, paid: true },
{ name: "Collect Events", free: true, paid: true },
{ name: "Collect Attributes", free: true, paid: true },
{ name: "Advanced User Targeting", free: false, paid: true, comingSoon: true },
{ name: "Reusable Segments", free: false, paid: true, comingSoon: true },
{ name: "Advanced Targeting", free: false, paid: true, comingSoon: false },
{ name: "Reusable Segments", free: true, paid: false, comingSoon: false },
],
endRow: {
title: "User Segmentation like Segment",

View File

@@ -1,5 +1,4 @@
/** @type {import('tailwindcss').Config} */
import base from "../../packages/tailwind-config/tailwind.config";
export default {

View File

@@ -116,7 +116,7 @@ export const getActionCountInLast7DaysAction = async (actionClassId: string, env
return await getActionCountInLast7Days(actionClassId);
};
export const GetActiveInactiveSurveysAction = async (
export const getActiveInactiveSurveysAction = async (
actionClassId: string,
environmentId: string
): Promise<{ activeSurveys: string[]; inactiveSurveys: string[] }> => {

View File

@@ -11,10 +11,10 @@ import { Label } from "@formbricks/ui/Label";
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
import {
GetActiveInactiveSurveysAction,
getActionCountInLast7DaysAction,
getActionCountInLast24HoursAction,
getActionCountInLastHourAction,
getActiveInactiveSurveysAction,
} from "../actions";
interface ActivityTabProps {
@@ -49,7 +49,7 @@ export default function EventActivityTab({ actionClass, environmentId }: Activit
getActionCountInLastHourAction(actionClass.id, environmentId),
getActionCountInLast24HoursAction(actionClass.id, environmentId),
getActionCountInLast7DaysAction(actionClass.id, environmentId),
GetActiveInactiveSurveysAction(actionClass.id, environmentId),
getActiveInactiveSurveysAction(actionClass.id, environmentId),
]);
setNumEventsLastHour(numEventsLastHourData);
setNumEventsLast24Hours(numEventsLast24HoursData);

View File

@@ -4,22 +4,39 @@ import { getServerSession } from "next-auth";
import { canUserAccessAttributeClass } from "@formbricks/lib/attributeClass/auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getSurveysByAttributeClassId } from "@formbricks/lib/survey/service";
import { getSegmentsByAttributeClassName } from "@formbricks/lib/segment/service";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { AuthorizationError } from "@formbricks/types/errors";
export const GetActiveInactiveSurveysAction = async (
attributeClassId: string
export const getSegmentsByAttributeClassAction = async (
environmentId: string,
attributeClass: TAttributeClass
): Promise<{ activeSurveys: string[]; inactiveSurveys: string[] }> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
try {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessAttributeClass(session.user.id, attributeClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessAttributeClass(session.user.id, attributeClass.id);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const segments = await getSegmentsByAttributeClassName(environmentId, attributeClass.name);
const surveys = await getSurveysByAttributeClassId(attributeClassId);
const response = {
activeSurveys: surveys.filter((s) => s.status === "inProgress").map((survey) => survey.name),
inactiveSurveys: surveys.filter((s) => s.status !== "inProgress").map((survey) => survey.name),
};
return response;
// segments is an array of segments, each segment has a survey array with objects with properties: id, name and status.
// We need the name of the surveys only and we need to filter out the surveys that are both in progress and not in progress.
const activeSurveys = segments
.map((segment) =>
segment.surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name)
)
.flat();
const inactiveSurveys = segments
.map((segment) =>
segment.surveys.filter((survey) => survey.status !== "inProgress").map((survey) => survey.name)
)
.flat();
return { activeSurveys, inactiveSurveys };
} catch (err) {
console.log(err);
throw err;
}
};

View File

@@ -1,6 +1,6 @@
"use client";
import { GetActiveInactiveSurveysAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions";
import { getSegmentsByAttributeClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions";
import { TagIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
@@ -29,16 +29,20 @@ export default function AttributeActivityTab({ attributeClass }: EventActivityTa
async function getSurveys() {
try {
setLoading(true);
const activeInactive = await GetActiveInactiveSurveysAction(attributeClass.id);
setActiveSurveys(activeInactive.activeSurveys);
setInactiveSurveys(activeInactive.inactiveSurveys);
const segmentsWithAttributeClassName = await getSegmentsByAttributeClassAction(
attributeClass.environmentId,
attributeClass
);
setActiveSurveys(segmentsWithAttributeClassName.activeSurveys);
setInactiveSurveys(segmentsWithAttributeClassName.inactiveSurveys);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}
}, [attributeClass.id]);
}, [attributeClass, attributeClass.environmentId, attributeClass.id, attributeClass.name]);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorComponent />;

View File

@@ -18,7 +18,7 @@ export default function AttributeClassesTable({
}) {
const [isAttributeDetailModalOpen, setAttributeDetailModalOpen] = useState(false);
const [isUploadCSVModalOpen, setUploadCSVModalOpen] = useState(false);
const [activeAttributeClass, setActiveAttributeClass] = useState("" as any);
const [activeAttributeClass, setActiveAttributeClass] = useState<TAttributeClass | null>(null);
const [showArchived, setShowArchived] = useState(false);
const displayedAttributeClasses = useMemo(() => {
@@ -33,8 +33,7 @@ export default function AttributeClassesTable({
return attributeClasses ? attributeClasses.some((ac) => ac.archived) : false;
}, [attributeClasses]);
const handleOpenAttributeDetailModalClick = (e, attributeClass) => {
e.preventDefault();
const handleOpenAttributeDetailModalClick = (attributeClass: TAttributeClass) => {
setActiveAttributeClass(attributeClass);
setAttributeDetailModalOpen(true);
};
@@ -59,20 +58,21 @@ export default function AttributeClassesTable({
<div className="grid-cols-7">
{displayedAttributeClasses.map((attributeClass, index) => (
<button
onClick={(e) => {
handleOpenAttributeDetailModalClick(e, attributeClass);
}}
onClick={() => handleOpenAttributeDetailModalClick(attributeClass)}
className="w-full"
key={attributeClass.id}>
{attributeRows[index]}
</button>
))}
</div>
<AttributeDetailModal
open={isAttributeDetailModalOpen}
setOpen={setAttributeDetailModalOpen}
attributeClass={activeAttributeClass}
/>
{activeAttributeClass && (
<AttributeDetailModal
open={isAttributeDetailModalOpen}
setOpen={setAttributeDetailModalOpen}
attributeClass={activeAttributeClass}
/>
)}
<UploadAttributesModal open={isUploadCSVModalOpen} setOpen={setUploadCSVModalOpen} />
</div>
</>

View File

@@ -0,0 +1,27 @@
import SecondNavbar from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/components/SecondNavbar";
import { UserGroupIcon, UserIcon } from "@heroicons/react/24/solid";
interface PeopleSegmentsTabsProps {
activeId: string;
environmentId: string;
isUserTargetingAllowed?: boolean;
}
export default function PeopleSegmentsTabs({ activeId, environmentId }: PeopleSegmentsTabsProps) {
let tabs = [
{
id: "people",
label: "People",
icon: <UserIcon />,
href: `/environments/${environmentId}/people`,
},
{
id: "segments",
label: "Segments",
icon: <UserGroupIcon />,
href: `/environments/${environmentId}/segments`,
},
];
return <SecondNavbar tabs={tabs} activeId={activeId} environmentId={environmentId} />;
}

View File

@@ -1,4 +1,4 @@
import ActivityTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/components/ActivityTimeline";
import ActivityTimeline from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ActivityTimeline";
import { getActionsByPersonId } from "@formbricks/lib/action/service";
import { getEnvironment } from "@formbricks/lib/environment/service";

View File

@@ -1,6 +1,6 @@
"use client";
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/people/[personId]/actions";
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/actions";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";

View File

@@ -1,4 +1,4 @@
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/components/ResponseTimeline";
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponseTimeline";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";

View File

@@ -1,5 +1,6 @@
"use client";
import ResponseFeed from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useEffect, useState } from "react";
@@ -9,8 +10,6 @@ import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
import ResponseFeed from "./ResponsesFeed";
export default function ResponseTimeline({
surveys,
user,

View File

@@ -1,7 +1,7 @@
import {
ActivityItemIcon,
ActivityItemPopover,
} from "@/app/(app)/environments/[environmentId]/people/[personId]/components/ActivityItemComponents";
} from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ActivityItemComponents";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { TrashIcon } from "lucide-react";

View File

@@ -1,7 +1,7 @@
import ActivitySection from "@/app/(app)/environments/[environmentId]/people/[personId]/components/ActivitySection";
import AttributesSection from "@/app/(app)/environments/[environmentId]/people/[personId]/components/AttributesSection";
import HeadingSection from "@/app/(app)/environments/[environmentId]/people/[personId]/components/HeadingSection";
import ResponseSection from "@/app/(app)/environments/[environmentId]/people/[personId]/components/ResponseSection";
import ActivitySection from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ActivitySection";
import AttributesSection from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/AttributesSection";
import HeadingSection from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/HeadingSection";
import ResponseSection from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponseSection";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";

View File

@@ -0,0 +1,17 @@
import PeopleSegmentsTabs from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/PeopleSegmentsTabs";
import { Metadata } from "next";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
export const metadata: Metadata = {
title: "People",
};
export default async function PeopleLayout({ params, children }) {
return (
<>
<PeopleSegmentsTabs activeId="people" environmentId={params.environmentId} />
<ContentWrapper>{children}</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,248 @@
"use client";
import { UserGroupIcon } from "@heroicons/react/20/solid";
import { FilterIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/actions";
import { cn } from "@formbricks/lib/cn";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TBaseFilter, TSegment } from "@formbricks/types/segment";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Modal } from "@formbricks/ui/Modal";
import BasicAddFilterModal from "@formbricks/ui/Targeting/BasicAddFilterModal";
import BasicSegmentEditor from "@formbricks/ui/Targeting/BasicSegmentEditor";
import { UpgradePlanNotice } from "@formbricks/ui/UpgradePlanNotice";
type TCreateSegmentModalProps = {
environmentId: string;
attributeClasses: TAttributeClass[];
isFormbricksCloud: boolean;
};
const BasicCreateSegmentModal = ({
environmentId,
attributeClasses,
isFormbricksCloud,
}: TCreateSegmentModalProps) => {
const router = useRouter();
const initialSegmentState = {
title: "",
description: "",
isPrivate: false,
filters: [],
environmentId,
id: "",
surveys: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const [open, setOpen] = useState(false);
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [segment, setSegment] = useState<TSegment>(initialSegmentState);
const [isCreatingSegment, setIsCreatingSegment] = useState(false);
const [titleError, setTitleError] = useState("");
const handleResetState = () => {
setSegment(initialSegmentState);
setTitleError("");
setOpen(false);
};
const handleAddFilterInGroup = (filter: TBaseFilter) => {
const updatedSegment = structuredClone(segment);
if (updatedSegment?.filters?.length === 0) {
updatedSegment.filters.push({
...filter,
connector: null,
});
} else {
updatedSegment?.filters.push(filter);
}
setSegment(updatedSegment);
};
const handleCreateSegment = async () => {
if (!segment.title) {
setTitleError("Title is required");
return;
}
try {
setIsCreatingSegment(true);
await createSegmentAction({
title: segment.title,
description: segment.description ?? "",
isPrivate: segment.isPrivate,
filters: segment.filters,
environmentId,
surveyId: "",
});
setIsCreatingSegment(false);
toast.success("Segment created successfully!");
} catch (err: any) {
toast.error(`${err.message}`);
setIsCreatingSegment(false);
return;
}
handleResetState();
setIsCreatingSegment(false);
router.refresh();
};
return (
<>
<div className="mb-4 flex justify-end">
<Button variant="darkCTA" onClick={() => setOpen(true)}>
Create Segment
</Button>
</div>
<Modal
open={open}
setOpen={() => {
handleResetState();
}}
noPadding
closeOnOutsideClick={false}
size="lg">
<div className="rounded-lg bg-slate-50">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<UserGroupIcon />
</div>
<div>
<h3 className="text-base font-medium">Create Segment</h3>
<p className="text-sm text-slate-600">
Segments help you target the users with the same characteristics easily.
</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col overflow-auto rounded-lg bg-white p-6">
<div className="flex w-full items-center gap-4">
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">Title</label>
<div className="relative flex flex-col gap-1">
<Input
placeholder="Ex. Power Users"
onChange={(e) => {
setSegment((prev) => ({
...prev,
title: e.target.value,
}));
}}
className={cn(titleError && "border border-red-500 focus:border-red-500")}
/>
{titleError && (
<p className="absolute right-1 bg-white text-xs text-red-500" style={{ top: "-8px" }}>
{titleError}
</p>
)}
</div>
</div>
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">Description</label>
<Input
placeholder="Ex. Fully activated recurring users"
onChange={(e) => {
setSegment((prev) => ({
...prev,
description: e.target.value,
}));
}}
/>
</div>
</div>
<label className="my-4 text-sm font-medium text-slate-900">Targeting</label>
<div className="filter-scrollbar flex w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
{segment?.filters?.length === 0 && (
<div className="-mb-2 flex items-center gap-1">
<FilterIcon className="h-5 w-5 text-slate-700" />
<h3 className="text-sm font-medium text-slate-700">Add your first filter to get started</h3>
</div>
)}
<BasicSegmentEditor
environmentId={environmentId}
segment={segment}
setSegment={setSegment}
group={segment.filters}
attributeClasses={attributeClasses}
/>
<Button
className="w-fit"
variant="secondary"
size="sm"
onClick={() => setAddFilterModalOpen(true)}>
Add Filter
</Button>
<BasicAddFilterModal
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
setOpen={setAddFilterModalOpen}
attributeClasses={attributeClasses}
/>
</div>
{isFormbricksCloud ? (
<UpgradePlanNotice
message="For advanced targeting, please"
textForUrl="upgrade to the User Identification plan."
url={`/environments/${environmentId}/settings/billing`}
/>
) : (
<UpgradePlanNotice
message="For advanced targeting, please"
textForUrl="request an Enterprise license."
url="https://formbricks.com/docs/self-hosting/enterprise"
/>
)}
<div className="flex justify-end pt-4">
<div className="flex space-x-2">
<Button
type="button"
variant="minimal"
onClick={() => {
handleResetState();
}}>
Cancel
</Button>
<Button
variant="darkCTA"
type="submit"
loading={isCreatingSegment}
onClick={() => {
handleCreateSegment();
}}>
Create Segment
</Button>
</div>
</div>
</div>
</div>
</Modal>
</>
);
};
export default BasicCreateSegmentModal;

View File

@@ -0,0 +1,263 @@
"use client";
import {
deleteBasicSegmentAction,
updateBasicSegmentAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { isAdvancedSegment } from "@formbricks/lib/segment/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TBaseFilter, TSegment, TSegmentWithSurveyNames, ZSegmentFilters } from "@formbricks/types/segment";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import BasicAddFilterModal from "@formbricks/ui/Targeting/BasicAddFilterModal";
import BasicSegmentEditor from "@formbricks/ui/Targeting/BasicSegmentEditor";
import ConfirmDeleteSegmentModal from "@formbricks/ui/Targeting/ConfirmDeleteSegmentModal";
import { UpgradePlanNotice } from "@formbricks/ui/UpgradePlanNotice";
type TBasicSegmentSettingsTabProps = {
environmentId: string;
setOpen: (open: boolean) => void;
initialSegment: TSegmentWithSurveyNames;
attributeClasses: TAttributeClass[];
isFormbricksCloud: boolean;
};
const BasicSegmentSettings = ({
environmentId,
initialSegment,
setOpen,
attributeClasses,
isFormbricksCloud,
}: TBasicSegmentSettingsTabProps) => {
const router = useRouter();
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [segment, setSegment] = useState<TSegment>(initialSegment);
const [isUpdatingSegment, setIsUpdatingSegment] = useState(false);
const [isDeletingSegment, setIsDeletingSegment] = useState(false);
const [titleError, setTitleError] = useState("");
const [isSaveDisabled, setIsSaveDisabled] = useState(false);
const [isDeleteSegmentModalOpen, setIsDeleteSegmentModalOpen] = useState(false);
const handleResetState = () => {
setSegment(initialSegment);
setOpen(false);
setTitleError("");
router.refresh();
};
const handleAddFilterInGroup = (filter: TBaseFilter) => {
const updatedSegment = structuredClone(segment);
if (updatedSegment?.filters?.length === 0) {
updatedSegment.filters.push({
...filter,
connector: null,
});
} else {
updatedSegment?.filters.push(filter);
}
setSegment(updatedSegment);
};
const handleUpdateSegment = async () => {
if (!segment.title) {
setTitleError("Title is required");
return;
}
try {
setIsUpdatingSegment(true);
await updateBasicSegmentAction(segment.environmentId, segment.id, {
title: segment.title,
description: segment.description ?? "",
isPrivate: segment.isPrivate,
filters: segment.filters,
});
setIsUpdatingSegment(false);
toast.success("Segment updated successfully!");
} catch (err: any) {
toast.error(`${err.message}`);
setIsUpdatingSegment(false);
return;
}
setIsUpdatingSegment(false);
handleResetState();
router.refresh();
};
const handleDeleteSegment = async () => {
try {
setIsDeletingSegment(true);
await deleteBasicSegmentAction(segment.environmentId, segment.id);
setIsDeletingSegment(false);
toast.success("Segment deleted successfully!");
handleResetState();
} catch (err: any) {
toast.error(`${err.message}`);
}
setIsDeletingSegment(false);
};
useEffect(() => {
// parse the filters to check if they are valid
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
setIsSaveDisabled(true);
} else {
setIsSaveDisabled(false);
}
}, [segment]);
if (isAdvancedSegment(segment.filters)) {
return (
<p className="italic text-slate-600">
This is an advanced segment, you cannot edit it. Please upgrade your plan to edit this segment.
</p>
);
}
return (
<>
<div className="mb-4">
<div className="rounded-lg bg-slate-50">
<div className="flex flex-col overflow-auto rounded-lg bg-white">
<div className="flex w-full items-center gap-4">
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">Title</label>
<div className="relative flex flex-col gap-1">
<Input
value={segment.title}
placeholder="Ex. Power Users"
onChange={(e) => {
setSegment((prev) => ({
...prev,
title: e.target.value,
}));
if (e.target.value) {
setTitleError("");
}
}}
className={cn("w-auto", titleError && "border border-red-500 focus:border-red-500")}
/>
{titleError && (
<p className="absolute -bottom-1.5 right-2 bg-white text-xs text-red-500">{titleError}</p>
)}
</div>
</div>
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">Description</label>
<div className="relative flex flex-col gap-1">
<Input
value={segment.description ?? ""}
placeholder="Ex. Power Users"
onChange={(e) => {
setSegment((prev) => ({
...prev,
description: e.target.value,
}));
}}
className={cn("w-auto")}
/>
</div>
</div>
</div>
<label className="my-4 text-sm font-medium text-slate-900">Targeting</label>
<div className="filter-scrollbar flex max-h-96 w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<BasicSegmentEditor
environmentId={environmentId}
segment={segment}
setSegment={setSegment}
group={segment.filters}
attributeClasses={attributeClasses}
/>
<div>
<Button variant="secondary" size="sm" onClick={() => setAddFilterModalOpen(true)}>
Add Filter
</Button>
</div>
<BasicAddFilterModal
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
setOpen={setAddFilterModalOpen}
attributeClasses={attributeClasses}
/>
</div>
{isFormbricksCloud ? (
<UpgradePlanNotice
message="For advanced targeting, please"
textForUrl="upgrade to the User Identification plan."
url={`/environments/${environmentId}/settings/billing`}
/>
) : (
<UpgradePlanNotice
message="For advanced targeting, please"
textForUrl="request an Enterprise license."
url="https://formbricks.com/docs/self-hosting/enterprise"
/>
)}
<div className="flex w-full items-center justify-between pt-4">
<Button
type="button"
variant="warn"
loading={isDeletingSegment}
onClick={() => {
setIsDeleteSegmentModalOpen(true);
}}
EndIcon={Trash2}
endIconClassName="p-0.5">
Delete
</Button>
<Button
variant="darkCTA"
type="submit"
loading={isUpdatingSegment}
onClick={() => {
handleUpdateSegment();
}}
disabled={isSaveDisabled}>
Save Changes
</Button>
</div>
{isDeleteSegmentModalOpen && (
<ConfirmDeleteSegmentModal
onDelete={handleDeleteSegment}
open={isDeleteSegmentModalOpen}
segment={initialSegment}
setOpen={setIsDeleteSegmentModalOpen}
/>
)}
</div>
</div>
</div>
</>
);
};
export default BasicSegmentSettings;

View File

@@ -0,0 +1,86 @@
"use client";
import { UserGroupIcon } from "@heroicons/react/24/solid";
import SegmentSettingsTab from "@formbricks/ee/advancedTargeting/components/SegmentSettings";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import ModalWithTabs from "@formbricks/ui/ModalWithTabs";
import BasicSegmentSettings from "./BasicSegmentSettings";
import SegmentActivityTab from "./SegmentActivityTab";
interface EditSegmentModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
currentSegment: TSegmentWithSurveyNames;
segments: TSegment[];
attributeClasses: TAttributeClass[];
actionClasses: TActionClass[];
isAdvancedTargetingAllowed: boolean;
isFormbricksCloud: boolean;
}
export default function EditSegmentModal({
environmentId,
open,
setOpen,
currentSegment,
actionClasses,
attributeClasses,
segments,
isAdvancedTargetingAllowed,
isFormbricksCloud,
}: EditSegmentModalProps) {
const SettingsTab = () => {
if (isAdvancedTargetingAllowed) {
return (
<SegmentSettingsTab
actionClasses={actionClasses}
attributeClasses={attributeClasses}
environmentId={environmentId}
initialSegment={currentSegment}
segments={segments}
setOpen={setOpen}
/>
);
}
return (
<BasicSegmentSettings
attributeClasses={attributeClasses}
environmentId={environmentId}
initialSegment={currentSegment}
setOpen={setOpen}
isFormbricksCloud={isFormbricksCloud}
/>
);
};
const tabs = [
{
title: "Activity",
children: <SegmentActivityTab environmentId={environmentId} currentSegment={currentSegment} />,
},
{
title: "Settings",
children: <SettingsTab />,
},
];
return (
<>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={<UserGroupIcon />}
label={currentSegment.title}
description={currentSegment.description || ""}
closeOnOutsideClick={false}
/>
</>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { TSegment } from "@formbricks/types/segment";
import { Label } from "@formbricks/ui/Label";
interface SegmentActivityTabProps {
environmentId: string;
currentSegment: TSegment & {
activeSurveys: string[];
inactiveSurveys: string[];
};
}
export default function SegmentActivityTab({ currentSegment }: SegmentActivityTabProps) {
const activeSurveys = currentSegment?.activeSurveys;
const inactiveSurveys = currentSegment?.inactiveSurveys;
return (
<div className="grid grid-cols-3 pb-2">
<div className="col-span-2 space-y-4 pr-6">
<div>
<Label className="text-slate-500">Active surveys</Label>
{!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{activeSurveys?.map((survey) => <p className="text-sm text-slate-900">{survey}</p>)}
</div>
<div>
<Label className="text-slate-500">Inactive surveys</Label>
{!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{inactiveSurveys?.map((survey) => <p className="text-sm text-slate-900">{survey}</p>)}
</div>
</div>
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
<div>
<Label className="text-xs font-normal text-slate-500">Created on</Label>
<p className=" text-xs text-slate-700">
{convertDateTimeStringShort(currentSegment.createdAt?.toString())}
</p>
</div>{" "}
<div>
<Label className="text-xs font-normal text-slate-500">Last updated</Label>
<p className=" text-xs text-slate-700">
{convertDateTimeStringShort(currentSegment.updatedAt?.toString())}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSegment } from "@formbricks/types/segment";
import SegmentTableDataRowContainer from "./SegmentTableDataRowContainer";
type TSegmentTableProps = {
segments: TSegment[];
attributeClasses: TAttributeClass[];
actionClasses: TActionClass[];
isAdvancedTargetingAllowed: boolean;
};
const SegmentTable = ({
segments,
actionClasses,
attributeClasses,
isAdvancedTargetingAllowed,
}: TSegmentTableProps) => {
return (
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-7 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 pl-6">Title</div>
<div className="col-span-1 hidden text-center sm:block">Surveys</div>
<div className="col-span-1 hidden text-center sm:block">Updated</div>
<div className="col-span-1 hidden text-center sm:block">Created</div>
</div>
{segments.map((segment) => (
<SegmentTableDataRowContainer
currentSegment={segment}
segments={segments}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
/>
))}
</div>
);
};
export default SegmentTable;

View File

@@ -0,0 +1,80 @@
"use client";
import { UserGroupIcon } from "@heroicons/react/24/solid";
import { format, formatDistanceToNow } from "date-fns";
import { useState } from "react";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import EditSegmentModal from "./EditSegmentModal";
type TSegmentTableDataRowProps = {
currentSegment: TSegmentWithSurveyNames;
segments: TSegment[];
attributeClasses: TAttributeClass[];
actionClasses: TActionClass[];
isAdvancedTargetingAllowed: boolean;
isFormbricksCloud: boolean;
};
const SegmentTableDataRow = ({
currentSegment,
actionClasses,
attributeClasses,
segments,
isAdvancedTargetingAllowed,
isFormbricksCloud,
}: TSegmentTableDataRowProps) => {
const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment;
const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false);
return (
<>
<div
key={id}
className="m-2 grid h-16 cursor-pointer grid-cols-7 content-center rounded-lg hover:bg-slate-100"
onClick={() => setIsEditSegmentModalOpen(true)}>
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture h-8 w-8 flex-shrink-0 text-slate-700">
<UserGroupIcon />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{title}</div>
<div className="ph-no-capture text-xs font-medium text-slate-500">{description}</div>
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{surveys?.length}</div>
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(updatedAt, {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
</div>
</div>
<EditSegmentModal
environmentId={environmentId}
open={isEditSegmentModalOpen}
setOpen={setIsEditSegmentModalOpen}
currentSegment={currentSegment}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
isFormbricksCloud={isFormbricksCloud}
/>
</>
);
};
export default SegmentTableDataRow;

View File

@@ -0,0 +1,50 @@
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getSurveysBySegmentId } from "@formbricks/lib/survey/service";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSegment } from "@formbricks/types/segment";
import SegmentTableDataRow from "./SegmentTableDataRow";
type TSegmentTableDataRowProps = {
currentSegment: TSegment;
segments: TSegment[];
attributeClasses: TAttributeClass[];
actionClasses: TActionClass[];
isAdvancedTargetingAllowed: boolean;
};
const SegmentTableDataRowContainer = async ({
currentSegment,
segments,
actionClasses,
attributeClasses,
isAdvancedTargetingAllowed,
}: TSegmentTableDataRowProps) => {
const surveys = await getSurveysBySegmentId(currentSegment.id);
const activeSurveys = surveys?.length
? surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name)
: [];
const inactiveSurveys = surveys?.length
? surveys.filter((survey) => ["draft", "paused"].includes(survey.status)).map((survey) => survey.name)
: [];
return (
<SegmentTableDataRow
currentSegment={{
...currentSegment,
activeSurveys,
inactiveSurveys,
}}
segments={segments}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
);
};
export default SegmentTableDataRowContainer;

View File

@@ -0,0 +1,31 @@
import PeopleSegmentsTabs from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/PeopleSegmentsTabs";
import { Metadata } from "next";
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
export const metadata: Metadata = {
title: "Segments",
};
export default async function PeopleLayout({ params, children }) {
const team = await getTeamByEnvironmentId(params.environmentId);
if (!team) {
throw new Error("Team not found");
}
const isUserTargetingAllowed = getAdvancedTargetingPermission(team);
return (
<>
<PeopleSegmentsTabs
activeId="segments"
environmentId={params.environmentId}
isUserTargetingAllowed={isUserTargetingAllowed}
/>
<ContentWrapper>{children}</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,87 @@
import BasicCreateSegmentModal from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/segments/components/BasicCreateSegmentModal";
import SegmentTable from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/segments/components/SegmentTable";
import CreateSegmentModal from "@formbricks/ee/advancedTargeting/components/CreateSegmentModal";
import { ACTIONS_TO_EXCLUDE } from "@formbricks/ee/advancedTargeting/lib/constants";
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { IS_FORMBRICKS_CLOUD, REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getSegments } from "@formbricks/lib/segment/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
export const revalidate = REVALIDATION_INTERVAL;
export default async function SegmentsPage({ params }) {
const [environment, segments, attributeClasses, actionClassesFromServer, team] = await Promise.all([
getEnvironment(params.environmentId),
getSegments(params.environmentId),
getAttributeClasses(params.environmentId),
getActionClasses(params.environmentId),
getTeamByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
if (!team) {
throw new Error("Team not found");
}
const isAdvancedTargetingAllowed = getAdvancedTargetingPermission(team);
if (!segments) {
throw new Error("Failed to fetch segments");
}
const filteredSegments = segments.filter((segment) => !segment.isPrivate);
const actionClasses = actionClassesFromServer.filter((actionClass) => {
if (actionClass.type === "automatic") {
if (ACTIONS_TO_EXCLUDE.includes(actionClass.name)) {
return false;
}
return true;
}
return true;
});
return (
<>
{isAdvancedTargetingAllowed ? (
<CreateSegmentModal
environmentId={params.environmentId}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={filteredSegments}
/>
) : (
<BasicCreateSegmentModal
attributeClasses={attributeClasses}
environmentId={params.environmentId}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
)}
{filteredSegments.length === 0 ? (
<EmptySpaceFiller
type="table"
environment={environment}
emptyMessage="No segments yet. Add your first one to get started."
/>
) : (
<SegmentTable
segments={filteredSegments}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
/>
)}
</>
);
}

View File

@@ -120,11 +120,10 @@ export default function Navigation({
hidden: false,
},
{
name: "People",
name: "People & Segments",
href: `/environments/${environment.id}/people`,
icon: CustomersIcon,
current: pathname?.includes("/people"),
hidden: false,
current: pathname?.includes("/people") || pathname?.includes("/segments"),
},
{
name: "Actions & Attributes",
@@ -151,7 +150,7 @@ export default function Navigation({
[environment.id, pathname, isViewer]
);
const dropdownnavigation = [
const dropdownNavigation = [
{
title: "Survey",
links: [
@@ -459,7 +458,7 @@ export default function Navigation({
</DropdownMenuPortal>
</DropdownMenuSub>
{dropdownnavigation.map((item) => (
{dropdownNavigation.map((item) => (
<DropdownMenuGroup key={item.title}>
<DropdownMenuSeparator />
{item.links.map(

View File

@@ -1,17 +0,0 @@
import { Metadata } from "next";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
export const metadata: Metadata = {
title: "People",
};
export default function PeopleLayout({ children }) {
return (
<>
{/*
<PeopleGroupsTabs activeId="people" environmentId={params.environmentId} /> */}
<ContentWrapper>{children}</ContentWrapper>
</>
);
}

View File

@@ -134,7 +134,7 @@ export default function PricingTableComponent({
},
{
title: "Advanced Targeting",
comingSoon: true,
comingSoon: false,
},
{
title: "Unlimited User Identification",
@@ -142,7 +142,7 @@ export default function PricingTableComponent({
},
{
title: "Reusable Segments",
comingSoon: true,
comingSoon: false,
unlimited: true,
},
];
@@ -335,15 +335,15 @@ export default function PricingTableComponent({
</div>
<AlertDialog
confirmWhat="that you want to unsubscribe?"
headerText="Are you sure that you want to unsubscribe?"
open={openDeleteModal}
setOpen={setOpenDeleteModal}
onDiscard={() => {
onConfirm={() => {
setOpenDeleteModal(false);
}}
text="Your subscription for this product will be canceled at the end of the month. After that, you won't have access to the pro features anymore"
onSave={() => handleDeleteSubscription()}
confirmButtonLabel="Unsubscribe"
mainText="Your subscription for this product will be canceled at the end of the month. After that, you won't have access to the pro features anymore"
onDecline={() => handleDeleteSubscription()}
confirmBtnLabel="Unsubscribe"
/>
</div>
);

View File

@@ -3,15 +3,36 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { canUserAccessProduct } from "@formbricks/lib/product/auth";
import { getProduct } from "@formbricks/lib/product/service";
import {
cloneSegment,
createSegment,
deleteSegment,
getSegment,
updateSegment,
} from "@formbricks/lib/segment/service";
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { deleteSurvey, getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { loadNewSegmentInSurvey } from "@formbricks/lib/survey/service";
import { formatSurveyDateFields } from "@formbricks/lib/survey/util";
import { formatDateFields } from "@formbricks/lib/utils/datetime";
import { AuthorizationError } from "@formbricks/types/errors";
import { TProduct } from "@formbricks/types/product";
import {
TBaseFilters,
TSegmentUpdateInput,
ZSegmentFilters,
ZSegmentUpdateInput,
} from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys";
export async function surveyMutateAction(survey: TSurvey): Promise<TSurvey> {
return await updateSurvey(survey);
}
export async function updateSurveyAction(survey: TSurvey): Promise<TSurvey> {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
@@ -54,3 +75,116 @@ export const refetchProduct = async (productId: string): Promise<TProduct | null
const product = await getProduct(productId);
return product;
};
export const createBasicSegmentAction = async ({
description,
environmentId,
filters,
isPrivate,
surveyId,
title,
}: {
environmentId: string;
surveyId: string;
title: string;
description?: string;
isPrivate: boolean;
filters: TBaseFilters;
}) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
const parsedFilters = ZSegmentFilters.safeParse(filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
}
const segment = await createSegment({
environmentId,
surveyId,
title,
description: description || "",
isPrivate,
filters,
});
surveyCache.revalidate({ id: surveyId });
return segment;
};
export const updateBasicSegmentAction = async (
environmentId: string,
segmentId: string,
data: TSegmentUpdateInput
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
const { filters } = data;
if (filters) {
const parsedFilters = ZSegmentFilters.safeParse(filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
}
}
const _data = {
...data,
...formatDateFields(data, ZSegmentUpdateInput),
};
return await updateSegment(segmentId, _data);
};
export const loadNewBasicSegmentAction = async (surveyId: string, segmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
return await loadNewSegmentInSurvey(surveyId, segmentId);
};
export const cloneBasicSegmentAction = async (segmentId: string, surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
try {
const clonedSegment = await cloneSegment(segmentId, surveyId);
return clonedSegment;
} catch (err: any) {
throw new Error(err);
}
};
export const deleteBasicSegmentAction = async (environmentId: string, segmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
const foundSegment = await getSegment(segmentId);
if (!foundSegment) {
throw new Error(`Segment with id ${segmentId} not found`);
}
return await deleteSegment(segmentId);
};

View File

@@ -194,7 +194,7 @@ export default function RecontactOptionsCard({
id="inputDays"
value={inputDays === 0 ? 1 : inputDays}
onChange={handleRecontactDaysChange}
className="ml-2 mr-2 inline w-16 text-center text-sm"
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
/>
days before showing this survey again.
</p>

View File

@@ -1,15 +1,17 @@
import { AdvancedTargetingCard } from "@formbricks/ee/advancedTargeting/components/AdvancedTargetingCard";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys";
import HowToSendCard from "./HowToSendCard";
import RecontactOptionsCard from "./RecontactOptionsCard";
import ResponseOptionsCard from "./ResponseOptionsCard";
import StylingCard from "./StylingCard";
import TargetingCard from "./TargetingCard";
import WhenToSendCard from "./WhenToSendCard";
import WhoToSendCard from "./WhoToSendCard";
interface SettingsViewProps {
environment: TEnvironment;
@@ -17,9 +19,12 @@ interface SettingsViewProps {
setLocalSurvey: (survey: TSurvey) => void;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
segments: TSegment[];
responseCount: number;
membershipRole?: TMembershipRole;
colours: string[];
isUserTargetingAllowed?: boolean;
isFormbricksCloud: boolean;
}
export default function SettingsView({
@@ -28,20 +33,42 @@ export default function SettingsView({
setLocalSurvey,
actionClasses,
attributeClasses,
segments,
responseCount,
membershipRole,
colours,
isUserTargetingAllowed = false,
isFormbricksCloud,
}: SettingsViewProps) {
return (
<div className="mt-12 space-y-3 p-5">
<HowToSendCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} environment={environment} />
<WhoToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environment.id}
attributeClasses={attributeClasses}
/>
{localSurvey.type === "web" ? (
!isUserTargetingAllowed ? (
<TargetingCard
key={localSurvey.segment?.id}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environment.id}
attributeClasses={attributeClasses}
segments={segments}
initialSegment={segments.find((segment) => segment.id === localSurvey.segment?.id)}
isFormbricksCloud={isFormbricksCloud}
/>
) : (
<AdvancedTargetingCard
key={localSurvey.segment?.id}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environment.id}
attributeClasses={attributeClasses}
actionClasses={actionClasses}
segments={segments}
initialSegment={segments.find((segment) => segment.id === localSurvey.segment?.id)}
/>
)
) : null}
<WhenToSendCard
localSurvey={localSurvey}

View File

@@ -2,14 +2,15 @@
import { refetchProduct } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import Loading from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/loading";
import React from "react";
import { useEffect, useState } from "react";
import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/actions";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys";
import PreviewSurvey from "../../../components/PreviewSurvey";
@@ -24,9 +25,12 @@ interface SurveyEditorProps {
environment: TEnvironment;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
segments: TSegment[];
responseCount: number;
membershipRole?: TMembershipRole;
colours: string[];
isUserTargetingAllowed?: boolean;
isFormbricksCloud: boolean;
}
export default function SurveyEditor({
@@ -35,25 +39,31 @@ export default function SurveyEditor({
environment,
actionClasses,
attributeClasses,
segments,
responseCount,
membershipRole,
colours,
isUserTargetingAllowed = false,
isFormbricksCloud,
}: SurveyEditorProps): JSX.Element {
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>();
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
const [localProduct, setLocalProduct] = useState<TProduct>(product);
useEffect(() => {
if (survey) {
if (localSurvey) return;
setLocalSurvey(JSON.parse(JSON.stringify(survey)));
const surveyClone = structuredClone(survey);
setLocalSurvey(surveyClone);
if (survey.questions.length > 0) {
setActiveQuestionId(survey.questions[0].id);
}
}
}, [survey, localSurvey]);
}, [localSurvey, survey]);
useEffect(() => {
const listener = () => {
@@ -80,7 +90,45 @@ export default function SurveyEditor({
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey?.type]);
}, [localSurvey?.type, survey?.questions]);
useEffect(() => {
// if the localSurvey object has not been populated yet, do nothing
if (!localSurvey) {
return;
}
// do nothing if its not an in-app survey
if (localSurvey.type !== "web") {
return;
}
const createSegment = async () => {
const createdSegment = await createSegmentAction({
title: survey.id,
description: "",
environmentId: environment.id,
surveyId: localSurvey.id,
filters: [],
isPrivate: true,
});
setLocalSurvey({
...localSurvey,
segment: createdSegment,
});
};
if (!localSurvey.segment?.id) {
try {
createSegment();
} catch (err) {
throw new Error("Error creating segment");
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environment.id, isUserTargetingAllowed, localSurvey?.type, survey.id]);
if (!localSurvey) {
return <Loading />;
@@ -120,13 +168,16 @@ export default function SurveyEditor({
setLocalSurvey={setLocalSurvey}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
responseCount={responseCount}
membershipRole={membershipRole}
colours={colours}
isUserTargetingAllowed={isUserTargetingAllowed}
isFormbricksCloud={isFormbricksCloud}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
setActiveQuestionId={setActiveQuestionId}

View File

@@ -10,6 +10,7 @@ import toast from "react-hot-toast";
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
import AlertDialog from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
@@ -245,18 +246,25 @@ export default function SurveyMenuBar({
const { isDraft, ...rest } = question;
return rest;
}),
attributeFilters: localSurvey.attributeFilters.filter((attributeFilter) => {
if (attributeFilter.attributeClassId && attributeFilter.value) {
return true;
}
}),
};
if (!validateSurvey(localSurvey)) {
setIsSurveySaving(false);
return;
}
// validate the user segment filters
if (!!strippedSurvey.segment?.filters?.length) {
const parsedFilters = ZSegmentFilters.safeParse(strippedSurvey.segment.filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
"Invalid targeting: Please check your audience filters";
setIsSurveySaving(false);
toast.error(errMsg);
return;
}
}
try {
await updateSurveyAction({ ...strippedSurvey });
setIsSurveySaving(false);
@@ -404,16 +412,18 @@ export default function SurveyMenuBar({
}}
/>
<AlertDialog
confirmWhat="Survey changes"
headerText="Confirm Survey Changes"
open={isConfirmDialogOpen}
setOpen={setConfirmDialogOpen}
onDiscard={() => {
mainText="You have unsaved changes in your survey. Would you like to save them before leaving?"
confirmBtnLabel="Save"
declineBtnLabel="Discard"
declineBtnVariant="warn"
onDecline={() => {
setConfirmDialogOpen(false);
router.back();
}}
text="You have unsaved changes in your survey. Would you like to save them before leaving?"
confirmButtonLabel="Save"
onSave={() => saveSurveyAction(true)}
onConfirm={() => saveSurveyAction(true)}
/>
</div>
</>

View File

@@ -0,0 +1,425 @@
"use client";
import { CheckCircleIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { AlertCircle } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { isAdvancedSegment } from "@formbricks/lib/segment/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TBaseFilter, TSegment, TSegmentCreateInput, TSegmentUpdateInput } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys";
import AlertDialog from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import BasicAddFilterModal from "@formbricks/ui/Targeting/BasicAddFilterModal";
import BasicSegmentEditor from "@formbricks/ui/Targeting/BasicSegmentEditor";
import LoadSegmentModal from "@formbricks/ui/Targeting/LoadSegmentModal";
import SaveAsNewSegmentModal from "@formbricks/ui/Targeting/SaveAsNewSegmentModal";
import SegmentTitle from "@formbricks/ui/Targeting/SegmentTitle";
import TargetingIndicator from "@formbricks/ui/Targeting/TargetingIndicator";
import { UpgradePlanNotice } from "@formbricks/ui/UpgradePlanNotice";
import {
cloneBasicSegmentAction,
createBasicSegmentAction,
loadNewBasicSegmentAction,
updateBasicSegmentAction,
} from "../actions";
interface TargetingCardProps {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
environmentId: string;
attributeClasses: TAttributeClass[];
segments: TSegment[];
initialSegment?: TSegment;
isFormbricksCloud: boolean;
}
export default function TargetingCard({
localSurvey,
setLocalSurvey,
environmentId,
attributeClasses,
segments,
initialSegment,
isFormbricksCloud,
}: TargetingCardProps) {
const [segment, setSegment] = useState<TSegment | null>(localSurvey.segment);
const [open, setOpen] = useState(false);
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [saveAsNewSegmentModalOpen, setSaveAsNewSegmentModalOpen] = useState(false);
const [isSegmentEditorOpen, setIsSegmentEditorOpen] = useState(!!localSurvey.segment?.isPrivate);
const [loadSegmentModalOpen, setLoadSegmentModalOpen] = useState(false);
const [loadSegmentModalStep, setLoadSegmentModalStep] = useState<"initial" | "load">("initial");
const [resetAllFiltersModalOpen, setResetAllFiltersModalOpen] = useState(false);
const [segmentEditorViewOnly, setSegmentEditorViewOnly] = useState(true);
const handleAddFilterInGroup = (filter: TBaseFilter) => {
const updatedSegment = structuredClone(segment);
if (updatedSegment?.filters?.length === 0) {
updatedSegment.filters.push({
...filter,
connector: null,
});
} else {
updatedSegment?.filters.push(filter);
}
setSegment(updatedSegment);
};
const handleEditSegment = () => {
setIsSegmentEditorOpen(true);
setSegmentEditorViewOnly(false);
};
const handleCloneSegment = async () => {
if (!segment) return;
try {
const clonedSegment = await cloneBasicSegmentAction(segment.id, localSurvey.id);
setSegment(clonedSegment);
} catch (err) {
toast.error(err.message);
}
};
const handleLoadNewSegment = async (surveyId: string, segmentId: string) => {
const updatedSurvey = await loadNewBasicSegmentAction(surveyId, segmentId);
return updatedSurvey;
};
const handleSegmentUpdate = async (environmentId: string, segmentId: string, data: TSegmentUpdateInput) => {
const updatedSegment = await updateBasicSegmentAction(environmentId, segmentId, data);
return updatedSegment;
};
const handleSegmentCreate = async (data: TSegmentCreateInput) => {
const createdSegment = await createBasicSegmentAction(data);
return createdSegment;
};
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try {
if (!segment) throw new Error("Invalid segment");
await updateBasicSegmentAction(environmentId, segment?.id, data);
toast.success("Segment saved successfully");
} catch (err) {
toast.error(err.message ?? "Error Saving Segment");
}
};
useEffect(() => {
if (!!segment && segment?.filters?.length > 0) {
setOpen(true);
}
}, [segment, segment?.filters?.length]);
useEffect(() => {
setLocalSurvey((localSurveyOld) => ({
...localSurveyOld,
segment: segment,
}));
}, [setLocalSurvey, segment]);
const isSegmentUsedInOtherSurveys = useMemo(
() => (localSurvey?.segment ? localSurvey.segment?.surveys?.length > 1 : false),
[localSurvey.segment]
);
return (
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-6">
<div className="flex items-center pl-2 pr-5">
<CheckCircleIcon className="h-8 w-8 text-green-400 " />
</div>
<div>
<p className="font-semibold text-slate-800">Target Audience</p>
<p className="mt-1 text-sm text-slate-500">Pre-segment your users with attributes filters.</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="min-w-full overflow-auto">
<hr className="text-slate-600" />
<div className="flex flex-col gap-5 p-6">
<TargetingIndicator segment={segment} />
<div className="filter-scrollbar flex flex-col gap-4 overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-4">
<div className="flex w-full flex-col gap-2">
{isAdvancedSegment(segment?.filters ?? []) ? (
<div>
{!segment?.isPrivate ? (
<SegmentTitle
title={localSurvey.segment?.title}
description={localSurvey.segment?.description}
/>
) : (
<div>
<p className="text-sm font-semibold text-slate-800">
Send survey to audience who match...
</p>
</div>
)}
<p className="text-sm italic text-slate-600">
This is an advanced segment. Please upgrade your plan to edit it.
</p>
</div>
) : (
<>
{isSegmentEditorOpen ? (
<div className="flex w-full flex-col gap-2">
<div>
{!segment?.isPrivate ? (
<SegmentTitle
title={localSurvey.segment?.title}
description={localSurvey.segment?.description}
/>
) : (
<div className="mb-4">
<p className="text-sm font-semibold text-slate-800">
Send survey to audience who match...
</p>
</div>
)}
</div>
{!!segment?.filters?.length && (
<div className="w-full">
<BasicSegmentEditor
key={segment.filters.toString()}
group={segment.filters}
environmentId={environmentId}
segment={segment}
setSegment={setSegment}
attributeClasses={attributeClasses}
/>
</div>
)}
<div className="mt-3 flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => setAddFilterModalOpen(true)}>
Add filter
</Button>
{isSegmentEditorOpen && !segment?.isPrivate && !!segment?.filters?.length && (
<Button
variant="secondary"
size="sm"
onClick={() => {
handleSaveSegment({ filters: segment.filters });
}}>
Save changes
</Button>
)}
{/* {isSegmentEditorOpen && !!segment?.filters?.length && (
<Button
variant="minimal"
size="sm"
className="flex items-center gap-2"
onClick={() => setResetAllFiltersModalOpen(true)}>
<p className="text-sm">Reset all filters</p>
</Button>
)} */}
{isSegmentEditorOpen && !segment?.isPrivate && !!segment?.filters?.length && (
<Button
variant="minimal"
size="sm"
className="flex items-center gap-2"
onClick={() => {
setIsSegmentEditorOpen(false);
setSegmentEditorViewOnly(false);
if (initialSegment) {
setSegment(initialSegment);
}
}}>
Cancel
</Button>
)}
</div>
</div>
) : (
<div className="flex flex-col gap-2 rounded-lg">
<SegmentTitle
title={localSurvey.segment?.title}
description={localSurvey.segment?.description}
/>
{segmentEditorViewOnly && segment && (
<div className="opacity-60">
<BasicSegmentEditor
key={segment.filters.toString()}
group={segment.filters}
environmentId={environmentId}
segment={segment}
attributeClasses={attributeClasses}
setSegment={setSegment}
viewOnly
/>
</div>
)}
<div className="mt-3 flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
setSegmentEditorViewOnly(!segmentEditorViewOnly);
}}>
{segmentEditorViewOnly ? "Hide" : "View"} Filters{" "}
{segmentEditorViewOnly ? (
<ChevronUpIcon className="ml-2 h-3 w-3" />
) : (
<ChevronDownIcon className="ml-2 h-3 w-3" />
)}
</Button>
{isSegmentUsedInOtherSurveys && (
<Button
variant="secondary"
size="sm"
onClick={() => {
handleCloneSegment();
}}>
Clone & Edit Segment
</Button>
)}
{!isSegmentUsedInOtherSurveys && (
<Button
variant="secondary"
size="sm"
onClick={() => {
handleEditSegment();
}}>
Edit Segment
<PencilIcon className="ml-2 h-3 w-3" />
</Button>
)}
</div>
{isSegmentUsedInOtherSurveys && (
<p className="mt-1 flex items-center text-xs text-slate-500">
<AlertCircle className="mr-1 inline h-3 w-3" />
This segment is used in other surveys. Make changes{" "}
<Link
href={`/environments/${environmentId}/segments`}
target="_blank"
className="ml-1 underline">
here.
</Link>
</p>
)}
</div>
)}
</>
)}
</div>
</div>
<div className="flex w-full gap-3">
<Button variant="secondary" size="sm" onClick={() => setLoadSegmentModalOpen(true)}>
Load Segment
</Button>
{isSegmentEditorOpen && !!segment?.filters?.length && (
<Button
variant="secondary"
size="sm"
className="flex items-center gap-2"
onClick={() => setSaveAsNewSegmentModalOpen(true)}>
Save as new Segment
</Button>
)}
</div>
<div className="-mt-1.5">
{isFormbricksCloud ? (
<UpgradePlanNotice
message="For advanced targeting, please"
textForUrl="upgrade to the User Identification plan."
url={`/environments/${environmentId}/settings/billing`}
/>
) : (
<UpgradePlanNotice
message="For advanced targeting, please"
textForUrl="request an Enterprise license."
url="https://formbricks.com/docs/self-hosting/enterprise"
/>
)}
</div>
</div>
{!!segment && (
<LoadSegmentModal
open={loadSegmentModalOpen}
setOpen={setLoadSegmentModalOpen}
surveyId={localSurvey.id}
step={loadSegmentModalStep}
setStep={setLoadSegmentModalStep}
currentSegment={segment}
segments={segments}
setSegment={setSegment}
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
onSegmentLoad={handleLoadNewSegment}
/>
)}
<BasicAddFilterModal
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
setOpen={setAddFilterModalOpen}
attributeClasses={attributeClasses}
/>
{!!segment && (
<SaveAsNewSegmentModal
open={saveAsNewSegmentModalOpen}
setOpen={setSaveAsNewSegmentModalOpen}
localSurvey={localSurvey}
segment={segment}
setSegment={setSegment}
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
onCreateSegment={handleSegmentCreate}
onUpdateSegment={handleSegmentUpdate}
/>
)}
<AlertDialog
headerText="Are you sure?"
open={resetAllFiltersModalOpen}
setOpen={setResetAllFiltersModalOpen}
mainText="This action resets all filters in this survey."
declineBtnLabel="Cancel"
onDecline={() => {
setResetAllFiltersModalOpen(false);
}}
confirmBtnLabel="Remove all filters"
onConfirm={() => {
const updatedSegment = structuredClone(segment);
if (updatedSegment?.filters) {
updatedSegment.filters = [];
}
setSegment(updatedSegment);
setResetAllFiltersModalOpen(false);
}}
/>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
}

View File

@@ -126,6 +126,7 @@ export default function WhenToSendCard({
useEffect(() => {
if (isAddEventModalOpen) return;
if (activeIndex !== null) {
const newActionClass = actionClasses[actionClasses.length - 1].name;
const currentActionClass = localSurvey.triggers[activeIndex];
@@ -183,134 +184,136 @@ export default function WhenToSendCard({
</div>
</div>
</Collapsible.CollapsibleTrigger>
<hr className="py-1 text-slate-600" />
<Collapsible.CollapsibleContent className="p-3">
{!isAddEventModalOpen &&
localSurvey.triggers?.map((triggerEventClass, idx) => (
<div className="mt-2" key={idx}>
<div className="inline-flex items-center">
<p className="mr-2 w-14 text-right text-sm">{idx === 0 ? "When" : "or"}</p>
<Select
value={triggerEventClass}
onValueChange={(actionClassName) => setTriggerEvent(idx, actionClassName)}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<button
type="button"
className="flex w-full items-center space-x-2 rounded-md p-1 text-sm font-semibold text-slate-800 hover:bg-slate-100 hover:text-slate-500"
value="none"
onClick={() => {
setAddEventModalOpen(true);
setActiveIndex(idx);
}}>
<PlusIcon className="mr-1 h-5 w-5" />
Add Action
</button>
<SelectSeparator />
{actionClasses.map((actionClass) => (
<SelectItem
value={actionClass.name}
key={actionClass.name}
title={actionClass.description ? actionClass.description : ""}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mx-2 text-sm">action is performed</p>
<button type="button" onClick={() => removeTriggerEvent(idx)}>
<TrashIcon className="ml-3 h-4 w-4 text-slate-400" />
</button>
<Collapsible.CollapsibleContent>
<hr className="py-1 text-slate-600" />
<div className="p-3">
{!isAddEventModalOpen &&
localSurvey.triggers?.map((triggerEventClass, idx) => (
<div className="mt-2" key={idx}>
<div className="inline-flex items-center">
<p className="mr-2 w-14 text-right text-sm">{idx === 0 ? "When" : "or"}</p>
<Select
value={triggerEventClass}
onValueChange={(actionClassName) => setTriggerEvent(idx, actionClassName)}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<button
type="button"
className="flex w-full items-center space-x-2 rounded-md p-1 text-sm font-semibold text-slate-800 hover:bg-slate-100 hover:text-slate-500"
value="none"
onClick={() => {
setAddEventModalOpen(true);
setActiveIndex(idx);
}}>
<PlusIcon className="mr-1 h-5 w-5" />
Add Action
</button>
<SelectSeparator />
{actionClasses.map((actionClass) => (
<SelectItem
value={actionClass.name}
key={actionClass.name}
title={actionClass.description ? actionClass.description : ""}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mx-2 text-sm">action is performed</p>
<button type="button" onClick={() => removeTriggerEvent(idx)}>
<TrashIcon className="ml-3 h-4 w-4 text-slate-400" />
</button>
</div>
</div>
</div>
))}
<div className="px-6 py-4">
<Button
variant="secondary"
onClick={() => {
addTriggerEvent();
}}>
<PlusIcon className="mr-2 h-4 w-4" />
Add condition
</Button>
</div>
))}
<div className="px-6 py-4">
<Button
variant="secondary"
onClick={() => {
addTriggerEvent();
}}>
<PlusIcon className="mr-2 h-4 w-4" />
Add condition
</Button>
</div>
<div className="ml-2 flex items-center space-x-1 px-4 pb-4"></div>
<AdvancedOptionToggle
htmlId="delay"
isChecked={delay}
onToggle={handleDelayToggle}
title="Add delay before showing survey"
description="Wait a few seconds after the trigger before showing the survey"
childBorder={true}>
<label
htmlFor="triggerDelay"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="">
<div className="ml-2 flex items-center space-x-1 px-4 pb-4"></div>
<AdvancedOptionToggle
htmlId="delay"
isChecked={delay}
onToggle={handleDelayToggle}
title="Add delay before showing survey"
description="Wait a few seconds after the trigger before showing the survey"
childBorder={true}>
<label
htmlFor="triggerDelay"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="">
<p className="text-sm font-semibold text-slate-700">
Wait
<Input
type="number"
min="0"
id="triggerDelay"
value={localSurvey.delay.toString()}
onChange={(e) => handleTriggerDelay(e)}
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
/>
seconds before showing the survey.
</p>
</div>
</label>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="autoClose"
isChecked={autoClose}
onToggle={handleAutoCloseToggle}
title="Auto close on inactivity"
description="Automatically close the survey if the user does not respond after certain number of seconds"
childBorder={true}>
<label htmlFor="autoCloseSeconds" className="cursor-pointer p-4">
<p className="text-sm font-semibold text-slate-700">
Wait
Automatically close survey after
<Input
type="number"
min="0"
id="triggerDelay"
value={localSurvey.delay.toString()}
onChange={(e) => handleTriggerDelay(e)}
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
min="1"
id="autoCloseSeconds"
value={localSurvey.autoClose?.toString()}
onChange={(e) => handleInputSeconds(e)}
className="mx-2 inline w-16 bg-white text-center text-sm"
/>
seconds before showing the survey.
seconds with no initial interaction.
</p>
</label>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="randomizer"
isChecked={randomizerToggle}
onToggle={handleDisplayPercentageToggle}
title="Show survey to % of users"
description="Only display the survey to a subset of the users"
childBorder={true}>
<div className="w-full">
<div className="flex flex-col justify-center rounded-lg border bg-slate-50 p-6">
<h3 className="mb-4 text-sm font-semibold text-slate-700">
Show to {localSurvey.displayPercentage}% of targeted users
</h3>
<input
id="small-range"
type="range"
min="1"
max="100"
value={localSurvey.displayPercentage ?? 50}
onChange={handleRandomizerInput}
className="range-sm mb-6 h-1 w-full cursor-pointer appearance-none rounded-lg bg-slate-200 dark:bg-slate-700"
/>
</div>
</div>
</label>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="autoClose"
isChecked={autoClose}
onToggle={handleAutoCloseToggle}
title="Auto close on inactivity"
description="Automatically close the survey if the user does not respond after certain number of seconds"
childBorder={true}>
<label htmlFor="autoCloseSeconds" className="cursor-pointer p-4">
<p className="text-sm font-semibold text-slate-700">
Automatically close survey after
<Input
type="number"
min="1"
id="autoCloseSeconds"
value={localSurvey.autoClose?.toString()}
onChange={(e) => handleInputSeconds(e)}
className="mx-2 inline w-16 bg-white text-center text-sm"
/>
seconds with no initial interaction.
</p>
</label>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="randomizer"
isChecked={randomizerToggle}
onToggle={handleDisplayPercentageToggle}
title="Show survey to % of users"
description="Only display the survey to a subset of the users"
childBorder={true}>
<div className="w-full">
<div className="flex flex-col justify-center rounded-lg border bg-slate-50 p-6">
<h3 className="mb-4 text-sm font-semibold text-slate-700">
Show to {localSurvey.displayPercentage}% of targeted users
</h3>
<input
id="small-range"
type="range"
min="1"
max="100"
value={localSurvey.displayPercentage ?? 50}
onChange={handleRandomizerInput}
className="range-sm mb-6 h-1 w-full cursor-pointer appearance-none rounded-lg bg-slate-200 dark:bg-slate-700"
/>
</div>
</div>
</AdvancedOptionToggle>
</AdvancedOptionToggle>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
<AddNoCodeActionModal

View File

@@ -1,209 +0,0 @@
"use client";
import { CheckCircleIcon, FunnelIcon, PlusIcon, TrashIcon, UserGroupIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { Info } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
const filterConditions = [
{ id: "equals", name: "equals" },
{ id: "notEquals", name: "not equals" },
];
interface WhoToSendCardProps {
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
environmentId: string;
attributeClasses: TAttributeClass[];
}
export default function WhoToSendCard({ localSurvey, setLocalSurvey, attributeClasses }: WhoToSendCardProps) {
const [open, setOpen] = useState(false);
const condition = filterConditions[0].id === "equals" ? "equals" : "notEquals";
useEffect(() => {
if (localSurvey.type === "link") {
setOpen(false);
}
}, [localSurvey.type]);
const addAttributeFilter = () => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.attributeFilters = [
...localSurvey.attributeFilters,
{ attributeClassId: "", condition: condition, value: "" },
];
setLocalSurvey(updatedSurvey);
};
const setAttributeFilter = (idx: number, attributeClassId: string, condition: string, value: string) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.attributeFilters[idx] = {
attributeClassId,
condition: condition === "equals" ? "equals" : "notEquals",
value,
};
setLocalSurvey(updatedSurvey);
};
const removeAttributeFilter = (idx: number) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.attributeFilters = [
...localSurvey.attributeFilters.slice(0, idx),
...localSurvey.attributeFilters.slice(idx + 1),
];
setLocalSurvey(updatedSurvey);
};
if (localSurvey.type === "link") {
return null; // Hide card completely
}
return (
<>
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-6">
<div className="flex items-center pl-2 pr-5">
<CheckCircleIcon className="h-8 w-8 text-green-400 " />
</div>
<div>
<p className="font-semibold text-slate-800">Target Audience</p>
<p className="mt-1 text-sm text-slate-500">Pre-segment your users with attributes filters.</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="">
<hr className="py-1 text-slate-600" />
<div className="mx-6 mb-4 mt-3">
<Alert variant="info">
<Info className="h-4 w-4" />
<AlertTitle>User Identification</AlertTitle>
<AlertDescription>
To target your audience you need to identify your users within your app. You can read more
about how to do this in our{" "}
<a
href="https://formbricks.com/docs/attributes/identify-users"
className="underline"
target="_blank">
docs
</a>
.
</AlertDescription>
</Alert>
</div>
<div className="mx-6 flex items-center rounded-lg border border-slate-200 p-4 text-slate-800">
<div>
{localSurvey.attributeFilters?.length === 0 ? (
<UserGroupIcon className="mr-4 h-6 w-6 text-slate-600" />
) : (
<FunnelIcon className="mr-4 h-6 w-6 text-slate-600" />
)}
</div>
<div>
<p className="">
Current:{" "}
<span className="font-semibold text-slate-900">
{localSurvey.attributeFilters?.length === 0 ? "All users" : "Filtered"}
</span>
</p>
<p className="mt-1 text-sm text-slate-500">
{localSurvey.attributeFilters?.length === 0
? "All users can see the survey."
: "Only users who match the attribute filter will see the survey."}
</p>
</div>
</div>
{localSurvey.attributeFilters?.map((attributeFilter, idx) => (
<div className="mt-4 px-5" key={idx}>
<div className="justify-left flex items-center space-x-3">
<p className={cn(idx !== 0 && "ml-5", "text-right text-sm")}>{idx === 0 ? "Where" : "and"}</p>
<Select
value={attributeFilter.attributeClassId}
onValueChange={(attributeClassId) =>
setAttributeFilter(
idx,
attributeClassId,
attributeFilter.condition,
attributeFilter.value
)
}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{attributeClasses
.filter((attributeClass) => !attributeClass.archived)
.map((attributeClass) => (
<SelectItem value={attributeClass.id}>{attributeClass.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={attributeFilter.condition}
onValueChange={(condition) =>
setAttributeFilter(
idx,
attributeFilter.attributeClassId,
condition,
attributeFilter.value
)
}>
<SelectTrigger className="w-[210px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{filterConditions.map((filterCondition) => (
<SelectItem value={filterCondition.id}>{filterCondition.name}</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={attributeFilter.value}
onChange={(e) => {
e.preventDefault();
setAttributeFilter(
idx,
attributeFilter.attributeClassId,
attributeFilter.condition,
e.target.value
);
}}
/>
<button type="button" onClick={() => removeAttributeFilter(idx)}>
<TrashIcon className="h-4 w-4 text-slate-400" />
</button>
</div>
</div>
))}
<div className="px-6 py-4">
<Button
variant="secondary"
onClick={() => {
addAttributeFilter();
}}>
<PlusIcon className="mr-2 h-4 w-4" />
Add filter
</Button>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</>
);
}

View File

@@ -1,14 +1,16 @@
import { getServerSession } from "next-auth";
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { colours } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD, colours } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSegments } from "@formbricks/lib/segment/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
@@ -23,17 +25,27 @@ export const generateMetadata = async ({ params }) => {
};
export default async function SurveysEditPage({ params }) {
const [survey, product, environment, actionClasses, attributeClasses, responseCount, team, session] =
await Promise.all([
getSurvey(params.surveyId),
getProductByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
getActionClasses(params.environmentId),
getAttributeClasses(params.environmentId),
getResponseCountBySurveyId(params.surveyId),
getTeamByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
const [
survey,
product,
environment,
actionClasses,
attributeClasses,
responseCount,
team,
session,
segments,
] = await Promise.all([
getSurvey(params.surveyId),
getProductByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
getActionClasses(params.environmentId),
getAttributeClasses(params.environmentId),
getResponseCountBySurveyId(params.surveyId),
getTeamByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getSegments(params.environmentId),
]);
if (!session) {
throw new Error("Session not found");
@@ -47,6 +59,8 @@ export default async function SurveysEditPage({ params }) {
const { isViewer } = getAccessFlags(currentUserMembership?.role);
const isSurveyCreationDeletionDisabled = isViewer;
const isUserTargetingAllowed = getAdvancedTargetingPermission(team);
if (
!survey ||
!environment ||
@@ -68,6 +82,9 @@ export default async function SurveysEditPage({ params }) {
responseCount={responseCount}
membershipRole={currentUserMembership?.role}
colours={colours}
segments={segments}
isUserTargetingAllowed={isUserTargetingAllowed}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
);
}

View File

@@ -2527,7 +2527,6 @@ export const minimalSurvey: TSurvey = {
environmentId: "someEnvId1",
createdBy: null,
status: "draft",
attributeFilters: [],
displayOption: "displayOnce",
autoClose: null,
triggers: [],
@@ -2552,4 +2551,5 @@ export const minimalSurvey: TSurvey = {
singleUse: null,
styling: null,
resultShareKey: null,
segment: null,
};

View File

@@ -71,7 +71,7 @@ export async function POST(req: Request, context: Context): Promise<NextResponse
});
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveys(environmentId, person),
getSyncSurveys(environmentId, person.id),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);

View File

@@ -70,7 +70,7 @@ export async function POST(req: Request, context: Context): Promise<NextResponse
});
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveys(environmentId, person),
getSyncSurveys(environmentId, person.id),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);

View File

@@ -94,7 +94,7 @@ export const getUpdatedState = async (environmentId: string, personId?: string):
if (isAppSurveyLimitReached) {
surveys = [];
} else if (isPerson) {
surveys = await getSyncSurveys(environmentId, person as TPerson);
surveys = await getSyncSurveys(environmentId, (person as TPerson).id);
} else {
surveys = await getSurveys(environmentId);
surveys = surveys.filter((survey) => survey.type === "web" && survey.status === "inProgress");

View File

@@ -1,7 +1,7 @@
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/in-app/sync/lib/posthog";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextResponse } from "next/server";
import { NextRequest, NextResponse, userAgent } from "next/server";
import { getLatestActionByPersonId } from "@formbricks/lib/action/service";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
@@ -27,7 +27,7 @@ export async function OPTIONS(): Promise<NextResponse> {
}
export async function GET(
_: Request,
request: NextRequest,
{
params,
}: {
@@ -38,6 +38,9 @@ export async function GET(
}
): Promise<NextResponse> {
try {
const { device } = userAgent(request);
const apiVersion = request.nextUrl.searchParams.get("version");
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse({
environmentId: params.environmentId,
@@ -114,7 +117,9 @@ export async function GET(
}
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveys(environmentId, person),
getSyncSurveys(environmentId, person.id, device.type === "mobile" ? "phone" : "desktop", {
version: apiVersion ?? undefined,
}),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
@@ -125,7 +130,7 @@ export async function GET(
// return state
const state: TJsStateSync = {
person: { id: person.id, userId: person.userId },
person: { id: person.id, userId: person.userId, attributes: person.attributes },
surveys: !isInAppSurveyLimitReached ? surveys : [],
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,

View File

@@ -71,13 +71,19 @@ export async function GET(
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
const state: TJsStateSync = {
surveys: !isInAppSurveyLimitReached
? surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web")
? surveys.filter(
(survey) =>
survey.status === "inProgress" &&
survey.type === "web" &&
(!survey.segment || survey.segment.filters.length === 0)
)
: [],
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,

View File

@@ -77,6 +77,18 @@
border: 3px solid #cbd5e1;
}
.filter-scrollbar::-webkit-scrollbar {
height: 10px;
}
.filter-scrollbar::-webkit-scrollbar-thumb {
border-radius: 8px;
}
.filter-scrollbar::-webkit-scrollbar-track {
border-radius: 8px;
}
input:focus {
--tw-ring-color: none;
--tw-ring-offset-color: none;

View File

@@ -1,235 +0,0 @@
import { prisma } from "@formbricks/database";
import { TSettings } from "@formbricks/types/js";
export const getSettings = async (environmentId: string, personId: string): Promise<TSettings> => {
// get recontactDays from product
const product = await prisma.product.findFirst({
where: {
environments: {
some: {
id: environmentId,
},
},
},
select: {
recontactDays: true,
},
});
if (!product) {
throw new Error("Product not found");
}
const person = await prisma.person.findUnique({
where: {
id: personId,
},
select: {
attributes: {
select: {
id: true,
value: true,
attributeClassId: true,
},
},
},
});
if (!person) {
throw new Error("Person not found");
}
// get all surveys that meet the displayOption criteria
const potentialSurveys = await prisma.survey.findMany({
where: {
OR: [
{
environmentId,
type: "web",
status: "inProgress",
displayOption: "respondMultiple",
},
{
environmentId,
type: "web",
status: "inProgress",
displayOption: "displayOnce",
displays: { none: { personId } },
},
{
environmentId,
type: "web",
status: "inProgress",
displayOption: "displayMultiple",
displays: { none: { personId, status: "responded" } },
},
],
},
select: {
id: true,
questions: true,
recontactDays: true,
triggers: {
select: {
id: true,
actionClass: {
select: {
id: true,
name: true,
},
},
},
// last display
},
attributeFilters: {
select: {
id: true,
condition: true,
value: true,
attributeClass: {
select: {
id: true,
name: true,
},
},
},
},
displays: {
where: {
personId,
},
orderBy: {
createdAt: "desc",
},
take: 1,
select: {
createdAt: true,
},
},
thankYouCard: true,
welcomeCard: true,
autoClose: true,
delay: true,
},
});
// get last display for this person
const lastDisplayPerson = await prisma.display.findFirst({
where: {
personId,
},
orderBy: {
createdAt: "desc",
},
select: {
createdAt: true,
},
});
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = potentialSurveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attribute = person.attributes.find(
(attribute) => attribute.attributeClassId === attributeFilter.attributeClass.id
);
if (attributeFilter.condition === "equals") {
return attribute?.value === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return attribute?.value !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
// filter surveys that meet the recontactDays criteria
const surveys = potentialSurveysWithAttributes
.filter((survey) => {
if (!lastDisplayPerson) {
// no display yet - always display
return true;
} else if (survey.recontactDays !== null) {
// if recontactDays is set on survey, use that
const lastDisplaySurvey = survey.displays[0];
if (!lastDisplaySurvey) {
// no display yet - always display
return true;
}
const lastDisplayDate = new Date(lastDisplaySurvey.createdAt);
const currentDate = new Date();
const diffTime = Math.abs(currentDate.getTime() - lastDisplayDate.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays >= survey.recontactDays;
} else if (product.recontactDays !== null) {
// if recontactDays is not set in survey, use product recontactDays
const lastDisplayDate = new Date(lastDisplayPerson.createdAt);
const currentDate = new Date();
const diffTime = Math.abs(currentDate.getTime() - lastDisplayDate.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays >= product.recontactDays;
} else {
// if recontactDays is not set in survey or product, always display
return true;
}
})
.map((survey) => {
return {
id: survey.id,
questions: JSON.parse(JSON.stringify(survey.questions)),
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
thankYouCard: JSON.parse(JSON.stringify(survey.thankYouCard)),
welcomeCard: JSON.parse(JSON.stringify(survey.welcomeCard)),
autoClose: survey.autoClose,
delay: survey.delay,
};
});
const noCodeEvents = await prisma.actionClass.findMany({
where: {
environmentId,
type: "noCode",
},
select: {
name: true,
noCodeConfig: true,
},
});
const environmentProdut = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
product: {
select: {
brandColor: true,
linkSurveyBranding: true,
placement: true,
darkOverlay: true,
clickOutsideClose: true,
},
},
},
});
const formbricksSignature = environmentProdut?.product.linkSurveyBranding;
const brandColor = environmentProdut?.product.brandColor;
const placement = environmentProdut?.product.placement;
const darkOverlay = environmentProdut?.product.darkOverlay;
const clickOutsideClose = environmentProdut?.product.clickOutsideClose;
return {
surveys,
noCodeEvents,
brandColor,
formbricksSignature,
placement,
darkOverlay,
clickOutsideClose,
};
};

View File

@@ -1,6 +1,7 @@
import { TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { TIntegrationConfig } from "@formbricks/types/integration";
import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbricks/types/responses";
import { TBaseFilters } from "@formbricks/types/segment";
import {
TSurveyClosedMessage,
TSurveyHiddenFields,
@@ -34,5 +35,6 @@ declare global {
export type SurveyVerifyEmail = TSurveyVerifyEmail;
export type TeamBilling = TTeamBilling;
export type UserNotificationSettings = TUserNotificationSettings;
export type SegmentFilter = TBaseFilters;
}
}

View File

@@ -0,0 +1,111 @@
import { createId } from "@paralleldrive/cuid2";
import { PrismaClient } from "@prisma/client";
import {
TBaseFilter,
TBaseFilters,
TSegmentAttributeFilter,
TSegmentPersonFilter,
} from "@formbricks/types/segment";
const prisma = new PrismaClient();
async function main() {
await prisma.$transaction(async (tx) => {
const allSurveysWithAttributeFilters = await prisma.survey.findMany({
where: {
attributeFilters: {
some: {},
},
},
include: {
attributeFilters: { include: { attributeClass: true } },
},
});
if (!allSurveysWithAttributeFilters?.length) {
// stop the migration if there are no surveys with attribute filters
return;
}
allSurveysWithAttributeFilters.forEach(async (survey) => {
const { attributeFilters } = survey;
// if there are no attribute filters, we can skip this survey
if (!attributeFilters?.length) {
return;
}
// from these attribute filters, we need to create user segments
// each attribute filter will be a filter in the user segment
// all the filters will be joined by AND
// the user segment will be private
const filters: TBaseFilters = attributeFilters.map((filter, idx) => {
const { attributeClass } = filter;
let resource: TSegmentAttributeFilter | TSegmentPersonFilter;
// if the attribute class is userId, we need to create a user segment with the person filter
if (attributeClass.name === "userId" && attributeClass.type === "automatic") {
resource = {
id: createId(),
root: {
type: "person",
personIdentifier: "userId",
},
qualifier: {
operator: filter.condition,
},
value: filter.value,
};
} else {
resource = {
id: createId(),
root: {
type: "attribute",
attributeClassName: attributeClass.name,
},
qualifier: {
operator: filter.condition,
},
value: filter.value,
};
}
const attributeSegment: TBaseFilter = {
id: filter.id,
connector: idx === 0 ? null : "and",
resource,
};
return attributeSegment;
});
await tx.segment.create({
data: {
title: "",
description: "",
isPrivate: true,
filters,
surveys: {
connect: {
id: survey.id,
},
},
environment: {
connect: {
id: survey.environmentId,
},
},
},
});
});
// delete all attribute filters
await tx.surveyAttributeFilter.deleteMany({});
});
}
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());

View File

@@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "segmentId" TEXT;
-- CreateTable
CREATE TABLE "Segment" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"isPrivate" BOOLEAN NOT NULL DEFAULT true,
"filters" JSONB NOT NULL DEFAULT '[]',
"environmentId" TEXT NOT NULL,
CONSTRAINT "Segment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Segment_environmentId_idx" ON "Segment"("environmentId");
-- AddForeignKey
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_segmentId_fkey" FOREIGN KEY ("segmentId") REFERENCES "Segment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Segment" ADD CONSTRAINT "Segment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE UNIQUE INDEX "Segment_environmentId_title_key" ON "Segment"("environmentId", "title");

View File

@@ -22,7 +22,8 @@
"generate": "prisma generate",
"lint": "eslint ./src --fix",
"post-install": "pnpm generate",
"predev": "pnpm generate"
"predev": "pnpm generate",
"data-migration:advanced-targeting": "ts-node ./migrations/20240207041922_advanced_targeting/data-migration.ts"
},
"dependencies": {
"@prisma/client": "^5.8.1",

View File

@@ -285,18 +285,22 @@ model Survey {
/// @zod.custom(imports.ZSurveyClosedMessage)
/// [SurveyClosedMessage]
surveyClosedMessage Json?
/// @zod.custom(imports.ZSurveySingleUse)
/// [SurveySingleUse]
segmentId String?
segment Segment? @relation(fields: [segmentId], references: [id])
/// @zod.custom(imports.ZSurveyProductOverwrites)
/// [SurveyProductOverwrites]
productOverwrites Json?
/// @zod.custom(imports.ZSurveyStyling)
/// [SurveyStyling]
styling Json?
styling Json?
/// @zod.custom(imports.ZSurveySingleUse)
/// [SurveySingleUse]
singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}")
singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}")
/// @zod.custom(imports.ZSurveyVerifyEmail)
/// [SurveyVerifyEmail]
verifyEmail Json?
@@ -386,6 +390,7 @@ model Environment {
apiKeys ApiKey[]
webhooks Webhook[]
tags Tag[]
segments Segment[]
integration Integration[]
@@index([productId])
@@ -577,3 +582,21 @@ model ShortUrl {
updatedAt DateTime @updatedAt @map(name: "updated_at")
url String @unique
}
model Segment {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
title String
description String?
isPrivate Boolean @default(true)
/// @zod.custom(imports.ZSegmentFilters)
/// [SegmentFilter]
filters Json @default("[]")
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
surveys Survey[]
@@unique([environmentId, title])
@@index([environmentId])
}

View File

@@ -23,5 +23,6 @@ export {
ZSurveySingleUse,
} from "@formbricks/types/surveys";
export { ZSegmentFilters } from "@formbricks/types/segment";
export { ZTeamBilling } from "@formbricks/types/teams";
export { ZUserNotificationSettings } from "@formbricks/types/user";

View File

@@ -0,0 +1,562 @@
"use client";
import { createId } from "@paralleldrive/cuid2";
import { FingerprintIcon, MonitorSmartphoneIcon, MousePointerClick, TagIcon, Users2Icon } from "lucide-react";
import React, { useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TBaseFilter,
TSegment,
TSegmentAttributeFilter,
TSegmentPersonFilter,
} from "@formbricks/types/segment";
import { Input } from "@formbricks/ui/Input";
import { Modal } from "@formbricks/ui/Modal";
import { TabBar } from "@formbricks/ui/TabBar";
type TAddFilterModalProps = {
open: boolean;
setOpen: (open: boolean) => void;
onAddFilter: (filter: TBaseFilter) => void;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
segments: TSegment[];
};
type TFilterType = "action" | "attribute" | "segment" | "device" | "person";
const handleAddFilter = ({
type,
onAddFilter,
setOpen,
attributeClassName,
deviceType,
actionClassId,
segmentId,
}: {
type: TFilterType;
onAddFilter: (filter: TBaseFilter) => void;
setOpen: (open: boolean) => void;
actionClassId?: string;
attributeClassName?: string;
segmentId?: string;
deviceType?: string;
}) => {
if (type === "action") {
if (!actionClassId) return;
const newFilter: TBaseFilter = {
id: createId(),
connector: "and",
resource: {
id: createId(),
root: {
type: type,
actionClassId,
},
qualifier: {
metric: "occuranceCount",
operator: "greaterThan",
},
value: "",
},
};
onAddFilter(newFilter);
setOpen(false);
}
if (type === "attribute") {
if (!attributeClassName) return;
const newFilterResource: TSegmentAttributeFilter = {
id: createId(),
root: {
type,
attributeClassName,
},
qualifier: {
operator: "equals",
},
value: "",
};
const newFilter: TBaseFilter = {
id: createId(),
connector: "and",
resource: newFilterResource,
};
onAddFilter(newFilter);
setOpen(false);
}
if (type === "person") {
const newResource: TSegmentPersonFilter = {
id: createId(),
root: { type: "person", personIdentifier: "userId" },
qualifier: {
operator: "equals",
},
value: "",
};
const newFilter: TBaseFilter = {
id: createId(),
connector: "and",
resource: newResource,
};
onAddFilter(newFilter);
setOpen(false);
}
if (type === "segment") {
if (!segmentId) return;
const newFilter: TBaseFilter = {
id: createId(),
connector: "and",
resource: {
id: createId(),
root: {
type: type,
segmentId,
},
qualifier: {
operator: "userIsIn",
},
value: segmentId,
},
};
onAddFilter(newFilter);
setOpen(false);
}
if (type === "device") {
if (!deviceType) return;
const newFilter: TBaseFilter = {
id: createId(),
connector: "and",
resource: {
id: createId(),
root: {
type: type,
deviceType,
},
qualifier: {
operator: "equals",
},
value: deviceType,
},
};
onAddFilter(newFilter);
setOpen(false);
}
};
type AttributeTabContentProps = {
attributeClasses: TAttributeClass[];
onAddFilter: (filter: TBaseFilter) => void;
setOpen: (open: boolean) => void;
};
const AttributeTabContent = ({ attributeClasses, onAddFilter, setOpen }: AttributeTabContentProps) => {
return (
<div className="flex flex-col gap-2">
<div>
<h2 className="text-base font-medium">Person</h2>
<div>
<div
onClick={() => {
handleAddFilter({
type: "person",
onAddFilter,
setOpen,
});
}}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
<FingerprintIcon className="h-4 w-4" />
<p>userId</p>
</div>
</div>
</div>
<hr className="my-2" />
<div>
<h2 className="text-base font-medium">Attributes</h2>
</div>
{attributeClasses?.length === 0 && (
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
<p>There are no attributes yet!</p>
</div>
)}
{attributeClasses.map((attributeClass) => {
return (
<div
onClick={() => {
handleAddFilter({
type: "attribute",
onAddFilter,
setOpen,
attributeClassName: attributeClass.name,
});
}}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
<TagIcon className="h-4 w-4" />
<p>{attributeClass.name}</p>
</div>
);
})}
</div>
);
};
const AddFilterModal = ({
onAddFilter,
open,
setOpen,
actionClasses,
attributeClasses,
segments,
}: TAddFilterModalProps) => {
const [activeTabId, setActiveTabId] = useState("all");
const [searchValue, setSearchValue] = useState("");
const tabs: {
id: string;
label: string;
icon?: React.ReactNode;
}[] = [
{ id: "all", label: "All" },
{ id: "actions", label: "Actions", icon: <MousePointerClick className="h-4 w-4" /> },
{ id: "attributes", label: "Person & Attributes", icon: <TagIcon className="h-4 w-4" /> },
{ id: "segments", label: "Segments", icon: <Users2Icon className="h-4 w-4" /> },
{ id: "devices", label: "Devices", icon: <MonitorSmartphoneIcon className="h-4 w-4" /> },
];
// eslint-disable-next-line react-hooks/exhaustive-deps
const devices = [
{ id: "phone", name: "Phone" },
{ id: "desktop", name: "Desktop" },
];
const actionClassesFiltered = useMemo(() => {
if (!actionClasses) return [];
if (!searchValue) return actionClasses;
return actionClasses.filter((actionClass) =>
actionClass.name.toLowerCase().includes(searchValue.toLowerCase())
);
}, [actionClasses, searchValue]);
const attributeClassesFiltered = useMemo(() => {
if (!attributeClasses) return [];
if (!searchValue) return attributeClasses;
return attributeClasses.filter((attributeClass) =>
attributeClass.name.toLowerCase().includes(searchValue.toLowerCase())
);
}, [attributeClasses, searchValue]);
const personAttributesFiltered = useMemo(() => {
const personAttributes = [{ name: "userId" }];
return personAttributes.filter((personAttribute) =>
personAttribute.name.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
const segmentsFiltered = useMemo(() => {
if (!segments) return [];
if (!searchValue) return segments.filter((segment) => !segment.isPrivate);
return segments
.filter((segment) => !segment.isPrivate)
.filter((segment) => segment.title.toLowerCase().includes(searchValue.toLowerCase()));
}, [segments, searchValue]);
const deviceTypesFiltered = useMemo(() => {
if (!searchValue) return devices;
return devices.filter((deviceType) => deviceType.name.toLowerCase().includes(searchValue.toLowerCase()));
}, [devices, searchValue]);
const allFiltersFiltered = useMemo(
() => [
{
attributes: attributeClassesFiltered,
personAttributes: personAttributesFiltered,
actions: actionClassesFiltered,
segments: segmentsFiltered,
devices: deviceTypesFiltered,
},
],
[
actionClassesFiltered,
attributeClassesFiltered,
deviceTypesFiltered,
personAttributesFiltered,
segmentsFiltered,
]
);
const getAllTabContent = () => {
return (
<>
{allFiltersFiltered?.every((filterArr) => {
return (
filterArr.actions.length === 0 &&
filterArr.attributes.length === 0 &&
filterArr.segments.length === 0 &&
filterArr.devices.length === 0 &&
filterArr.personAttributes.length === 0
);
}) && (
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
<p>There are no filters yet!</p>
</div>
)}
{allFiltersFiltered.map((filters) => {
return (
<>
{filters.actions.map((actionClass) => {
return (
<div
onClick={() => {
handleAddFilter({
type: "action",
onAddFilter,
setOpen,
actionClassId: actionClass.id,
});
}}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
<MousePointerClick className="h-4 w-4" />
<p>{actionClass.name}</p>
</div>
);
})}
{filters.attributes.map((attributeClass) => {
return (
<div
onClick={() => {
handleAddFilter({
type: "attribute",
onAddFilter,
setOpen,
attributeClassName: attributeClass.name,
});
}}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
<TagIcon className="h-4 w-4" />
<p>{attributeClass.name}</p>
</div>
);
})}
{filters.personAttributes.map((personAttribute) => {
return (
<div
onClick={() => {
handleAddFilter({
type: "person",
onAddFilter,
setOpen,
});
}}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
<FingerprintIcon className="h-4 w-4" />
<p>{personAttribute.name}</p>
</div>
);
})}
{filters.segments.map((segment) => {
return (
<div
onClick={() => {
handleAddFilter({
type: "segment",
onAddFilter,
setOpen,
segmentId: segment.id,
});
}}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
<Users2Icon className="h-4 w-4" />
<p>{segment.title}</p>
</div>
);
})}
{filters.devices.map((deviceType) => (
<div
key={deviceType.id}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
onClick={() => {
handleAddFilter({
type: "device",
onAddFilter,
setOpen,
deviceType: deviceType.id,
});
}}>
<MonitorSmartphoneIcon className="h-4 w-4" />
<span>{deviceType.name}</span>
</div>
))}
</>
);
})}
</>
);
};
const getActionsTabContent = () => {
return (
<>
{actionClassesFiltered?.length === 0 && (
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
<p>There are no actions yet!</p>
</div>
)}
{actionClassesFiltered.map((actionClass) => {
return (
<div
onClick={() => {
handleAddFilter({
type: "action",
onAddFilter,
setOpen,
actionClassId: actionClass.id,
});
}}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
<MousePointerClick className="h-4 w-4" />
<p>{actionClass.name}</p>
</div>
);
})}
</>
);
};
const getAttributesTabContent = () => {
return (
<AttributeTabContent
attributeClasses={attributeClassesFiltered}
onAddFilter={onAddFilter}
setOpen={setOpen}
/>
);
};
const getSegmentsTabContent = () => {
return (
<>
{segmentsFiltered?.length === 0 && (
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
<p>You currently have no saved segments.</p>
</div>
)}
{segmentsFiltered
?.filter((segment) => !segment.isPrivate)
?.map((segment) => {
return (
<div
onClick={() => {
handleAddFilter({
type: "segment",
onAddFilter,
setOpen,
segmentId: segment.id,
});
}}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
<Users2Icon className="h-4 w-4" />
<p>{segment.title}</p>
</div>
);
})}
</>
);
};
const getDevicesTabContent = () => {
return (
<div className="flex flex-col">
{deviceTypesFiltered.map((deviceType) => (
<div
key={deviceType.id}
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
onClick={() => {
handleAddFilter({
type: "device",
onAddFilter,
setOpen,
deviceType: deviceType.id,
});
}}>
<MonitorSmartphoneIcon className="h-4 w-4" />
<span>{deviceType.name}</span>
</div>
))}
</div>
);
};
const TabContent = (): JSX.Element => {
switch (activeTabId) {
case "all": {
return getAllTabContent();
}
case "actions": {
return getActionsTabContent();
}
case "attributes": {
return getAttributesTabContent();
}
case "segments": {
return getSegmentsTabContent();
}
case "devices": {
return getDevicesTabContent();
}
default: {
return getAllTabContent();
}
}
};
return (
<Modal
hideCloseButton
open={open}
setOpen={setOpen}
closeOnOutsideClick
className="sm:w-[650px] sm:max-w-full">
<div className="flex w-auto flex-col">
<Input placeholder="Browse filters..." autoFocus onChange={(e) => setSearchValue(e.target.value)} />
<TabBar className="bg-white" tabs={tabs} activeId={activeTabId} setActiveId={setActiveTabId} />
</div>
<div className={cn("mt-2 flex max-h-80 flex-col gap-1 overflow-y-auto")}>
<TabContent />
</div>
</Modal>
);
};
export default AddFilterModal;

View File

@@ -0,0 +1,407 @@
"use client";
import { CheckCircleIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { AlertCircle } from "lucide-react";
import Link from "next/link";
import React, { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TBaseFilter, TSegment, TSegmentCreateInput, TSegmentUpdateInput } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys";
import AlertDialog from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import LoadSegmentModal from "@formbricks/ui/Targeting/LoadSegmentModal";
import SaveAsNewSegmentModal from "@formbricks/ui/Targeting/SaveAsNewSegmentModal";
import SegmentTitle from "@formbricks/ui/Targeting/SegmentTitle";
import TargetingIndicator from "@formbricks/ui/Targeting/TargetingIndicator";
import {
cloneSegmentAction,
createSegmentAction,
loadNewSegmentAction,
updateSegmentAction,
} from "../lib/actions";
import { ACTIONS_TO_EXCLUDE } from "../lib/constants";
import AddFilterModal from "./AddFilterModal";
import SegmentEditor from "./SegmentEditor";
interface UserTargetingAdvancedCardProps {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
environmentId: string;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
segments: TSegment[];
initialSegment?: TSegment;
}
export function AdvancedTargetingCard({
localSurvey,
setLocalSurvey,
environmentId,
actionClasses: actionClassesProps,
attributeClasses,
segments,
initialSegment,
}: UserTargetingAdvancedCardProps) {
const [open, setOpen] = useState(false);
const [segment, setSegment] = useState<TSegment | null>(localSurvey.segment);
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [saveAsNewSegmentModalOpen, setSaveAsNewSegmentModalOpen] = useState(false);
const [resetAllFiltersModalOpen, setResetAllFiltersModalOpen] = useState(false);
const [loadSegmentModalOpen, setLoadSegmentModalOpen] = useState(false);
const [loadSegmentModalStep, setLoadSegmentModalStep] = useState<"initial" | "load">("initial");
const [isSegmentEditorOpen, setIsSegmentEditorOpen] = useState(!!localSurvey.segment?.isPrivate);
const [segmentEditorViewOnly, setSegmentEditorViewOnly] = useState(true);
const actionClasses = actionClassesProps.filter((actionClass) => {
if (actionClass.type === "automatic") {
if (ACTIONS_TO_EXCLUDE.includes(actionClass.name)) {
return false;
}
return true;
}
return true;
});
useEffect(() => {
setLocalSurvey((localSurveyOld) => ({
...localSurveyOld,
segment: segment,
}));
}, [setLocalSurvey, segment]);
const isSegmentUsedInOtherSurveys = useMemo(
() => (localSurvey?.segment ? localSurvey.segment?.surveys?.length > 1 : false),
[localSurvey.segment]
);
const handleCloneSegment = async () => {
if (!segment) return;
try {
const clonedSegment = await cloneSegmentAction(segment.id, localSurvey.id);
setSegment(clonedSegment);
} catch (err: any) {
toast.error(err.message);
}
};
useEffect(() => {
if (!!segment && segment?.filters?.length > 0) {
setOpen(true);
}
}, [segment, segment?.filters?.length]);
useEffect(() => {
if (localSurvey.type === "link") {
setOpen(false);
}
}, [localSurvey.type]);
const handleAddFilterInGroup = (filter: TBaseFilter) => {
const updatedSegment = structuredClone(segment);
if (updatedSegment?.filters?.length === 0) {
updatedSegment.filters.push({
...filter,
connector: null,
});
} else {
updatedSegment?.filters.push(filter);
}
setSegment(updatedSegment);
};
const handleLoadNewSegment = async (surveyId: string, segmentId: string) => {
const updatedSurvey = await loadNewSegmentAction(surveyId, segmentId);
return updatedSurvey;
};
const handleSaveAsNewSegmentUpdate = async (
environmentId: string,
segmentId: string,
data: TSegmentUpdateInput
) => {
const updatedSegment = await updateSegmentAction(environmentId, segmentId, data);
return updatedSegment;
};
const handleSaveAsNewSegmentCreate = async (data: TSegmentCreateInput) => {
const createdSegment = await createSegmentAction(data);
return createdSegment;
};
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try {
if (!segment) throw new Error("Invalid segment");
await updateSegmentAction(environmentId, segment?.id, data);
toast.success("Segment saved successfully");
} catch (err: any) {
toast.error(err.message ?? "Error Saving Segment");
}
};
if (localSurvey.type === "link") {
return null; // Hide card completely
}
return (
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-6">
<div className="flex items-center pl-2 pr-5">
<CheckCircleIcon className="h-8 w-8 text-green-400 " />
</div>
<div>
<p className="font-semibold text-slate-800">Target Audience</p>
<p className="mt-1 text-sm text-slate-500">Pre-segment your users with attributes filters.</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="min-w-full overflow-auto">
<hr className="text-slate-600" />
<div className="flex flex-col gap-5 p-6">
<TargetingIndicator segment={segment} />
<div className="filter-scrollbar flex flex-col gap-4 overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-4">
{!!segment && (
<LoadSegmentModal
open={loadSegmentModalOpen}
setOpen={setLoadSegmentModalOpen}
surveyId={localSurvey.id}
step={loadSegmentModalStep}
setStep={setLoadSegmentModalStep}
currentSegment={segment}
segments={segments}
setSegment={setSegment}
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
onSegmentLoad={handleLoadNewSegment}
/>
)}
{isSegmentEditorOpen ? (
<div className="flex w-full flex-col gap-2">
<div>
{!segment?.isPrivate ? (
<SegmentTitle
title={localSurvey.segment?.title}
description={localSurvey.segment?.description}
/>
) : (
<div className="mb-4">
<p className="text-sm font-semibold text-slate-800">
Send survey to audience who match...
</p>
</div>
)}
</div>
{!!segment?.filters?.length && (
<div className="w-full">
<SegmentEditor
key={segment.filters.toString()}
group={segment.filters}
environmentId={environmentId}
segment={segment}
setSegment={setSegment}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
/>
</div>
)}
<div className="mt-3 flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={() => setAddFilterModalOpen(true)}>
Add filter
</Button>
{isSegmentEditorOpen && !segment?.isPrivate && !!segment?.filters?.length && (
<Button
variant="secondary"
size="sm"
onClick={() => {
handleSaveSegment({ filters: segment.filters });
}}>
Save changes
</Button>
)}
{/*
{isSegmentEditorOpen && !!segment?.filters?.length && (
<Button
variant="minimal"
size="sm"
className="flex items-center gap-2"
onClick={() => setResetAllFiltersModalOpen(true)}>
<p className="text-sm">Reset all filters</p>
</Button>
)} */}
{isSegmentEditorOpen && !segment?.isPrivate && !!segment?.filters?.length && (
<Button
variant="minimal"
size="sm"
className="flex items-center gap-2"
onClick={() => {
setIsSegmentEditorOpen(false);
setSegmentEditorViewOnly(true);
if (initialSegment) {
setSegment(initialSegment);
}
}}>
Cancel
</Button>
)}
</div>
<>
<AddFilterModal
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
setOpen={setAddFilterModalOpen}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
/>
{!!segment && (
<SaveAsNewSegmentModal
open={saveAsNewSegmentModalOpen}
setOpen={setSaveAsNewSegmentModalOpen}
localSurvey={localSurvey}
segment={segment}
setSegment={setSegment}
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
onCreateSegment={handleSaveAsNewSegmentCreate}
onUpdateSegment={handleSaveAsNewSegmentUpdate}
/>
)}
<AlertDialog
headerText="Are you sure?"
open={resetAllFiltersModalOpen}
setOpen={setResetAllFiltersModalOpen}
mainText="This action resets all filters in this survey."
declineBtnLabel="Cancel"
onDecline={() => {
setResetAllFiltersModalOpen(false);
}}
confirmBtnLabel="Remove all filters"
onConfirm={() => {
const updatedSegment = structuredClone(segment);
if (updatedSegment?.filters) {
updatedSegment.filters = [];
}
setSegment(updatedSegment);
setResetAllFiltersModalOpen(false);
}}
/>
</>
</div>
) : (
<div className="flex flex-col gap-2 rounded-lg">
<SegmentTitle
title={localSurvey.segment?.title}
description={localSurvey.segment?.description}
/>
{segmentEditorViewOnly && segment && (
<div className="opacity-60">
<SegmentEditor
key={segment.filters.toString()}
group={segment.filters}
environmentId={environmentId}
segment={segment}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
setSegment={setSegment}
viewOnly={segmentEditorViewOnly}
/>
</div>
)}
<div className="mt-3 flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={() => {
setSegmentEditorViewOnly(!segmentEditorViewOnly);
}}>
{segmentEditorViewOnly ? "Hide" : "View"} Filters{" "}
{segmentEditorViewOnly ? (
<ChevronUpIcon className="ml-2 h-3 w-3" />
) : (
<ChevronDownIcon className="ml-2 h-3 w-3" />
)}
</Button>
{isSegmentUsedInOtherSurveys && (
<Button variant="secondary" size="sm" onClick={() => handleCloneSegment()}>
Clone & Edit Segment
</Button>
)}
{!isSegmentUsedInOtherSurveys && (
<Button
variant={isSegmentUsedInOtherSurveys ? "minimal" : "secondary"}
size="sm"
onClick={() => {
setIsSegmentEditorOpen(true);
setSegmentEditorViewOnly(false);
}}>
Edit Segment
<PencilIcon className="ml-2 h-3 w-3" />
</Button>
)}
</div>
{isSegmentUsedInOtherSurveys && (
<p className="mt-1 flex items-center text-xs text-slate-500">
<AlertCircle className="mr-1 inline h-3 w-3" />
This segment is used in other surveys. Make changes{" "}
<Link
href={`/environments/${environmentId}/segments`}
target="_blank"
className="ml-1 underline">
here.
</Link>
</p>
)}
</div>
)}
</div>
<div className="flex gap-3">
<Button variant="secondary" size="sm" onClick={() => setLoadSegmentModalOpen(true)}>
Load Segment
</Button>
{isSegmentEditorOpen && !!segment?.filters?.length && (
<Button
variant="secondary"
size="sm"
className="flex items-center gap-2"
onClick={() => setSaveAsNewSegmentModalOpen(true)}>
Save as new Segment
</Button>
)}
</div>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
}

View File

@@ -0,0 +1,242 @@
"use client";
import { UserGroupIcon } from "@heroicons/react/20/solid";
import { FilterIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TBaseFilter, TSegment } from "@formbricks/types/segment";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Modal } from "@formbricks/ui/Modal";
import { createSegmentAction } from "../lib/actions";
import AddFilterModal from "./AddFilterModal";
import SegmentFilters from "./SegmentEditor";
type TCreateSegmentModalProps = {
environmentId: string;
segments: TSegment[];
attributeClasses: TAttributeClass[];
actionClasses: TActionClass[];
};
const CreateSegmentModal = ({
environmentId,
actionClasses,
attributeClasses,
segments,
}: TCreateSegmentModalProps) => {
const router = useRouter();
const initialSegmentState = {
title: "",
description: "",
isPrivate: false,
filters: [],
environmentId,
id: "",
surveys: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const [open, setOpen] = useState(false);
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [segment, setSegment] = useState<TSegment>(initialSegmentState);
const [isCreatingSegment, setIsCreatingSegment] = useState(false);
const [titleError, setTitleError] = useState("");
const handleResetState = () => {
setSegment(initialSegmentState);
setTitleError("");
setOpen(false);
};
const handleAddFilterInGroup = (filter: TBaseFilter) => {
const updatedSegment = structuredClone(segment);
if (updatedSegment?.filters?.length === 0) {
updatedSegment.filters.push({
...filter,
connector: null,
});
} else {
updatedSegment?.filters.push(filter);
}
setSegment(updatedSegment);
};
const handleCreateSegment = async () => {
if (!segment.title) {
setTitleError("Title is required");
return;
}
try {
setIsCreatingSegment(true);
await createSegmentAction({
title: segment.title,
description: segment.description ?? "",
isPrivate: segment.isPrivate,
filters: segment.filters,
environmentId,
surveyId: "",
});
setIsCreatingSegment(false);
toast.success("Segment created successfully!");
} catch (err: any) {
toast.error(`${err.message}`);
setIsCreatingSegment(false);
return;
}
handleResetState();
setIsCreatingSegment(false);
router.refresh();
};
return (
<>
<div className="mb-4 flex justify-end">
<Button variant="darkCTA" onClick={() => setOpen(true)}>
Create Segment
</Button>
</div>
<Modal
open={open}
setOpen={() => {
handleResetState();
}}
noPadding
closeOnOutsideClick={false}
className="md:w-full"
size="lg">
<div className="rounded-lg bg-slate-50">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<UserGroupIcon />
</div>
<div>
<h3 className="text-base font-medium">Create Segment</h3>
<p className="text-sm text-slate-600">
Segments help you target the users with the same characteristics easily.
</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col overflow-auto rounded-lg bg-white p-6">
<div className="flex w-full items-center gap-4">
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">Title</label>
<div className="relative flex flex-col gap-1">
<Input
placeholder="Ex. Power Users"
onChange={(e) => {
setSegment((prev) => ({
...prev,
title: e.target.value,
}));
}}
className={cn(titleError && "border border-red-500 focus:border-red-500")}
/>
{titleError && (
<p className="absolute right-1 bg-white text-xs text-red-500" style={{ top: "-8px" }}>
{titleError}
</p>
)}
</div>
</div>
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">Description</label>
<Input
placeholder="Ex. Fully activated recurring users"
onChange={(e) => {
setSegment((prev) => ({
...prev,
description: e.target.value,
}));
}}
/>
</div>
</div>
<label className="my-4 text-sm font-medium text-slate-900">Targeting</label>
<div className="filter-scrollbar flex w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
{segment?.filters?.length === 0 && (
<div className="-mb-2 flex items-center gap-1">
<FilterIcon className="h-5 w-5 text-slate-700" />
<h3 className="text-sm font-medium text-slate-700">Add your first filter to get started</h3>
</div>
)}
<SegmentFilters
environmentId={environmentId}
segment={segment}
setSegment={setSegment}
group={segment.filters}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
/>
<Button
className="w-fit"
variant="secondary"
size="sm"
onClick={() => setAddFilterModalOpen(true)}>
Add Filter
</Button>
<AddFilterModal
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
setOpen={setAddFilterModalOpen}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
/>
</div>
<div className="flex justify-end pt-4">
<div className="flex space-x-2">
<Button
type="button"
variant="minimal"
onClick={() => {
handleResetState();
}}>
Cancel
</Button>
<Button
variant="darkCTA"
type="submit"
loading={isCreatingSegment}
onClick={() => {
handleCreateSegment();
}}>
Create Segment
</Button>
</div>
</div>
</div>
</div>
</Modal>
</>
);
};
export default CreateSegmentModal;

View File

@@ -0,0 +1,264 @@
import { MoreVertical, Trash2 } from "lucide-react";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import {
addFilterBelow,
addFilterInGroup,
createGroupFromResource,
deleteResource,
isResourceFilter,
moveResource,
toggleGroupConnector,
} from "@formbricks/lib/segment/utils";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TBaseFilter, TBaseFilters, TSegment, TSegmentConnector } from "@formbricks/types/segment";
import { Button } from "@formbricks/ui/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
import AddFilterModal from "./AddFilterModal";
import SegmentFilter from "./SegmentFilter";
type TSegmentEditorProps = {
group: TBaseFilters;
environmentId: string;
segment: TSegment;
segments: TSegment[];
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
setSegment: React.Dispatch<React.SetStateAction<TSegment | null>>;
viewOnly?: boolean;
};
const SegmentEditor = ({
group,
environmentId,
setSegment,
segment,
actionClasses,
attributeClasses,
segments,
viewOnly = false,
}: TSegmentEditorProps) => {
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [addFilterModalOpenedFromBelow, setAddFilterModalOpenedFromBelow] = useState(false);
const handleAddFilterBelow = (resourceId: string, filter: TBaseFilter) => {
const localSegmentCopy = structuredClone(segment);
if (localSegmentCopy.filters) {
addFilterBelow(localSegmentCopy.filters, resourceId, filter);
}
setSegment(localSegmentCopy);
};
const handleCreateGroup = (resourceId: string) => {
const localSegmentCopy = structuredClone(segment);
if (localSegmentCopy.filters) {
createGroupFromResource(localSegmentCopy.filters, resourceId);
}
setSegment(localSegmentCopy);
};
const handleMoveResource = (resourceId: string, direction: "up" | "down") => {
const localSegmentCopy = structuredClone(segment);
if (localSegmentCopy.filters) {
moveResource(localSegmentCopy.filters, resourceId, direction);
}
setSegment(localSegmentCopy);
};
const handleDeleteResource = (resourceId: string) => {
const localSegmentCopy = structuredClone(segment);
if (localSegmentCopy.filters) {
deleteResource(localSegmentCopy.filters, resourceId);
}
setSegment(localSegmentCopy);
};
const handleToggleGroupConnector = (groupId: string, newConnectorValue: TSegmentConnector) => {
const localSegmentCopy = structuredClone(segment);
if (localSegmentCopy.filters) {
toggleGroupConnector(localSegmentCopy.filters, groupId, newConnectorValue);
}
setSegment(localSegmentCopy);
};
const onConnectorChange = (groupId: string, connector: TSegmentConnector) => {
if (!connector) return;
if (connector === "and") {
handleToggleGroupConnector(groupId, "or");
} else {
handleToggleGroupConnector(groupId, "and");
}
};
const handleAddFilterInGroup = (groupId: string, filter: TBaseFilter) => {
const localSegmentCopy = structuredClone(segment);
if (localSegmentCopy.filters) {
addFilterInGroup(localSegmentCopy.filters, groupId, filter);
}
setSegment(localSegmentCopy);
};
return (
<div className="flex flex-col gap-4 rounded-lg">
{group?.map((groupItem) => {
const { connector, resource, id: groupId } = groupItem;
if (isResourceFilter(resource)) {
return (
<SegmentFilter
key={groupId}
connector={connector}
resource={resource}
environmentId={environmentId}
segment={segment}
segments={segments}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
setSegment={setSegment}
handleAddFilterBelow={handleAddFilterBelow}
onCreateGroup={(filterId: string) => handleCreateGroup(filterId)}
onDeleteFilter={(filterId: string) => handleDeleteResource(filterId)}
onMoveFilter={(filterId: string, direction: "up" | "down") =>
handleMoveResource(filterId, direction)
}
viewOnly={viewOnly}
/>
);
} else {
return (
<div key={groupId}>
<div className="flex items-start gap-2">
<div key={connector} className="w-auto">
<span
className={cn(
!!connector && "cursor-pointer underline",
"text-sm",
viewOnly && "cursor-not-allowed"
)}
onClick={() => {
if (viewOnly) return;
onConnectorChange(groupId, connector);
}}>
{!!connector ? connector : "Where"}
</span>
</div>
<div className="rounded-lg border-2 border-slate-300 bg-white p-4">
<SegmentEditor
group={resource}
environmentId={environmentId}
segment={segment}
setSegment={setSegment}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
viewOnly={viewOnly}
/>
<div className="mt-4">
<Button
variant="secondary"
size="sm"
onClick={() => {
if (viewOnly) return;
setAddFilterModalOpen(true);
}}
disabled={viewOnly}>
Add filter
</Button>
</div>
<AddFilterModal
open={addFilterModalOpen}
setOpen={setAddFilterModalOpen}
onAddFilter={(filter) => {
if (addFilterModalOpenedFromBelow) {
handleAddFilterBelow(groupId, filter);
setAddFilterModalOpenedFromBelow(false);
} else {
handleAddFilterInGroup(groupId, filter);
}
}}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
/>
</div>
<div className="flex items-center gap-2 p-4">
<DropdownMenu>
<DropdownMenuTrigger disabled={viewOnly}>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setAddFilterModalOpenedFromBelow(true);
setAddFilterModalOpen(true);
}}>
Add filter below
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleCreateGroup(groupId);
}}>
Create group
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleMoveResource(groupId, "up");
}}>
Move up
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (viewOnly) return;
handleMoveResource(groupId, "down");
}}>
Move down
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="minimal"
className="p-0"
disabled={viewOnly}
onClick={() => {
if (viewOnly) return;
handleDeleteResource(groupId);
}}>
<Trash2 className={cn("h-4 w-4 cursor-pointer", viewOnly && "cursor-not-allowed")} />
</Button>
</div>
</div>
</div>
);
}
})}
</div>
);
};
export default SegmentEditor;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,243 @@
"use client";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TBaseFilter, TSegment, TSegmentWithSurveyNames, ZSegmentFilters } from "@formbricks/types/segment";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import ConfirmDeleteSegmentModal from "@formbricks/ui/Targeting/ConfirmDeleteSegmentModal";
import { deleteSegmentAction, updateSegmentAction } from "../lib/actions";
import AddFilterModal from "./AddFilterModal";
import SegmentEditor from "./SegmentEditor";
type TSegmentSettingsTabProps = {
environmentId: string;
setOpen: (open: boolean) => void;
initialSegment: TSegmentWithSurveyNames;
segments: TSegment[];
attributeClasses: TAttributeClass[];
actionClasses: TActionClass[];
};
const SegmentSettings = ({
environmentId,
initialSegment,
setOpen,
actionClasses,
attributeClasses,
segments,
}: TSegmentSettingsTabProps) => {
const router = useRouter();
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [segment, setSegment] = useState<TSegment>(initialSegment);
const [isUpdatingSegment, setIsUpdatingSegment] = useState(false);
const [isDeletingSegment, setIsDeletingSegment] = useState(false);
const [titleError, setTitleError] = useState("");
const [isSaveDisabled, setIsSaveDisabled] = useState(false);
const [isDeleteSegmentModalOpen, setIsDeleteSegmentModalOpen] = useState(false);
const handleResetState = () => {
setSegment(initialSegment);
setOpen(false);
setTitleError("");
router.refresh();
};
const handleAddFilterInGroup = (filter: TBaseFilter) => {
const updatedSegment = structuredClone(segment);
if (updatedSegment?.filters?.length === 0) {
updatedSegment.filters.push({
...filter,
connector: null,
});
} else {
updatedSegment?.filters.push(filter);
}
setSegment(updatedSegment);
};
const handleUpdateSegment = async () => {
if (!segment.title) {
setTitleError("Title is required");
return;
}
try {
setIsUpdatingSegment(true);
await updateSegmentAction(segment.environmentId, segment.id, {
title: segment.title,
description: segment.description ?? "",
isPrivate: segment.isPrivate,
filters: segment.filters,
});
setIsUpdatingSegment(false);
toast.success("Segment updated successfully!");
} catch (err: any) {
toast.error(`${err.message}`);
setIsUpdatingSegment(false);
return;
}
setIsUpdatingSegment(false);
handleResetState();
router.refresh();
};
const handleDeleteSegment = async () => {
try {
setIsDeletingSegment(true);
await deleteSegmentAction(segment.environmentId, segment.id);
setIsDeletingSegment(false);
toast.success("Segment deleted successfully!");
handleResetState();
} catch (err: any) {
toast.error(`${err.message}`);
}
setIsDeletingSegment(false);
};
useEffect(() => {
// parse the filters to check if they are valid
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
setIsSaveDisabled(true);
} else {
setIsSaveDisabled(false);
}
}, [segment]);
return (
<>
<div className="mb-4">
<div className="rounded-lg bg-slate-50">
<div className="flex flex-col overflow-auto rounded-lg bg-white">
<div className="flex w-full items-center gap-4">
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">Title</label>
<div className="relative flex flex-col gap-1">
<Input
value={segment.title}
placeholder="Ex. Power Users"
onChange={(e) => {
setSegment((prev) => ({
...prev,
title: e.target.value,
}));
if (e.target.value) {
setTitleError("");
}
}}
className={cn("w-auto", titleError && "border border-red-500 focus:border-red-500")}
/>
{titleError && (
<p className="absolute -bottom-1.5 right-2 bg-white text-xs text-red-500">{titleError}</p>
)}
</div>
</div>
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">Description</label>
<div className="relative flex flex-col gap-1">
<Input
value={segment.description ?? ""}
placeholder="Ex. Power Users"
onChange={(e) => {
setSegment((prev) => ({
...prev,
description: e.target.value,
}));
}}
className={cn("w-auto")}
/>
</div>
</div>
</div>
<label className="my-4 text-sm font-medium text-slate-900">Targeting</label>
<div className="filter-scrollbar flex max-h-96 w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<SegmentEditor
environmentId={environmentId}
segment={segment}
setSegment={setSegment}
group={segment.filters}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
/>
<div>
<Button variant="secondary" size="sm" onClick={() => setAddFilterModalOpen(true)}>
Add Filter
</Button>
</div>
<AddFilterModal
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
setOpen={setAddFilterModalOpen}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
/>
</div>
<div className="flex w-full items-center justify-between pt-4">
<Button
type="button"
variant="warn"
loading={isDeletingSegment}
onClick={() => {
setIsDeleteSegmentModalOpen(true);
}}
EndIcon={Trash2}
endIconClassName="p-0.5">
Delete
</Button>
<Button
variant="darkCTA"
type="submit"
loading={isUpdatingSegment}
onClick={() => {
handleUpdateSegment();
}}
disabled={isSaveDisabled}>
Save Changes
</Button>
{isDeleteSegmentModalOpen && (
<ConfirmDeleteSegmentModal
onDelete={handleDeleteSegment}
open={isDeleteSegmentModalOpen}
segment={initialSegment}
setOpen={setIsDeleteSegmentModalOpen}
/>
)}
</div>
</div>
</div>
</div>
</>
);
};
export default SegmentSettings;

View File

@@ -0,0 +1,132 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { segmentCache } from "@formbricks/lib/segment/cache";
import {
cloneSegment,
createSegment,
deleteSegment,
getSegment,
updateSegment,
} from "@formbricks/lib/segment/service";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { loadNewSegmentInSurvey } from "@formbricks/lib/survey/service";
import { formatDateFields } from "@formbricks/lib/utils/datetime";
import { AuthorizationError } from "@formbricks/types/errors";
import {
TSegmentCreateInput,
TSegmentUpdateInput,
ZSegmentFilters,
ZSegmentUpdateInput,
} from "@formbricks/types/segment";
export const createSegmentAction = async ({
description,
environmentId,
filters,
isPrivate,
surveyId,
title,
}: TSegmentCreateInput) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
const parsedFilters = ZSegmentFilters.safeParse(filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
}
const segment = await createSegment({
environmentId,
surveyId,
title,
description,
isPrivate,
filters,
});
surveyCache.revalidate({ id: surveyId });
segmentCache.revalidate({ id: segment.id, environmentId });
return segment;
};
export const updateSegmentAction = async (
environmentId: string,
segmentId: string,
data: TSegmentUpdateInput
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
const { filters } = data;
if (filters) {
const parsedFilters = ZSegmentFilters.safeParse(filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new Error(errMsg);
}
}
const _data = {
...data,
...formatDateFields(data, ZSegmentUpdateInput),
};
return await updateSegment(segmentId, _data);
};
export const loadNewSegmentAction = async (surveyId: string, segmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
return await loadNewSegmentInSurvey(surveyId, segmentId);
};
export const cloneSegmentAction = async (segmentId: string, surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
try {
const clonedSegment = await cloneSegment(segmentId, surveyId);
return clonedSegment;
} catch (err: any) {
throw new Error(err);
}
};
export const deleteSegmentAction = async (environmentId: string, segmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const environmentAccess = hasUserEnvironmentAccess(session.user.id, environmentId);
if (!environmentAccess) throw new AuthorizationError("Not authorized");
const foundSegment = await getSegment(segmentId);
if (!foundSegment) {
throw new Error(`Segment with id ${segmentId} not found`);
}
return await deleteSegment(segmentId);
};

View File

@@ -0,0 +1 @@
export const ACTIONS_TO_EXCLUDE = ["Exit Intent (Desktop)", "50% Scroll"];

View File

@@ -27,3 +27,9 @@ export const getRoleManagementPermission = (team: TTeam): boolean => {
else if (!IS_FORMBRICKS_CLOUD) return getIsEnterpriseEdition();
else return false;
};
export const getAdvancedTargetingPermission = (team: TTeam): boolean => {
if (IS_FORMBRICKS_CLOUD) return team.billing.features.userTargeting.status !== "inactive";
else if (!IS_FORMBRICKS_CLOUD) return getIsEnterpriseEdition();
else return false;
};

View File

@@ -3,10 +3,10 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["/*"]
"~/*": ["/*"],
},
"resolveJsonModule": true
"resolveJsonModule": true,
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
"include": [".", "../ui/Targeting/TargetingIndicator.tsx"],
"exclude": ["dist", "build", "node_modules"],
}

View File

@@ -7,7 +7,7 @@
e.parentNode.insertBefore(t, e),
setTimeout(function () {
window.formbricks.init({
environmentId: "clrtawhu7002n7qagsf6t4rxj",
environmentId: "clsja4yzr00c1jyj8buxwmyds",
apiHost: "http://localhost:3000",
debug: true,
});

View File

@@ -5,6 +5,7 @@ import { TSurvey } from "@formbricks/types/surveys";
import { Config } from "./config";
import { NetworkError, Result, err, okVoid } from "./errors";
import { Logger } from "./logger";
import { sync } from "./sync";
import { renderWidget } from "./widget";
const logger = Logger.getInstance();
@@ -51,6 +52,13 @@ export const trackAction = async (
responseMessage: res.error.message,
});
}
// sync again
await sync({
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
userId,
});
}
logger.debug(`Formbricks: Action "${name}" tracked`);

View File

@@ -15,7 +15,7 @@ const syncWithBackend = async ({
environmentId,
userId,
}: TJsSyncParams): Promise<Result<TJsStateSync, NetworkError>> => {
const url = `${apiHost}/api/v1/client/${environmentId}/in-app/sync/${userId}`;
const url = `${apiHost}/api/v1/client/${environmentId}/in-app/sync/${userId}?version=${import.meta.env.VERSION}`;
const publicUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
// if user id is available
@@ -80,7 +80,7 @@ export const sync = async (params: TJsSyncParams): Promise<void> => {
surveys: syncResult.value.surveys,
noCodeActionClasses: syncResult.value.noCodeActionClasses,
product: syncResult.value.product,
attributes: oldState?.attributes || {},
attributes: syncResult.value.person?.attributes || {},
};
if (!params.userId) {

View File

@@ -3,6 +3,7 @@ import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import surveysPackageJson from "../surveys/package.json";
import packageJson from "./package.json";
const config = ({ mode }) => {
const isDevelopment = mode === "dev";
@@ -13,6 +14,7 @@ const config = ({ mode }) => {
return defineConfig({
define: {
"import.meta.env.FORMBRICKS_SURVEYS_SCRIPT_SRC": JSON.stringify(formbricksSurveysScriptSrc),
"import.meta.env.VERSION": JSON.stringify(packageJson.version),
},
build: {
emptyOutDir: false, // keep the dist folder to avoid errors with pnpm go when folder is empty during build

View File

@@ -1,6 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { differenceInDays } from "date-fns";
import { unstable_cache } from "next/cache";
import { prisma } from "@formbricks/database";
@@ -14,9 +15,11 @@ import { actionClassCache } from "../actionClass/cache";
import { createActionClass, getActionClassByEnvironmentIdAndName } from "../actionClass/service";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { createPerson, getPersonByUserId } from "../person/service";
import { surveyCache } from "../survey/cache";
import { formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { actionCache } from "./cache";
import { getStartDateOfLastMonth, getStartDateOfLastQuarter, getStartDateOfLastWeek } from "./utils";
export const getLatestActionByEnvironmentId = async (environmentId: string): Promise<TAction | null> => {
const action = await unstable_cache(
@@ -138,19 +141,13 @@ export const getActionsByPersonId = async (personId: string, page?: number): Pro
},
});
const actions: TAction[] = [];
// transforming response to type TAction[]
actionsPrisma.forEach((action) => {
actions.push({
id: action.id,
createdAt: action.createdAt,
personId: action.personId,
// sessionId: action.sessionId,
properties: action.properties,
actionClass: action.actionClass,
});
});
return actions;
return actionsPrisma.map((action) => ({
id: action.id,
createdAt: action.createdAt,
personId: action.personId,
properties: action.properties,
actionClass: action.actionClass,
}));
},
[`getActionsByPersonId-${personId}-${page}`],
{
@@ -266,6 +263,10 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
personId: person.id,
});
surveyCache.revalidate({
environmentId,
});
return {
id: action.id,
createdAt: action.createdAt,
@@ -352,3 +353,155 @@ export const getActionCountInLast7Days = async (actionClassId: string): Promise<
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getActionCountInLastQuarter = async (actionClassId: string, personId: string): Promise<number> =>
await unstable_cache(
async () => {
return await prisma.action.count({
where: {
personId,
actionClass: {
id: actionClassId,
},
createdAt: {
gte: getStartDateOfLastQuarter(),
},
},
});
},
[`getActionCountInLastQuarter-${actionClassId}-${personId}`],
{
tags: [actionClassCache.tag.byId(actionClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getActionCountInLastMonth = async (actionClassId: string, personId: string): Promise<number> =>
await unstable_cache(
async () => {
return await prisma.action.count({
where: {
personId,
actionClass: {
id: actionClassId,
},
createdAt: {
gte: getStartDateOfLastMonth(),
},
},
});
},
[`getActionCountInLastMonth-${actionClassId}-${personId}`],
{
tags: [actionClassCache.tag.byId(actionClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getActionCountInLastWeek = async (actionClassId: string, personId: string): Promise<number> =>
await unstable_cache(
async () => {
return await prisma.action.count({
where: {
personId,
actionClass: {
id: actionClassId,
},
createdAt: {
gte: getStartDateOfLastWeek(),
},
},
});
},
[`getActionCountInLastWeek-${actionClassId}-${personId}`],
{
tags: [actionClassCache.tag.byId(actionClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getTotalOccurrencesForAction = async (
actionClassId: string,
personId: string
): Promise<number> =>
await unstable_cache(
async () => {
const count = await prisma.action.count({
where: {
personId,
actionClass: {
id: actionClassId,
},
},
});
return count;
},
[`getTotalOccurrencesForAction-${actionClassId}-${personId}`],
{
tags: [actionClassCache.tag.byId(actionClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getLastOccurrenceDaysAgo = async (
actionClassId: string,
personId: string
): Promise<number | null> =>
await unstable_cache(
async () => {
const lastEvent = await prisma.action.findFirst({
where: {
personId,
actionClass: {
id: actionClassId,
},
},
orderBy: {
createdAt: "desc",
},
select: {
createdAt: true,
},
});
if (!lastEvent) return null;
return differenceInDays(new Date(), lastEvent.createdAt);
},
[`getLastOccurrenceDaysAgo-${actionClassId}-${personId}`],
{
tags: [actionClassCache.tag.byId(actionClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getFirstOccurrenceDaysAgo = async (
actionClassId: string,
personId: string
): Promise<number | null> =>
await unstable_cache(
async () => {
const firstEvent = await prisma.action.findFirst({
where: {
personId,
actionClass: {
id: actionClassId,
},
},
orderBy: {
createdAt: "asc",
},
select: {
createdAt: true,
},
});
if (!firstEvent) return null;
return differenceInDays(new Date(), firstEvent.createdAt);
},
[`getFirstOccurrenceDaysAgo-${actionClassId}-${personId}`],
{
tags: [actionClassCache.tag.byId(actionClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();

View File

@@ -0,0 +1,13 @@
import { startOfMonth, startOfQuarter, startOfWeek, subMonths, subQuarters, subWeeks } from "date-fns";
export const getStartDateOfLastQuarter = () => {
return startOfQuarter(subQuarters(new Date(), 1));
};
export const getStartDateOfLastMonth = () => {
return startOfMonth(subMonths(new Date(), 1));
};
export const getStartDateOfLastWeek = () => {
return startOfWeek(subWeeks(new Date(), 1));
};

View File

@@ -67,7 +67,13 @@ export const getAttributeClasses = async (
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return attributeClasses;
return attributeClasses.filter((attributeClass) => {
if (attributeClass.name === "userId" && attributeClass.type === "automatic") {
return false;
}
return true;
});
} catch (error) {
throw new DatabaseError(
`Database error when fetching attributeClasses for environment ${environmentId}`

View File

@@ -187,7 +187,7 @@ export const createEnvironment = async (
},
attributeClasses: {
create: [
{ name: "userId", description: "The internal ID of the person", type: "automatic" },
// { name: "userId", description: "The internal ID of the person", type: "automatic" },
{ name: "email", description: "The email of the person", type: "automatic" },
],
},

View File

@@ -1,4 +1,5 @@
// mock these globally used functions
import { ValidationError } from "@formbricks/types/errors";
jest.mock("next/cache", () => ({
__esModule: true,
@@ -20,3 +21,9 @@ beforeEach(() => {
afterEach(() => {
jest.clearAllMocks();
});
export const testInputValidation = async (service: Function, ...args: any[]): Promise<void> => {
it("it should throw a ValidationError if the inputs are invalid", async () => {
await expect(service(...args)).rejects.toThrow(ValidationError);
});
};

View File

@@ -11,7 +11,7 @@
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
"test:dev": "jest --coverage --watch",
"test": "jest -ci --coverage --no-cache"
"test": "jest -ci --coverage --no-cache --silent"
},
"dependencies": {
"@aws-sdk/s3-presigned-post": "3.499.0",

View File

@@ -0,0 +1,34 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
attributeClassName?: string;
}
export const segmentCache = {
tag: {
byId(id: string) {
return `segment-${id}`;
},
byEnvironmentId(environmentId: string): string {
return `environments-${environmentId}-segements`;
},
byAttributeClassName(attributeClassName: string): string {
return `attribute-${attributeClassName}-segements`;
},
},
revalidate({ id, environmentId, attributeClassName }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (attributeClassName) {
revalidateTag(this.tag.byAttributeClassName(attributeClassName));
}
},
};

View File

@@ -0,0 +1,622 @@
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import {
TActionMetric,
TAllOperators,
TBaseFilters,
TEvaluateSegmentUserAttributeData,
TEvaluateSegmentUserData,
TSegment,
TSegmentActionFilter,
TSegmentAttributeFilter,
TSegmentConnector,
TSegmentCreateInput,
TSegmentDeviceFilter,
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentUpdateInput,
ZSegmentCreateInput,
ZSegmentFilters,
ZSegmentUpdateInput,
} from "@formbricks/types/segment";
import {
getActionCountInLastMonth,
getActionCountInLastQuarter,
getActionCountInLastWeek,
getFirstOccurrenceDaysAgo,
getLastOccurrenceDaysAgo,
getTotalOccurrencesForAction,
} from "../action/service";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { surveyCache } from "../survey/cache";
import { validateInputs } from "../utils/validate";
import { segmentCache } from "./cache";
import { isResourceFilter, searchForAttributeClassNameInSegment } from "./utils";
type PrismaSegment = Prisma.SegmentGetPayload<{
include: {
surveys: {
select: {
id: true;
};
};
};
}>;
export const selectSegment: Prisma.SegmentDefaultArgs["select"] = {
id: true,
createdAt: true,
updatedAt: true,
title: true,
description: true,
environmentId: true,
filters: true,
isPrivate: true,
surveys: {
select: {
id: true,
},
},
};
export const transformPrismaSegment = (segment: PrismaSegment): TSegment => {
return {
...segment,
surveys: segment.surveys.map((survey) => survey.id),
};
};
export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Promise<TSegment> => {
validateInputs([segmentCreateInput, ZSegmentCreateInput]);
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
try {
const segment = await prisma.segment.create({
data: {
environmentId,
title,
description,
isPrivate,
filters,
...(surveyId && {
surveys: {
connect: {
id: surveyId,
},
},
}),
},
select: selectSegment,
});
segmentCache.revalidate({ id: segment.id, environmentId });
return transformPrismaSegment(segment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getSegments = async (environmentId: string): Promise<TSegment[]> => {
validateInputs([environmentId, ZId]);
const segments = await unstable_cache(
async () => {
try {
const segments = await prisma.segment.findMany({
where: {
environmentId,
},
select: selectSegment,
});
if (!segments) {
return [];
}
return segments.map((segment) => transformPrismaSegment(segment));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSegments-${environmentId}`],
{
tags: [segmentCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return segments;
};
export const getSegment = async (segmentId: string): Promise<TSegment> => {
validateInputs([segmentId, ZId]);
const segment = await unstable_cache(
async () => {
try {
const segment = await prisma.segment.findUnique({
where: {
id: segmentId,
},
select: selectSegment,
});
if (!segment) {
throw new ResourceNotFoundError("segment", segmentId);
}
return transformPrismaSegment(segment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSegment-${segmentId}`],
{
tags: [segmentCache.tag.byId(segmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return segment;
};
export const updateSegment = async (segmentId: string, data: TSegmentUpdateInput): Promise<TSegment> => {
validateInputs([segmentId, ZId], [data, ZSegmentUpdateInput]);
try {
let updatedInput: Prisma.SegmentUpdateInput = {
...data,
surveys: undefined,
};
if (data.surveys) {
updatedInput = {
...data,
surveys: {
connect: data.surveys.map((surveyId) => ({ id: surveyId })),
},
};
}
const currentSegment = await getSegment(segmentId);
if (!currentSegment) {
throw new ResourceNotFoundError("segment", segmentId);
}
const segment = await prisma.segment.update({
where: {
id: segmentId,
},
data: updatedInput,
select: selectSegment,
});
segmentCache.revalidate({ id: segmentId, environmentId: segment.environmentId });
segment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
return transformPrismaSegment(segment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteSegment = async (segmentId: string): Promise<TSegment> => {
try {
const currentSegment = await getSegment(segmentId);
if (!currentSegment) {
throw new ResourceNotFoundError("segment", segmentId);
}
const segment = await prisma.segment.delete({
where: {
id: segmentId,
},
select: selectSegment,
});
// pause all the running surveys that are using this segment
const surveyIds = segment.surveys.map((survey) => survey.id);
if (!!surveyIds?.length) {
await prisma.survey.updateMany({
where: {
id: { in: surveyIds },
status: "inProgress",
},
data: {
status: "paused",
},
});
}
segmentCache.revalidate({ id: segmentId, environmentId: segment.environmentId });
segment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
surveyCache.revalidate({ environmentId: currentSegment.environmentId });
return transformPrismaSegment(segment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const cloneSegment = async (segmentId: string, surveyId: string): Promise<TSegment> => {
try {
const segment = await getSegment(segmentId);
if (!segment) {
throw new ResourceNotFoundError("segment", segmentId);
}
const allSegments = await getSegments(segment.environmentId);
// Find the last "Copy of" title and extract the number from it
const lastCopyTitle = allSegments
.map((existingSegment) => existingSegment.title)
.filter((title) => title.startsWith(`Copy of ${segment.title}`))
.pop();
let suffix = 1;
if (lastCopyTitle) {
const match = lastCopyTitle.match(/\((\d+)\)$/);
if (match) {
suffix = parseInt(match[1], 10) + 1;
}
}
// Construct the title for the cloned segment
const clonedTitle = `Copy of ${segment.title} (${suffix})`;
// parse the filters and update the user segment
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
throw new ValidationError("Invalid filters");
}
const clonedSegment = await prisma.segment.create({
data: {
title: clonedTitle,
description: segment.description,
isPrivate: segment.isPrivate,
environmentId: segment.environmentId,
filters: segment.filters,
surveys: {
connect: {
id: surveyId,
},
},
},
select: selectSegment,
});
segmentCache.revalidate({ id: clonedSegment.id, environmentId: clonedSegment.environmentId });
surveyCache.revalidate({ id: surveyId });
return transformPrismaSegment(clonedSegment);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getSegmentsByAttributeClassName = async (environmentId: string, attributeClassName: string) => {
const segments = await unstable_cache(
async () => {
try {
const segments = await prisma.segment.findMany({
where: {
environmentId,
},
select: selectSegment,
});
// search for attributeClassName in the filters
const clonedSegments = structuredClone(segments);
const filteredSegments = clonedSegments.filter((segment) => {
return searchForAttributeClassNameInSegment(segment.filters, attributeClassName);
});
return filteredSegments;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSegmentsByAttributeClassName-${environmentId}-${attributeClassName}`],
{
tags: [
segmentCache.tag.byEnvironmentId(environmentId),
segmentCache.tag.byAttributeClassName(attributeClassName),
],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return segments;
};
const evaluateAttributeFilter = (
attributes: TEvaluateSegmentUserAttributeData,
filter: TSegmentAttributeFilter
): boolean => {
const { value, qualifier, root } = filter;
const { attributeClassName } = root;
const attributeValue = attributes[attributeClassName];
if (!attributeValue) {
return false;
}
const attResult = compareValues(attributeValue, value, qualifier.operator);
return attResult;
};
const evaluatePersonFilter = (userId: string, filter: TSegmentPersonFilter): boolean => {
const { value, qualifier, root } = filter;
const { personIdentifier } = root;
if (personIdentifier === "userId") {
const attResult = compareValues(userId, value, qualifier.operator);
return attResult;
}
return false;
};
const getResolvedActionValue = async (actionClassId: string, personId: string, metric: TActionMetric) => {
if (metric === "lastQuarterCount") {
const lastQuarterCount = await getActionCountInLastQuarter(actionClassId, personId);
return lastQuarterCount;
}
if (metric === "lastMonthCount") {
const lastMonthCount = await getActionCountInLastMonth(actionClassId, personId);
return lastMonthCount;
}
if (metric === "lastWeekCount") {
const lastWeekCount = await getActionCountInLastWeek(actionClassId, personId);
return lastWeekCount;
}
if (metric === "lastOccurranceDaysAgo") {
const lastOccurranceDaysAgo = await getLastOccurrenceDaysAgo(actionClassId, personId);
return lastOccurranceDaysAgo;
}
if (metric === "firstOccurranceDaysAgo") {
const firstOccurranceDaysAgo = await getFirstOccurrenceDaysAgo(actionClassId, personId);
return firstOccurranceDaysAgo;
}
if (metric === "occuranceCount") {
const occuranceCount = await getTotalOccurrencesForAction(actionClassId, personId);
return occuranceCount;
}
};
const evaluateActionFilter = async (
actionClassIds: string[],
filter: TSegmentActionFilter,
personId: string
): Promise<boolean> => {
const { value, qualifier, root } = filter;
const { actionClassId } = root;
const { metric } = qualifier;
// there could be a case when the actionIds do not have the actionClassId
// in such a case, we return false
const actionClassIdIndex = actionClassIds.findIndex((actionId) => actionId === actionClassId);
if (actionClassIdIndex === -1) {
return false;
}
// we have the action metric and we'll need to find out the values for those metrics from the db
const actionValue = await getResolvedActionValue(actionClassId, personId, metric);
const actionResult =
actionValue !== undefined && compareValues(actionValue ?? 0, value, qualifier.operator);
return actionResult;
};
const evaluateSegmentFilter = async (
userData: TEvaluateSegmentUserData,
filter: TSegmentSegmentFilter
): Promise<boolean> => {
const { qualifier, root } = filter;
const { segmentId } = root;
const { operator } = qualifier;
const segment = await getSegment(segmentId);
if (!segment) {
return false;
}
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
return false;
}
const isInSegment = await evaluateSegment(userData, parsedFilters.data);
if (operator === "userIsIn") {
return isInSegment;
}
if (operator === "userIsNotIn") {
return !isInSegment;
}
return false;
};
const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDeviceFilter): boolean => {
const { value, qualifier } = filter;
return compareValues(device, value, qualifier.operator);
};
export const compareValues = (
a: string | number | undefined,
b: string | number,
operator: TAllOperators
): boolean => {
switch (operator) {
case "equals":
return a === b;
case "notEquals":
return a !== b;
case "lessThan":
return (a as number) < (b as number);
case "lessEqual":
return (a as number) <= (b as number);
case "greaterThan":
return (a as number) > (b as number);
case "greaterEqual":
return (a as number) >= (b as number);
case "isSet":
return a !== undefined;
case "isNotSet":
return a === "" || a === null || a === undefined;
case "contains":
return (a as string).includes(b as string);
case "doesNotContain":
return !(a as string).includes(b as string);
case "startsWith":
return (a as string).startsWith(b as string);
case "endsWith":
return (a as string).endsWith(b as string);
default:
throw new Error(`Unexpected operator: ${operator}`);
}
};
type ResultConnectorPair = {
result: boolean;
connector: TSegmentConnector;
};
export const evaluateSegment = async (
userData: TEvaluateSegmentUserData,
filters: TBaseFilters
): Promise<boolean> => {
let resultPairs: ResultConnectorPair[] = [];
for (let filterItem of filters) {
const { resource } = filterItem;
let result: boolean;
if (isResourceFilter(resource)) {
const { root } = resource;
const { type } = root;
if (type === "attribute") {
result = evaluateAttributeFilter(userData.attributes, resource as TSegmentAttributeFilter);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
if (type === "person") {
result = evaluatePersonFilter(userData.userId, resource as TSegmentPersonFilter);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
if (type === "action") {
result = await evaluateActionFilter(
userData.actionIds,
resource as TSegmentActionFilter,
userData.personId
);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
if (type === "segment") {
result = await evaluateSegmentFilter(userData, resource as TSegmentSegmentFilter);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
if (type === "device") {
result = evaluateDeviceFilter(userData.deviceType, resource as TSegmentDeviceFilter);
resultPairs.push({
result,
connector: filterItem.connector,
});
}
} else {
result = await evaluateSegment(userData, resource);
// this is a sub-group and we need to evaluate the sub-group
resultPairs.push({
result,
connector: filterItem.connector,
});
}
}
if (!resultPairs.length) {
return false;
}
// Given that the first filter in every group/sub-group always has a connector value of "null",
// we initialize the finalResult with the result of the first filter.
let finalResult = resultPairs[0].result;
for (let i = 1; i < resultPairs.length; i++) {
const { result, connector } = resultPairs[i];
if (connector === "and") {
finalResult = finalResult && result;
} else if (connector === "or") {
finalResult = finalResult || result;
}
}
return finalResult;
};

View File

@@ -0,0 +1,170 @@
import {
TActionMetric,
TBaseFilters,
TBaseOperator,
TEvaluateSegmentUserAttributeData,
TEvaluateSegmentUserData,
TSegment,
TSegmentCreateInput,
TSegmentUpdateInput,
} from "@formbricks/types/segment";
export const mockSegmentId = "rh2eual2apby2bx0r027ru70";
export const mockEnvironmentId = "t7fszh4tsotoe87ppa6lqhie";
export const mockSurveyId = "phz5mjwvatwc0dqwuip90qpv";
export const mockFilterGroupId = "wi6zz4ekmcwi08bhv1hmgqcr";
export const mockFilerGroupResourceId1 = "j10rst27no5v68pjkop3p3f6";
export const mockFilterGroupResourceId11 = "qz97nzcz0phipgkkdgjlc2op";
export const mockFilterGroupResourceId2 = "wjy1rcs43knp0ef7b4jdsjri";
export const mockFilterGroupResourceId21 = "rjhll9q83qxc6fngl9byp0gn";
export const mockFilter2Id = "hp5ieqw889kt6k6z6wkuot8q";
export const mockFilter2Resource1Id = "iad253ddx4p7eshrbamsj4zk";
export const mockFilter3Id = "iix2savwqr4rv2y81ponep62";
export const mockFilter3Resource1Id = "evvoaniy0hn7srea7x0yn4vv";
// filter data:
export const mockActionClassId = "zg7lojfwnk9ipajgeumfz96t";
export const mockEmailValue = "example@example.com";
export const mockUserId = "random user id";
export const mockDeviceTypeValue = "phone";
// mock data for service input:
export const mockPersonId = "sb776r0uvt8m8puffe1hlhjn";
export const mockEvaluateSegmentUserAttributes: TEvaluateSegmentUserAttributeData = {
email: mockEmailValue,
userId: mockUserId,
};
export const mockEvaluateSegmentUserData: TEvaluateSegmentUserData = {
personId: mockPersonId,
environmentId: mockEnvironmentId,
attributes: mockEvaluateSegmentUserAttributes,
actionIds: [mockActionClassId],
deviceType: "phone",
userId: mockUserId,
};
export const mockSegmentTitle = "Engaged Users with Specific Interests";
export const mockSegmentDescription =
"Segment targeting engaged users interested in specific topics and using mobile";
export const getMockSegmentFilters = (
actionMetric: TActionMetric,
actionValue: string | number,
actionOperator: TBaseOperator
): TBaseFilters => [
{
id: mockFilterGroupId,
connector: null,
resource: [
{
id: mockFilerGroupResourceId1,
connector: null,
resource: {
id: mockFilterGroupResourceId11,
root: {
type: "attribute",
attributeClassName: "email",
},
value: mockEmailValue,
qualifier: {
operator: "equals",
},
},
},
{
id: mockFilterGroupResourceId2,
connector: "and",
resource: {
id: mockFilterGroupResourceId21,
root: {
type: "attribute",
attributeClassName: "userId",
},
value: mockUserId,
qualifier: {
operator: "equals",
},
},
},
],
},
{
id: mockFilter2Id,
connector: "and",
resource: {
id: mockFilter2Resource1Id,
root: {
type: "device",
deviceType: "phone",
},
value: mockDeviceTypeValue,
qualifier: {
operator: "equals",
},
},
},
{
id: mockFilter3Id,
connector: "and",
resource: {
id: mockFilter3Resource1Id,
root: {
type: "action",
actionClassId: mockActionClassId,
},
value: actionValue,
qualifier: {
metric: actionMetric,
operator: actionOperator,
},
},
},
];
export const mockSegment: TSegment = {
id: mockSegmentId,
title: mockSegmentTitle,
description: mockSegmentDescription,
isPrivate: false,
filters: getMockSegmentFilters("lastMonthCount", 5, "equals"),
environmentId: mockEnvironmentId,
createdAt: new Date(),
updatedAt: new Date(),
surveys: [mockSurveyId],
};
export const mockSegmentCreateInput: TSegmentCreateInput = {
title: mockSegmentTitle,
description: mockSegmentDescription,
isPrivate: false,
filters: getMockSegmentFilters("lastMonthCount", 5, "equals"),
environmentId: mockEnvironmentId,
surveyId: mockSurveyId,
};
export const mockSegmentUpdateInput: TSegmentUpdateInput = {
title: mockSegmentTitle,
description: mockSegmentDescription,
isPrivate: false,
filters: getMockSegmentFilters("lastMonthCount", 5, "greaterEqual"),
};
export const mockSegmentPrisma = {
id: mockSegmentId,
title: mockSegmentTitle,
description: mockSegmentDescription,
isPrivate: false,
filters: getMockSegmentFilters("lastMonthCount", 5, "equals"),
environmentId: mockEnvironmentId,
createdAt: new Date(),
updatedAt: new Date(),
surveys: [{ id: mockSurveyId }],
};
export const mockSegmentActiveInactiveSurves = {
activeSurveys: ["Churn Survey"],
inactiveSurveys: ["NPS Survey"],
};

View File

@@ -0,0 +1,337 @@
import {
getMockSegmentFilters,
mockEnvironmentId,
mockEvaluateSegmentUserData,
mockSegment,
mockSegmentCreateInput,
mockSegmentId,
mockSegmentPrisma,
mockSegmentUpdateInput,
mockSurveyId,
} from "./__mocks__/segment.mock";
import { Prisma } from "@prisma/client";
import { prismaMock } from "@formbricks/database/src/jestClient";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { testInputValidation } from "../../jest/jestSetup";
import {
cloneSegment,
createSegment,
deleteSegment,
evaluateSegment,
getSegment,
getSegments,
updateSegment,
} from "../service";
function addOrSubractDays(date: Date, number: number) {
return new Date(new Date().setDate(date.getDate() - number));
}
beforeEach(() => {
prismaMock.segment.findUnique.mockResolvedValue(mockSegmentPrisma);
prismaMock.segment.findMany.mockResolvedValue([mockSegmentPrisma]);
prismaMock.segment.update.mockResolvedValue({
...mockSegmentPrisma,
filters: getMockSegmentFilters("lastMonthCount", 5, "greaterEqual"),
});
});
describe("Tests for evaluateSegment service", () => {
describe("Happy Path", () => {
it("Returns true when the user meets the segment criteria", async () => {
prismaMock.action.count.mockResolvedValue(4);
const result = await evaluateSegment(
mockEvaluateSegmentUserData,
getMockSegmentFilters("lastQuarterCount", 5, "lessThan")
);
expect(result).toBe(true);
});
it("Calculates the action count for the last month", async () => {
prismaMock.action.count.mockResolvedValue(0);
const result = await evaluateSegment(
mockEvaluateSegmentUserData,
getMockSegmentFilters("lastMonthCount", 5, "lessThan")
);
expect(result).toBe(true);
});
it("Calculates the action count for the last week", async () => {
prismaMock.action.count.mockResolvedValue(6);
const result = await evaluateSegment(
mockEvaluateSegmentUserData,
getMockSegmentFilters("lastWeekCount", 5, "greaterEqual")
);
expect(result).toBe(true);
});
it("Calculates the total occurences of action", async () => {
prismaMock.action.count.mockResolvedValue(6);
const result = await evaluateSegment(
mockEvaluateSegmentUserData,
getMockSegmentFilters("occuranceCount", 5, "greaterEqual")
);
expect(result).toBe(true);
});
it("Calculates the last occurence days ago of action", async () => {
prismaMock.action.findFirst.mockResolvedValue({ createdAt: addOrSubractDays(new Date(), 5) } as any);
const result = await evaluateSegment(
mockEvaluateSegmentUserData,
getMockSegmentFilters("lastOccurranceDaysAgo", 0, "greaterEqual")
);
expect(result).toBe(true);
});
it("Calculates the first occurence days ago of action", async () => {
prismaMock.action.findFirst.mockResolvedValue({ createdAt: addOrSubractDays(new Date(), 5) } as any);
const result = await evaluateSegment(
mockEvaluateSegmentUserData,
getMockSegmentFilters("firstOccurranceDaysAgo", 6, "lessThan")
);
expect(result).toBe(true);
});
});
describe("Sad Path", () => {
it("Returns false when the user does not meet the segment criteria", async () => {
prismaMock.action.count.mockResolvedValue(0);
const result = await evaluateSegment(
mockEvaluateSegmentUserData,
getMockSegmentFilters("lastQuarterCount", 5, "greaterThan")
);
expect(result).toBe(false);
});
});
});
describe("Tests for createSegment service", () => {
describe("Happy Path", () => {
it("Creates a new user segment", async () => {
prismaMock.segment.create.mockResolvedValue(mockSegmentPrisma);
const result = await createSegment(mockSegmentCreateInput);
expect(result).toEqual(mockSegment);
});
});
describe("Sad Path", () => {
testInputValidation(createSegment, { ...mockSegmentCreateInput, title: undefined });
it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
clientVersion: "0.0.1",
});
prismaMock.segment.create.mockRejectedValue(errToThrow);
await expect(createSegment(mockSegmentCreateInput)).rejects.toThrow(DatabaseError);
});
it("Throws a generic Error for unexpected exceptions", async () => {
const mockErrorMessage = "Mock error message";
prismaMock.segment.create.mockRejectedValue(new Error(mockErrorMessage));
await expect(createSegment(mockSegmentCreateInput)).rejects.toThrow(Error);
});
});
});
describe("Tests for getSegments service", () => {
describe("Happy Path", () => {
it("Returns all user segments", async () => {
const result = await getSegments(mockEnvironmentId);
expect(result).toEqual([mockSegment]);
});
});
describe("Sad Path", () => {
testInputValidation(getSegments, "123");
it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
clientVersion: "0.0.1",
});
prismaMock.segment.findMany.mockRejectedValue(errToThrow);
await expect(getSegments(mockEnvironmentId)).rejects.toThrow(DatabaseError);
});
it("Throws a generic Error for unexpected exceptions", async () => {
const mockErrorMessage = "Mock error message";
prismaMock.segment.findMany.mockRejectedValue(new Error(mockErrorMessage));
await expect(getSegments(mockEnvironmentId)).rejects.toThrow(Error);
});
});
});
describe("Tests for getSegment service", () => {
describe("Happy Path", () => {
it("Returns a user segment", async () => {
const result = await getSegment(mockSegmentId);
expect(result).toEqual(mockSegment);
});
});
describe("Sad Path", () => {
testInputValidation(getSegment, "123");
it("Throws a ResourceNotFoundError error if the user segment does not exist", async () => {
prismaMock.segment.findUnique.mockResolvedValue(null);
await expect(getSegment(mockSegmentId)).rejects.toThrow(ResourceNotFoundError);
});
it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
clientVersion: "0.0.1",
});
prismaMock.segment.findUnique.mockRejectedValue(errToThrow);
await expect(getSegment(mockSegmentId)).rejects.toThrow(DatabaseError);
});
it("Throws a generic Error for unexpected exceptions", async () => {
const mockErrorMessage = "Mock error message";
prismaMock.segment.findUnique.mockRejectedValue(new Error(mockErrorMessage));
await expect(getSegment(mockSegmentId)).rejects.toThrow(Error);
});
});
});
describe("Tests for updateSegment service", () => {
describe("Happy Path", () => {
it("Updates a user segment", async () => {
const result = await updateSegment(mockSegmentId, mockSegmentUpdateInput);
expect(result).toEqual({
...mockSegment,
filters: getMockSegmentFilters("lastMonthCount", 5, "greaterEqual"),
});
});
});
describe("Sad Path", () => {
testInputValidation(updateSegment, "123", {});
it("Throws a ResourceNotFoundError error if the user segment does not exist", async () => {
prismaMock.segment.findUnique.mockResolvedValue(null);
await expect(updateSegment(mockSegmentId, mockSegmentCreateInput)).rejects.toThrow(
ResourceNotFoundError
);
});
it("Throws a DatabaseError error if there is a PrismaClientKnownReuestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
clientVersion: "0.0.1",
});
prismaMock.segment.update.mockRejectedValue(errToThrow);
await expect(updateSegment(mockSegmentId, mockSegmentCreateInput)).rejects.toThrow(DatabaseError);
});
it("Throws a generic Error for unexpected exceptions", async () => {
const mockErrorMessage = "Mock error message";
prismaMock.segment.update.mockRejectedValue(new Error(mockErrorMessage));
await expect(updateSegment(mockSegmentId, mockSegmentCreateInput)).rejects.toThrow(Error);
});
});
});
describe("Tests for deleteSegment service", () => {
describe("Happy Path", () => {
it("Deletes a user segment", async () => {
prismaMock.segment.delete.mockResolvedValue(mockSegmentPrisma);
const result = await deleteSegment(mockSegmentId);
expect(result).toEqual(mockSegment);
});
});
describe("Sad Path", () => {
testInputValidation(deleteSegment, "123");
it("Throws a ResourceNotFoundError error if the user segment does not exist", async () => {
prismaMock.segment.findUnique.mockResolvedValue(null);
await expect(deleteSegment(mockSegmentId)).rejects.toThrow(ResourceNotFoundError);
});
it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
clientVersion: "0.0.1",
});
prismaMock.segment.delete.mockRejectedValue(errToThrow);
await expect(deleteSegment(mockSegmentId)).rejects.toThrow(DatabaseError);
});
it("Throws a generic Error for unexpected exceptions", async () => {
const mockErrorMessage = "Mock error message";
prismaMock.segment.delete.mockRejectedValue(new Error(mockErrorMessage));
await expect(deleteSegment(mockSegmentId)).rejects.toThrow(Error);
});
});
});
describe("Tests for cloneSegment service", () => {
describe("Happy Path", () => {
it("Clones a user segment", async () => {
prismaMock.segment.create.mockResolvedValue({
...mockSegmentPrisma,
title: `Copy of ${mockSegmentPrisma.title}`,
});
const result = await cloneSegment(mockSegmentId, mockSurveyId);
expect(result).toEqual({
...mockSegment,
title: `Copy of ${mockSegment.title}`,
});
});
});
describe("Sad Path", () => {
testInputValidation(cloneSegment, "123", "123");
it("Throws a ResourceNotFoundError error if the user segment does not exist", async () => {
prismaMock.segment.findUnique.mockResolvedValue(null);
await expect(cloneSegment(mockSegmentId, mockSurveyId)).rejects.toThrow(ResourceNotFoundError);
});
it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
clientVersion: "0.0.1",
});
prismaMock.segment.create.mockRejectedValue(errToThrow);
await expect(cloneSegment(mockSegmentId, mockSurveyId)).rejects.toThrow(DatabaseError);
});
it("Throws a generic Error for unexpected exceptions", async () => {
const mockErrorMessage = "Mock error message";
prismaMock.segment.create.mockRejectedValue(new Error(mockErrorMessage));
await expect(cloneSegment(mockSegmentId, mockSurveyId)).rejects.toThrow(Error);
});
});
});

View File

@@ -0,0 +1,607 @@
import { createId } from "@paralleldrive/cuid2";
import {
TActionMetric,
TAllOperators,
TAttributeOperator,
TBaseFilter,
TBaseFilters,
TDeviceOperator,
TSegment,
TSegmentActionFilter,
TSegmentAttributeFilter,
TSegmentConnector,
TSegmentDeviceFilter,
TSegmentFilter,
TSegmentOperator,
TSegmentPersonFilter,
TSegmentSegmentFilter,
} from "@formbricks/types/segment";
// type guard to check if a resource is a filter
export const isResourceFilter = (resource: TSegmentFilter | TBaseFilters): resource is TSegmentFilter => {
return (resource as TSegmentFilter).root !== undefined;
};
export const convertOperatorToText = (operator: TAllOperators) => {
switch (operator) {
case "equals":
return "=";
case "notEquals":
return "!=";
case "lessThan":
return "<";
case "lessEqual":
return "<=";
case "greaterThan":
return ">";
case "greaterEqual":
return ">=";
case "isSet":
return "is set";
case "isNotSet":
return "is not set";
case "contains":
return "contains ";
case "doesNotContain":
return "does not contain";
case "startsWith":
return "starts with";
case "endsWith":
return "ends with";
case "userIsIn":
return "User is in";
case "userIsNotIn":
return "User is not in";
default:
return operator;
}
};
export const convertOperatorToTitle = (operator: TAllOperators) => {
switch (operator) {
case "equals":
return "Equals";
case "notEquals":
return "Not equals to";
case "lessThan":
return "Less than";
case "lessEqual":
return "Less than or equal to";
case "greaterThan":
return "Greater than";
case "greaterEqual":
return "Greater than or equal to";
case "isSet":
return "Is set";
case "isNotSet":
return "Is not set";
case "contains":
return "Contains";
case "doesNotContain":
return "Does not contain";
case "startsWith":
return "Starts with";
case "endsWith":
return "Ends with";
case "userIsIn":
return "User is in";
case "userIsNotIn":
return "User is not in";
default:
return operator;
}
};
export const convertMetricToText = (metric: TActionMetric) => {
switch (metric) {
case "lastQuarterCount":
return "Last quarter (Count)";
case "lastMonthCount":
return "Last month (Count)";
case "lastWeekCount":
return "Last week (Count)";
case "occuranceCount":
return "Occurance (Count)";
case "lastOccurranceDaysAgo":
return "Last occurrance (Days ago)";
case "firstOccurranceDaysAgo":
return "First occurrance (Days ago)";
default:
return metric;
}
};
export const addFilterBelow = (group: TBaseFilters, resourceId: string, filter: TBaseFilter) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === resourceId) {
group.splice(i + 1, 0, filter);
break;
}
} else {
if (group[i].id === resourceId) {
group.splice(i + 1, 0, filter);
break;
} else {
addFilterBelow(resource, resourceId, filter);
}
}
}
};
export const createGroupFromResource = (group: TBaseFilters, resourceId: string) => {
for (let i = 0; i < group.length; i++) {
const filters = group[i];
if (isResourceFilter(filters.resource)) {
if (filters.resource.id === resourceId) {
const newGroupToAdd: TBaseFilter = {
id: createId(),
connector: filters.connector,
resource: [
{
...filters,
connector: null,
},
],
};
group.splice(i, 1, newGroupToAdd);
break;
}
} else {
if (group[i].id === resourceId) {
// make an outer group, wrap the current group in it and add a filter below it
// const newFilter: TBaseFilter = {
// id: createId(),
// connector: "and",
// resource: {
// id: createId(),
// root: { type: "attribute", attributeClassName: "" },
// qualifier: { operator: "endsWith" },
// value: "",
// },
// };
const outerGroup: TBaseFilter = {
connector: filters.connector,
id: createId(),
resource: [{ ...filters, connector: null }],
};
group.splice(i, 1, outerGroup);
break;
} else {
createGroupFromResource(filters.resource, resourceId);
}
}
}
};
export const moveResourceUp = (group: TBaseFilters, i: number) => {
if (i === 0) {
return;
}
const previousTemp = group[i - 1];
group[i - 1] = group[i];
group[i] = previousTemp;
if (i - 1 === 0) {
const newConnector = group[i - 1].connector;
group[i - 1].connector = null;
group[i].connector = newConnector;
}
};
export const moveResourceDown = (group: TBaseFilters, i: number) => {
if (i === group.length - 1) {
return;
}
const temp = group[i + 1];
group[i + 1] = group[i];
group[i] = temp;
// after the swap, determine if the connector should be null or not
if (i === 0) {
const nextConnector = group[i].connector;
group[i].connector = null;
group[i + 1].connector = nextConnector;
}
};
export const moveResource = (group: TBaseFilters, resourceId: string, direction: "up" | "down") => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === resourceId) {
if (direction === "up") {
moveResourceUp(group, i);
break;
} else {
moveResourceDown(group, i);
break;
}
}
} else {
if (group[i].id === resourceId) {
if (direction === "up") {
moveResourceUp(group, i);
break;
} else {
moveResourceDown(group, i);
break;
}
}
moveResource(resource, resourceId, direction);
}
}
};
export const deleteResource = (group: TBaseFilters, resourceId: string) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource) && resource.id === resourceId) {
group.splice(i, 1);
if (group.length) {
group[0].connector = null;
}
break;
} else if (!isResourceFilter(resource) && group[i].id === resourceId) {
group.splice(i, 1);
if (group.length) {
group[0].connector = null;
}
break;
} else if (!isResourceFilter(resource)) {
deleteResource(resource, resourceId);
}
}
// check and delete all empty groups
deleteEmptyGroups(group);
};
export const deleteEmptyGroups = (group: TBaseFilters) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (!isResourceFilter(resource) && resource.length === 0) {
group.splice(i, 1);
} else if (!isResourceFilter(resource)) {
deleteEmptyGroups(resource);
}
}
};
export const addFilterInGroup = (group: TBaseFilters, groupId: string, filter: TBaseFilter) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
continue;
} else {
if (group[i].id === groupId) {
const { resource } = group[i];
if (!isResourceFilter(resource)) {
if (resource.length === 0) {
resource.push({
...filter,
connector: null,
});
} else {
resource.push(filter);
}
}
break;
} else {
addFilterInGroup(resource, groupId, filter);
}
}
}
};
export const toggleGroupConnector = (
group: TBaseFilters,
groupId: string,
newConnectorValue: TSegmentConnector
) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (!isResourceFilter(resource)) {
if (group[i].id === groupId) {
group[i].connector = newConnectorValue;
break;
} else {
toggleGroupConnector(resource, groupId, newConnectorValue);
}
}
}
};
export const toggleFilterConnector = (
group: TBaseFilters,
filterId: string,
newConnectorValue: TSegmentConnector
) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === filterId) {
group[i].connector = newConnectorValue;
}
} else {
toggleFilterConnector(resource, filterId, newConnectorValue);
}
}
};
export const updateOperatorInFilter = (
group: TBaseFilters,
filterId: string,
newOperator: TAttributeOperator | TSegmentOperator | TDeviceOperator
) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === filterId) {
resource.qualifier.operator = newOperator;
break;
}
} else {
updateOperatorInFilter(resource, filterId, newOperator);
}
}
};
export const updateAttributeClassNameInFilter = (
group: TBaseFilters,
filterId: string,
newAttributeClassName: string
) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === filterId) {
(resource as TSegmentAttributeFilter).root.attributeClassName = newAttributeClassName;
break;
}
} else {
updateAttributeClassNameInFilter(resource, filterId, newAttributeClassName);
}
}
};
export const updatePersonIdentifierInFilter = (
group: TBaseFilters,
filterId: string,
newPersonIdentifier: string
) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === filterId) {
(resource as TSegmentPersonFilter).root.personIdentifier = newPersonIdentifier;
}
} else {
updatePersonIdentifierInFilter(resource, filterId, newPersonIdentifier);
}
}
};
export const updateActionClassIdInFilter = (
group: TBaseFilters,
filterId: string,
newActionClassId: string
) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === filterId) {
(resource as TSegmentActionFilter).root.actionClassId = newActionClassId;
break;
}
} else {
updateActionClassIdInFilter(resource, filterId, newActionClassId);
}
}
};
export const updateMetricInFilter = (group: TBaseFilters, filterId: string, newMetric: TActionMetric) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === filterId) {
(resource as TSegmentActionFilter).qualifier.metric = newMetric;
break;
}
} else {
updateMetricInFilter(resource, filterId, newMetric);
}
}
};
export const updateSegmentIdInFilter = (group: TBaseFilters, filterId: string, newSegmentId: string) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === filterId) {
(resource as TSegmentSegmentFilter).root.segmentId = newSegmentId;
resource.value = newSegmentId;
break;
}
} else {
updateSegmentIdInFilter(resource, filterId, newSegmentId);
}
}
};
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: string | number) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === filterId) {
resource.value = newValue;
break;
}
} else {
updateFilterValue(resource, filterId, newValue);
}
}
};
export const updateDeviceTypeInFilter = (
group: TBaseFilters,
filterId: string,
newDeviceType: "phone" | "desktop"
) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
if (isResourceFilter(resource)) {
if (resource.id === filterId) {
(resource as TSegmentDeviceFilter).root.deviceType = newDeviceType;
resource.value = newDeviceType;
break;
}
} else {
updateDeviceTypeInFilter(resource, filterId, newDeviceType);
}
}
};
export const formatSegmentDateFields = (segment: TSegment): TSegment => {
if (typeof segment.createdAt === "string") {
segment.createdAt = new Date(segment.createdAt);
}
if (typeof segment.updatedAt === "string") {
segment.updatedAt = new Date(segment.updatedAt);
}
return segment;
};
export const searchForAttributeClassNameInSegment = (
filters: TBaseFilters,
attributeClassName: string
): boolean => {
for (let filter of filters) {
const { resource } = filter;
if (isResourceFilter(resource)) {
const { root } = resource;
const { type } = root;
if (type === "attribute") {
const { attributeClassName: className } = root;
if (className === attributeClassName) {
return true;
}
}
} else {
const found = searchForAttributeClassNameInSegment(resource, attributeClassName);
if (found) {
return true;
}
}
}
return false;
};
// check if a segment has a filter with "type" other than "attribute" or "person"
// if it does, this is an advanced segment
export const isAdvancedSegment = (filters: TBaseFilters): boolean => {
for (let filter of filters) {
const { resource } = filter;
if (isResourceFilter(resource)) {
const { root } = resource;
const { type } = root;
if (type !== "attribute" && type !== "person") {
return true;
}
} else {
// the resource is a group, so we don't need to recurse, we know that this is an advanced segment
return true;
}
}
return false;
};
type TAttributeFilter = {
attributeClassName: string;
operator: TAttributeOperator;
value: string;
};
export const transformSegmentFiltersToAttributeFilters = (
filters: TBaseFilters
): TAttributeFilter[] | null => {
const attributeFilters: TAttributeFilter[] = [];
for (let filter of filters) {
const { resource } = filter;
if (isResourceFilter(resource)) {
const { root, qualifier, value } = resource;
const { type } = root;
if (type === "attribute") {
const { attributeClassName } = root;
const { operator } = qualifier;
attributeFilters.push({
attributeClassName,
operator: operator as TAttributeOperator,
value: value.toString(),
});
}
if (type === "person") {
const { operator } = qualifier;
attributeFilters.push({
attributeClassName: "userId",
operator: operator as TAttributeOperator,
value: value.toString(),
});
}
} else {
// the resource is a group, so we don't need to recurse, we know that this is an advanced segment
return null;
}
}
return attributeFilters;
};

View File

@@ -5,6 +5,7 @@ interface RevalidateProps {
attributeClassId?: string;
actionClassId?: string;
environmentId?: string;
segmentId?: string;
}
export const surveyCache = {
@@ -21,8 +22,11 @@ export const surveyCache = {
byActionClassId(actionClassId: string) {
return `actionClasses-${actionClassId}-surveys`;
},
bySegmentId(segmentId: string) {
return `segments-${segmentId}-surveys`;
},
},
revalidate({ id, attributeClassId, actionClassId, environmentId }: RevalidateProps): void {
revalidate({ id, attributeClassId, actionClassId, environmentId, segmentId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
@@ -38,5 +42,9 @@ export const surveyCache = {
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (segmentId) {
revalidateTag(this.tag.bySegmentId(segmentId));
}
},
};

View File

@@ -7,23 +7,28 @@ import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/actionClasses";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TPerson } from "@formbricks/types/people";
import { TSurvey, TSurveyAttributeFilter, TSurveyInput, ZSurvey } from "@formbricks/types/surveys";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey, TSurveyInput, ZSurvey } from "@formbricks/types/surveys";
import { getActionsByPersonId } from "../action/service";
import { getActionClasses } from "../actionClass/service";
import { getAttributeClasses } from "../attributeClass/service";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { displayCache } from "../display/cache";
import { getDisplaysByPersonId } from "../display/service";
import { personCache } from "../person/cache";
import { getPerson } from "../person/service";
import { productCache } from "../product/cache";
import { getProductByEnvironmentId } from "../product/service";
import { responseCache } from "../response/cache";
import { segmentCache } from "../segment/cache";
import { evaluateSegment, getSegment, updateSegment } from "../segment/service";
import { transformSegmentFiltersToAttributeFilters } from "../segment/utils";
import { subscribeTeamMembersToSurveyResponses } from "../team/service";
import { diffInDays, formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { anySurveyHasFilters } from "./util";
export const selectSurvey = {
id: true,
@@ -69,12 +74,13 @@ export const selectSurvey = {
},
},
},
attributeFilters: {
select: {
id: true,
attributeClassId: true,
condition: true,
value: true,
segment: {
include: {
surveys: {
select: {
id: true,
},
},
},
},
};
@@ -92,14 +98,6 @@ const revalidateSurveyByActionClassId = (actionClasses: TActionClass[], actionCl
}
};
const revalidateSurveyByAttributeClassId = (attributeFilters: TSurveyAttributeFilter[]): void => {
for (const attributeFilter of attributeFilters) {
surveyCache.revalidate({
attributeClassId: attributeFilter.attributeClassId,
});
}
};
export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
const survey = await unstable_cache(
async () => {
@@ -126,10 +124,23 @@ export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
return null;
}
const transformedSurvey = {
let surveySegment: TSegment | null = null;
if (surveyPrisma.segment) {
surveySegment = formatDateFields(
{
...surveyPrisma.segment,
surveys: surveyPrisma.segment.surveys.map((survey) => survey.id),
},
ZSegment
);
}
const transformedSurvey: TSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
segment: surveySegment,
};
return transformedSurvey;
},
[`getSurvey-${surveyId}`],
@@ -144,48 +155,6 @@ export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
return survey ? formatDateFields(survey, ZSurvey) : null;
};
export const getSurveysByAttributeClassId = async (
attributeClassId: string,
page?: number
): Promise<TSurvey[]> => {
const surveys = await unstable_cache(
async () => {
validateInputs([attributeClassId, ZId], [page, ZOptionalNumber]);
const surveysPrisma = await prisma.survey.findMany({
where: {
attributeFilters: {
some: {
attributeClassId,
},
},
},
select: selectSurvey,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
};
surveys.push(transformedSurvey);
}
return surveys;
},
[`getSurveysByAttributeClassId-${attributeClassId}-${page}`],
{
tags: [surveyCache.tag.byAttributeClassId(attributeClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return surveys.map((survey) => formatDateFields(survey, ZSurvey));
};
export const getSurveysByActionClassId = async (actionClassId: string, page?: number): Promise<TSurvey[]> => {
const surveys = await unstable_cache(
async () => {
@@ -209,9 +178,19 @@ export const getSurveysByActionClassId = async (actionClassId: string, page?: nu
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
let segment: TSegment | null = null;
if (surveyPrisma.segment) {
segment = {
...surveyPrisma.segment,
surveys: surveyPrisma.segment.surveys.map((survey) => survey.id),
};
}
const transformedSurvey: TSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
segment,
};
surveys.push(transformedSurvey);
}
@@ -253,10 +232,21 @@ export const getSurveys = async (environmentId: string, page?: number): Promise<
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
let segment: TSegment | null = null;
if (surveyPrisma.segment) {
segment = {
...surveyPrisma.segment,
surveys: surveyPrisma.segment.surveys.map((survey) => survey.id),
};
}
const transformedSurvey: TSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
segment,
};
surveys.push(transformedSurvey);
}
return surveys;
@@ -286,7 +276,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
throw new ResourceNotFoundError("Survey", surveyId);
}
const { triggers, attributeFilters, environmentId, ...surveyData } = updatedSurvey;
const { triggers, environmentId, segment, ...surveyData } = updatedSurvey;
if (triggers) {
const newTriggers: string[] = [];
@@ -337,79 +327,19 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
revalidateSurveyByActionClassId(actionClasses, [...newTriggers, ...removedTriggers]);
}
if (attributeFilters) {
const newFilters: TSurveyAttributeFilter[] = [];
const removedFilters: TSurveyAttributeFilter[] = [];
// find added attribute filters
for (const attributeFilter of attributeFilters) {
if (!attributeFilter.attributeClassId || !attributeFilter.condition || !attributeFilter.value) {
continue;
}
if (
currentSurvey.attributeFilters.find(
(f) =>
f.attributeClassId === attributeFilter.attributeClassId &&
f.condition === attributeFilter.condition &&
f.value === attributeFilter.value
)
) {
continue;
} else {
newFilters.push({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
});
}
}
// find removed attribute filters
for (const attributeFilter of currentSurvey.attributeFilters) {
if (
attributeFilters.find(
(f) =>
f.attributeClassId === attributeFilter.attributeClassId &&
f.condition === attributeFilter.condition &&
f.value === attributeFilter.value
)
) {
continue;
} else {
removedFilters.push({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
});
}
if (segment) {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
// create new attribute filters
if (newFilters.length > 0) {
data.attributeFilters = {
...(data.attributeFilters || []),
create: newFilters.map((attributeFilter) => ({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
};
try {
await updateSegment(segment.id, segment);
} catch (error) {
console.error(error);
throw new Error("Error updating survey");
}
// delete removed attribute filter
if (removedFilters.length > 0) {
// delete all attribute filters that match the removed attribute classes
await Promise.all(
removedFilters.map(async (attributeFilter) => {
await prisma.surveyAttributeFilter.deleteMany({
where: {
attributeClassId: attributeFilter.attributeClassId,
},
});
})
);
}
revalidateSurveyByAttributeClassId([...newFilters, ...removedFilters]);
}
surveyData.updatedAt = new Date();
@@ -422,12 +352,21 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
const prismaSurvey = await prisma.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
triggers: updatedSurvey.triggers ? updatedSurvey.triggers : [], // Include triggers from updatedSurvey
attributeFilters: updatedSurvey.attributeFilters ? updatedSurvey.attributeFilters : [], // Include attributeFilters from updatedSurvey
segment: surveySegment,
};
surveyCache.revalidate({
@@ -465,18 +404,19 @@ export async function deleteSurvey(surveyId: string) {
environmentId: deletedSurvey.environmentId,
});
if (deletedSurvey.segment?.id) {
segmentCache.revalidate({
id: deletedSurvey.segment.id,
environmentId: deletedSurvey.environmentId,
});
}
// Revalidate triggers by actionClassId
deletedSurvey.triggers.forEach((trigger) => {
surveyCache.revalidate({
actionClassId: trigger.actionClass.id,
});
});
// Revalidate surveys by attributeClassId
deletedSurvey.attributeFilters.forEach((attributeFilter) => {
surveyCache.revalidate({
attributeClassId: attributeFilter.attributeClassId,
});
});
return deletedSurvey;
}
@@ -484,10 +424,6 @@ export async function deleteSurvey(surveyId: string) {
export const createSurvey = async (environmentId: string, surveyBody: TSurveyInput): Promise<TSurvey> => {
validateInputs([environmentId, ZId]);
if (surveyBody.attributeFilters) {
revalidateSurveyByAttributeClassId(surveyBody.attributeFilters);
}
if (surveyBody.triggers) {
const actionClasses = await getActionClasses(environmentId);
revalidateSurveyByActionClassId(actionClasses, surveyBody.triggers);
@@ -528,9 +464,10 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
select: selectSurvey,
});
const transformedSurvey = {
const transformedSurvey: TSurvey = {
...survey,
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
segment: null,
};
await subscribeTeamMembersToSurveyResponses(environmentId, survey.id);
@@ -552,11 +489,6 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
}
const actionClasses = await getActionClasses(environmentId);
const newAttributeFilters = existingSurvey.attributeFilters.map((attributeFilter) => ({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
}));
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
@@ -574,9 +506,6 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
actionClassId: getActionClassIdFromName(actionClasses, trigger),
})),
},
attributeFilters: {
create: newAttributeFilters,
},
environment: {
connect: {
id: environmentId,
@@ -600,6 +529,7 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
verifyEmail: existingSurvey.verifyEmail
? JSON.parse(JSON.stringify(existingSurvey.verifyEmail))
: Prisma.JsonNull,
segment: existingSurvey.segment ? { connect: { id: existingSurvey.segment.id } } : undefined,
},
});
@@ -608,26 +538,42 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
environmentId: newSurvey.environmentId,
});
if (newSurvey.segmentId) {
segmentCache.revalidate({
id: newSurvey.segmentId,
environmentId: newSurvey.environmentId,
});
}
// Revalidate surveys by actionClassId
revalidateSurveyByActionClassId(actionClasses, existingSurvey.triggers);
// Revalidate surveys by attributeClassId
revalidateSurveyByAttributeClassId(newAttributeFilters);
return newSurvey;
};
export const getSyncSurveys = async (environmentId: string, person: TPerson): Promise<TSurvey[]> => {
export const getSyncSurveys = async (
environmentId: string,
personId: string,
deviceType: "phone" | "desktop" = "desktop",
options?: {
version?: string;
}
): Promise<TSurvey[]> => {
validateInputs([environmentId, ZId]);
const surveys = await unstable_cache(
async () => {
const product = await getProductByEnvironmentId(environmentId);
const person = await getPerson(personId);
if (!product) {
throw new Error("Product not found");
}
if (!person) {
throw new Error("Person not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
@@ -651,37 +597,10 @@ export const getSyncSurveys = async (environmentId: string, person: TPerson): Pr
}
});
const attributeClasses = await getAttributeClasses(environmentId);
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = surveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attributeClassName = attributeClasses.find(
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
)?.name;
if (!attributeClassName) {
throw Error("Invalid attribute filter class");
}
const personAttributeValue = person.attributes[attributeClassName];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = potentialSurveysWithAttributes.filter((survey) => {
surveys = surveys.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
@@ -697,22 +616,94 @@ export const getSyncSurveys = async (environmentId: string, person: TPerson): Pr
}
});
// if no surveys have segment filters, return the surveys
if (!anySurveyHasFilters(surveys)) {
return surveys;
}
const personActions = await getActionsByPersonId(person.id);
const personActionClassIds = Array.from(
new Set(personActions?.map((action) => action.actionClass?.id ?? ""))
);
const personUserId = person.userId ?? person.attributes.userId ?? "";
// the surveys now have segment filters, so we need to evaluate them
const surveyPromises = surveys.map(async (survey) => {
const { segment } = survey;
if (!segment) {
return survey;
}
// backwards compatibility for older versions of the js package
// if the version is not provided, we will use the old method of evaluating the segment, which is attribute filters
// transform the segment filters to attribute filters and evaluate them
if (!options?.version) {
const attributeFilters = transformSegmentFiltersToAttributeFilters(segment.filters);
// if the attribute filters are null, it means the segment filters don't match the expected format for attribute filters, so we skip this survey
if (attributeFilters === null) {
return null;
}
// if there are no attribute filters, we return the survey
if (!attributeFilters.length) {
return survey;
}
// we check if the person meets the attribute filters for all the attribute filters
const isEligible = attributeFilters.every((attributeFilter) => {
const personAttributeValue = person.attributes[attributeFilter.attributeClassName];
if (attributeFilter.operator === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.operator === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
// if the operator is not equals or not equals, we skip the survey, this means that new segment filter options are being used
return false;
}
});
return isEligible ? survey : null;
}
// Evaluate the segment filters
const result = await evaluateSegment(
{
attributes: person.attributes,
actionIds: personActionClassIds,
deviceType,
environmentId,
personId: person.id,
userId: personUserId,
},
segment.filters
);
return result ? survey : null;
});
const resolvedSurveys = await Promise.all(surveyPromises);
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
if (!surveys) {
throw new ResourceNotFoundError("Survey", environmentId);
}
return surveys;
},
[`getSyncSurveys-${environmentId}-${person.userId}`],
[`getSyncSurveys-${environmentId}-${personId}`],
{
tags: [
personCache.tag.byEnvironmentIdAndUserId(environmentId, person.userId),
displayCache.tag.byPersonId(person.id),
personCache.tag.byEnvironmentId(environmentId),
personCache.tag.byId(personId),
displayCache.tag.byPersonId(personId),
surveyCache.tag.byEnvironmentId(environmentId),
productCache.tag.byEnvironmentId(environmentId),
],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return surveys.map((survey) => formatDateFields(survey, ZSurvey));
};
@@ -737,3 +728,106 @@ export const getSurveyByResultShareKey = async (resultShareKey: string): Promise
throw error;
}
};
export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: string): Promise<TSurvey> => {
try {
validateInputs([surveyId, ZId], [newSegmentId, ZId]);
const currentSurvey = await getSurvey(surveyId);
if (!currentSurvey) {
throw new ResourceNotFoundError("survey", surveyId);
}
const currentSegment = await getSegment(newSegmentId);
if (!currentSegment) {
throw new ResourceNotFoundError("segment", newSegmentId);
}
const prismaSurvey = await prisma.survey.update({
where: {
id: surveyId,
},
select: selectSurvey,
data: {
segment: {
connect: {
id: newSegmentId,
},
},
},
});
segmentCache.revalidate({ id: newSegmentId });
surveyCache.revalidate({ id: surveyId });
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
triggers: prismaSurvey.triggers.map((trigger) => trigger.actionClass.name),
segment: surveySegment,
};
return modifiedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getSurveysBySegmentId = async (segmentId: string): Promise<TSurvey[]> => {
const surveys = await unstable_cache(
async () => {
try {
const surveysPrisma = await prisma.survey.findMany({
where: { segmentId },
select: selectSurvey,
});
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
let segment: TSegment | null = null;
if (surveyPrisma.segment) {
segment = {
...surveyPrisma.segment,
surveys: surveyPrisma.segment.surveys.map((survey) => survey.id),
};
}
const transformedSurvey: TSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
segment,
};
surveys.push(transformedSurvey);
}
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveysBySegmentId-${segmentId}`],
{
tags: [surveyCache.tag.bySegmentId(segmentId), segmentCache.tag.byId(segmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return surveys;
};

View File

@@ -2,11 +2,9 @@ import { Prisma } from "@prisma/client";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TPerson } from "@formbricks/types/people";
import { TProduct } from "@formbricks/types/product";
import {
TSurvey,
TSurveyAttributeFilter,
TSurveyInput,
TSurveyQuestion,
TSurveyQuestionType,
@@ -15,6 +13,7 @@ import {
import { TTeam } from "@formbricks/types/teams";
import { TUser } from "@formbricks/types/user";
import { selectPerson } from "../../person/service";
import { selectSurvey } from "../service";
const currentDate = new Date();
@@ -59,19 +58,6 @@ export const mockDisplay = {
status: null,
};
// id: true,
// name: true,
// email: true,
// emailVerified: true,
// imageUrl: true,
// createdAt: true,
// updatedAt: true,
// onboardingCompleted: true,
// twoFactorEnabled: true,
// identityProvider: true,
// objective: true,
// notificationSettings: true,
export const mockUser: TUser = {
id: mockId,
name: "mock User",
@@ -91,10 +77,20 @@ export const mockUser: TUser = {
},
};
export const mockPerson: TPerson = {
export const mockPerson: Prisma.PersonGetPayload<{
include: typeof selectPerson;
}> = {
id: mockId,
userId: mockId,
attributes: { test: "value" },
attributes: [
{
value: "value",
attributeClass: {
id: mockId,
name: "test",
},
},
],
...commonMockProperties,
};
@@ -116,12 +112,6 @@ export const mockAttributeClass: TAttributeClass = {
...commonMockProperties,
};
export const mockAttributeFilter: TSurveyAttributeFilter = {
attributeClassId: mockId,
value: "test",
condition: "equals",
};
const mockQuestion: TSurveyQuestion = {
id: mockId,
type: TSurveyQuestionType.OpenText,
@@ -196,6 +186,8 @@ export const mockSurveyOutput: SurveyMock = {
displayPercentage: null,
createdBy: null,
pin: null,
segment: null,
segmentId: null,
resultShareKey: null,
...baseSurveyProperties,
};
@@ -206,7 +198,6 @@ export const createSurveyInput: TSurveyInput = {
displayOption: "respondMultiple",
triggers: [mockActionClass.name],
...baseSurveyProperties,
attributeFilters: [mockAttributeFilter],
};
export const updateSurveyInput: TSurvey = {
@@ -221,37 +212,12 @@ export const updateSurveyInput: TSurvey = {
createdBy: null,
pin: null,
resultShareKey: null,
segment: null,
...commonMockProperties,
...baseSurveyProperties,
attributeFilters: [mockAttributeFilter],
};
export const mockSurveyWithAttributesOutput: SurveyMock = {
...mockSurveyOutput,
attributeFilters: [
{
id: mockId,
...mockAttributeFilter,
},
],
};
export const mockTransformedSurveyOutput = {
...mockSurveyOutput,
triggers: mockSurveyOutput.triggers.map((trigger) => trigger.actionClass.name),
};
export const mockTransformedSurveyWithAttributesOutput = {
...mockTransformedSurveyOutput,
attributeFilters: [mockAttributeFilter],
};
export const mockTransformedSurveyWithAttributesIdOutput = {
...mockTransformedSurveyOutput,
attributeFilters: [
{
id: mockId,
...mockAttributeFilter,
},
],
};

View File

@@ -10,7 +10,6 @@ import {
getSurvey,
getSurveys,
getSurveysByActionClassId,
getSurveysByAttributeClassId,
getSyncSurveys,
updateSurvey,
} from "../service";
@@ -23,11 +22,8 @@ import {
mockPerson,
mockProduct,
mockSurveyOutput,
mockSurveyWithAttributesOutput,
mockTeamOutput,
mockTransformedSurveyOutput,
mockTransformedSurveyWithAttributesIdOutput,
mockTransformedSurveyWithAttributesOutput,
mockUser,
updateSurveyInput,
} from "./survey.mock";
@@ -75,32 +71,6 @@ describe("Tests for getSurvey", () => {
});
});
describe("Tests for getSurveysByAttributeClassId", () => {
describe("Happy Path", () => {
it("Returns an array of surveys for a given attributeClassId", async () => {
prismaMock.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
const surveys = await getSurveysByAttributeClassId(mockId);
expect(surveys).toEqual([mockTransformedSurveyOutput]);
});
it("Returns an empty array if no surveys are found", async () => {
prismaMock.survey.findMany.mockResolvedValueOnce([]);
const surveys = await getSurveysByAttributeClassId(mockId);
expect(surveys).toEqual([]);
});
});
describe("Sad Path", () => {
testInputValidation(getSurveysByAttributeClassId, "123");
it("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Unknown error occurred";
prismaMock.survey.findMany.mockRejectedValue(new Error(mockErrorMessage));
await expect(getSurveysByAttributeClassId(mockId)).rejects.toThrow(Error);
});
});
});
describe("Tests for getSurveysByActionClassId", () => {
describe("Happy Path", () => {
it("Returns an array of surveys for a given actionClassId", async () => {
@@ -174,7 +144,7 @@ describe("Tests for updateSurvey", () => {
prismaMock.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
prismaMock.survey.update.mockResolvedValueOnce(mockSurveyOutput);
const updatedSurvey = await updateSurvey(updateSurveyInput);
expect(updatedSurvey).toEqual(mockTransformedSurveyWithAttributesOutput);
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
});
});
@@ -211,9 +181,9 @@ describe("Tests for updateSurvey", () => {
describe("Tests for deleteSurvey", () => {
describe("Happy Path", () => {
it("Deletes a survey successfully", async () => {
prismaMock.survey.delete.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
prismaMock.survey.delete.mockResolvedValueOnce(mockSurveyOutput);
const deletedSurvey = await deleteSurvey(mockId);
expect(deletedSurvey).toEqual(mockSurveyWithAttributesOutput);
expect(deletedSurvey).toEqual(mockSurveyOutput);
});
});
@@ -236,7 +206,7 @@ describe("Tests for createSurvey", () => {
describe("Happy Path", () => {
it("Creates a survey successfully", async () => {
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyOutput);
prismaMock.team.findFirst.mockResolvedValueOnce(mockTeamOutput);
prismaMock.user.findMany.mockResolvedValueOnce([
{
@@ -259,7 +229,7 @@ describe("Tests for createSurvey", () => {
role: "engineer",
});
const createdSurvey = await createSurvey(mockId, createSurveyInput);
expect(createdSurvey).toEqual(mockTransformedSurveyWithAttributesIdOutput);
expect(createdSurvey).toEqual(mockTransformedSurveyOutput);
});
});
@@ -281,10 +251,10 @@ describe("Tests for duplicateSurvey", () => {
describe("Happy Path", () => {
it("Duplicates a survey successfully", async () => {
prismaMock.survey.findUnique.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
prismaMock.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyOutput);
const createdSurvey = await duplicateSurvey(mockId, mockId, mockId);
expect(createdSurvey).toEqual(mockSurveyWithAttributesOutput);
expect(createdSurvey).toEqual(mockSurveyOutput);
});
});
@@ -314,13 +284,15 @@ describe("Tests for getSyncedSurveys", () => {
it("Returns synced surveys", async () => {
prismaMock.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
const surveys = await getSyncSurveys(mockId, mockPerson);
prismaMock.person.findUnique.mockResolvedValueOnce(mockPerson);
const surveys = await getSyncSurveys(mockId, mockPerson.id);
expect(surveys).toEqual([mockTransformedSurveyOutput]);
});
it("Returns an empty array if no surveys are found", async () => {
prismaMock.survey.findMany.mockResolvedValueOnce([]);
const surveys = await getSyncSurveys(mockId, mockPerson);
prismaMock.person.findUnique.mockResolvedValueOnce(mockPerson);
const surveys = await getSyncSurveys(mockId, mockPerson.id);
expect(surveys).toEqual([]);
});
});
@@ -331,14 +303,14 @@ describe("Tests for getSyncedSurveys", () => {
it("does not find a Product", async () => {
prismaMock.product.findFirst.mockResolvedValueOnce(null);
await expect(getSyncSurveys(mockId, mockPerson)).rejects.toThrow(Error);
await expect(getSyncSurveys(mockId, mockPerson.id)).rejects.toThrow(Error);
});
it("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Unknown error occurred";
prismaMock.actionClass.findMany.mockResolvedValueOnce([mockActionClass]);
prismaMock.survey.create.mockRejectedValue(new Error(mockErrorMessage));
await expect(getSyncSurveys(mockId, mockPerson)).rejects.toThrow(Error);
await expect(getSyncSurveys(mockId, mockPerson.id)).rejects.toThrow(Error);
});
});
});

View File

@@ -1,8 +1,8 @@
import "server-only";
import { TSurveyDates } from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
export const formatSurveyDateFields = (survey: TSurveyDates): TSurveyDates => {
export const formatSurveyDateFields = (survey: TSurvey): TSurvey => {
if (typeof survey.createdAt === "string") {
survey.createdAt = new Date(survey.createdAt);
}
@@ -13,5 +13,18 @@ export const formatSurveyDateFields = (survey: TSurveyDates): TSurveyDates => {
survey.closeOnDate = new Date(survey.closeOnDate);
}
if (survey.segment) {
if (typeof survey.segment.createdAt === "string") {
survey.segment.createdAt = new Date(survey.segment.createdAt);
}
if (typeof survey.segment.updatedAt === "string") {
survey.segment.updatedAt = new Date(survey.segment.updatedAt);
}
}
return survey;
};
export const anySurveyHasFilters = (surveys: TSurvey[]) =>
!surveys.every((survey) => !survey.segment?.filters?.length);

View File

@@ -82,6 +82,20 @@ export const timeSince = (dateString: string) => {
});
};
export const timeSinceDate = (date: Date) => {
return formatDistance(date, new Date(), {
addSuffix: true,
});
};
export const formatDate = (date: Date) => {
return intlFormat(date, {
year: "numeric",
month: "long",
day: "numeric",
});
};
export const timeSinceConditionally = (dateString: string) => {
return new Date().getTime() - new Date(dateString).getTime() > 14 * 24 * 60 * 60 * 1000
? convertDateTimeStringShort(dateString)

View File

@@ -0,0 +1,4 @@
export const deviceType = (userAgent: string): "desktop" | "phone" =>
!!userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i)
? "phone"
: "desktop";

View File

@@ -9,7 +9,9 @@ export const validateInputs = (...pairs: ValidationPair[]): void => {
const inputValidation = schema.safeParse(value);
if (!inputValidation.success) {
console.error(`Validation failed for ${JSON.stringify(schema)}: ${inputValidation.error.message}`);
console.error(
`Validation failed for ${value} and ${JSON.stringify(schema)}: ${inputValidation.error.message}`
);
throw new ValidationError("Validation failed");
}
}

370
packages/types/segment.ts Normal file
View File

@@ -0,0 +1,370 @@
import { z } from "zod";
// The segment filter has operators, these are all the types of operators that can be used
export const BASE_OPERATORS = [
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"equals",
"notEquals",
] as const;
export const ARITHMETIC_OPERATORS = ["lessThan", "lessEqual", "greaterThan", "greaterEqual"] as const;
export type TArithmeticOperator = (typeof ARITHMETIC_OPERATORS)[number];
export const STRING_OPERATORS = ["contains", "doesNotContain", "startsWith", "endsWith"] as const;
export type TStringOperator = (typeof STRING_OPERATORS)[number];
export const ZBaseOperator = z.enum(BASE_OPERATORS);
export type TBaseOperator = z.infer<typeof ZBaseOperator>;
// An attribute filter can have these operators
export const ATTRIBUTE_OPERATORS = [
...BASE_OPERATORS,
"isSet",
"isNotSet",
"contains",
"doesNotContain",
"startsWith",
"endsWith",
] as const;
// the person filter currently has the same operators as the attribute filter
// but we might want to add more operators in the future, so we keep it separated
export const PERSON_OPERATORS = ATTRIBUTE_OPERATORS;
// A metric is always only associated with an action filter
// Metrics are used to evaluate the value of an action filter, from the database
export const ACTION_METRICS = [
"lastQuarterCount",
"lastMonthCount",
"lastWeekCount",
"occuranceCount",
"lastOccurranceDaysAgo",
"firstOccurranceDaysAgo",
] as const;
// operators for segment filters
export const SEGMENT_OPERATORS = ["userIsIn", "userIsNotIn"] as const;
// operators for device filters
export const DEVICE_OPERATORS = ["equals", "notEquals"] as const;
// all operators
export const ALL_OPERATORS = [...ATTRIBUTE_OPERATORS, ...SEGMENT_OPERATORS] as const;
export const ZAttributeOperator = z.enum(ATTRIBUTE_OPERATORS);
export type TAttributeOperator = z.infer<typeof ZAttributeOperator>;
export const ZPersonOperator = z.enum(PERSON_OPERATORS);
export type TPersonOperator = z.infer<typeof ZPersonOperator>;
export const ZSegmentOperator = z.enum(SEGMENT_OPERATORS);
export type TSegmentOperator = z.infer<typeof ZSegmentOperator>;
export const ZDeviceOperator = z.enum(DEVICE_OPERATORS);
export type TDeviceOperator = z.infer<typeof ZDeviceOperator>;
export type TAllOperators = (typeof ALL_OPERATORS)[number];
export const ZActionMetric = z.enum(ACTION_METRICS);
export type TActionMetric = z.infer<typeof ZActionMetric>;
export const ZSegmentFilterValue = z.union([z.string(), z.number()]);
export type TSegmentFilterValue = z.infer<typeof ZSegmentFilterValue>;
// the type of the root of a filter
export const ZSegmentFilterRootType = z.enum(["attribute", "action", "segment", "device", "person"]);
// Root of the filter, this defines the type of the filter and the metadata associated with it
// For example, if the root is "attribute", the attributeClassName is required
// if the root is "action", the actionClassId is required.
export const ZSegmentFilterRoot = z.discriminatedUnion("type", [
z.object({
type: z.literal(ZSegmentFilterRootType.Enum.attribute),
attributeClassId: z.string(),
}),
z.object({
type: z.literal(ZSegmentFilterRootType.Enum.person),
userId: z.string(),
}),
z.object({
type: z.literal(ZSegmentFilterRootType.Enum.action),
actionClassId: z.string(),
}),
z.object({
type: z.literal(ZSegmentFilterRootType.Enum.segment),
segmentId: z.string(),
}),
z.object({
type: z.literal(ZSegmentFilterRootType.Enum.device),
deviceType: z.string(),
}),
]);
// Each filter has a qualifier, which usually contains the operator for evaluating the filter.
// Only in the case of action filters, the metric is also included in the qualifier
// Attribute filter -> root will always have type "attribute"
export const ZSegmentAttributeFilter = z.object({
id: z.string().cuid2(),
root: z.object({
type: z.literal("attribute"),
attributeClassName: z.string(),
}),
value: ZSegmentFilterValue,
qualifier: z.object({
operator: ZAttributeOperator,
}),
});
export type TSegmentAttributeFilter = z.infer<typeof ZSegmentAttributeFilter>;
// Person filter -> root will always have type "person"
export const ZSegmentPersonFilter = z.object({
id: z.string().cuid2(),
root: z.object({
type: z.literal("person"),
personIdentifier: z.string(),
}),
value: ZSegmentFilterValue,
qualifier: z.object({
operator: ZPersonOperator,
}),
});
export type TSegmentPersonFilter = z.infer<typeof ZSegmentPersonFilter>;
// Action filter -> root will always have type "action"
// Action filters also have the metric along with the operator in the qualifier of the filter
export const ZSegmentActionFilter = z
.object({
id: z.string().cuid2(),
root: z.object({
type: z.literal("action"),
actionClassId: z.string(),
}),
value: ZSegmentFilterValue,
qualifier: z.object({
metric: z.enum(ACTION_METRICS),
operator: ZBaseOperator,
}),
})
.refine(
(actionFilter) => {
const { value } = actionFilter;
// if the value is not type of number, it's invalid
const isValueNumber = typeof value === "number";
if (!isValueNumber) {
return false;
}
return true;
},
{
message: "Value must be a number for action filters",
}
);
export type TSegmentActionFilter = z.infer<typeof ZSegmentActionFilter>;
// Segment filter -> root will always have type "segment"
export const ZSegmentSegmentFilter = z.object({
id: z.string().cuid2(),
root: z.object({
type: z.literal("segment"),
segmentId: z.string(),
}),
value: ZSegmentFilterValue,
qualifier: z.object({
operator: ZSegmentOperator,
}),
});
export type TSegmentSegmentFilter = z.infer<typeof ZSegmentSegmentFilter>;
// Device filter -> root will always have type "device"
export const ZSegmentDeviceFilter = z.object({
id: z.string().cuid2(),
root: z.object({
type: z.literal("device"),
deviceType: z.string(),
}),
value: ZSegmentFilterValue,
qualifier: z.object({
operator: ZDeviceOperator,
}),
});
export type TSegmentDeviceFilter = z.infer<typeof ZSegmentDeviceFilter>;
// A segment filter is a union of all the different filter types
export const ZSegmentFilter = z
.union([
ZSegmentActionFilter,
ZSegmentAttributeFilter,
ZSegmentPersonFilter,
ZSegmentSegmentFilter,
ZSegmentDeviceFilter,
])
// we need to refine the filter to make sure that the filter is valid
.refine(
(filter) => {
if (filter.root.type === "action") {
if (!("metric" in filter.qualifier)) {
return false;
}
}
return true;
},
{
message: "Metric operator must be specified for action filters",
}
)
.refine(
(filter) => {
// if the operator is an arithmentic operator, the value must be a number
if (
ARITHMETIC_OPERATORS.includes(filter.qualifier.operator as (typeof ARITHMETIC_OPERATORS)[number]) &&
typeof filter.value !== "number"
) {
return false;
}
// if the operator is a string operator, the value must be a string
if (
STRING_OPERATORS.includes(filter.qualifier.operator as (typeof STRING_OPERATORS)[number]) &&
typeof filter.value !== "string"
) {
return false;
}
return true;
},
{
message: "Value must be a string for string operators and a number for arithmetic operators",
}
)
.refine(
(filter) => {
const { value, qualifier } = filter;
const { operator } = qualifier;
// if the operator is "isSet" or "isNotSet", the value doesn't matter
if (operator === "isSet" || operator === "isNotSet") {
return true;
}
if (typeof value === "string") {
return value.length > 0;
}
return true;
},
{
message: "Invalid value for filters: please check your filter values",
}
);
export type TSegmentFilter = z.infer<typeof ZSegmentFilter>;
export const ZSegmentConnector = z.enum(["and", "or"]).nullable();
export type TSegmentConnector = z.infer<typeof ZSegmentConnector>;
export type TBaseFilter = {
id: string;
connector: TSegmentConnector;
resource: TSegmentFilter | TBaseFilters;
};
export type TBaseFilters = TBaseFilter[];
// here again, we refine the filters to make sure that the filters are valid
const refineFilters = (filters: TBaseFilters): boolean => {
let result = true;
for (let i = 0; i < filters.length; i++) {
const group = filters[i];
if (Array.isArray(group.resource)) {
result = refineFilters(group.resource);
} else {
// if the connector for a "first" group is not null, it's invalid
if (i === 0 && group.connector !== null) {
result = false;
break;
}
}
}
return result;
};
// The filters can be nested, so we need to use z.lazy to define the type
// more on recusrsive types -> https://zod.dev/?id=recursive-types
export const ZSegmentFilters: z.ZodType<TBaseFilters> = z
.lazy(() =>
z.array(
z.object({
id: z.string().cuid2(),
connector: ZSegmentConnector,
resource: z.union([ZSegmentFilter, ZSegmentFilters]),
})
)
)
.refine(refineFilters, {
message: "Invalid filters applied",
});
export const ZSegment = z.object({
id: z.string(),
title: z.string(),
description: z.string().nullable(),
isPrivate: z.boolean().default(true),
filters: ZSegmentFilters,
environmentId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
surveys: z.array(z.string()),
});
export const ZSegmentCreateInput = z.object({
environmentId: z.string(),
title: z.string(),
description: z.string().optional(),
isPrivate: z.boolean().default(true),
filters: ZSegmentFilters,
surveyId: z.string(),
});
export type TSegmentCreateInput = z.infer<typeof ZSegmentCreateInput>;
export type TSegment = z.infer<typeof ZSegment>;
export type TSegmentWithSurveyNames = TSegment & {
activeSurveys: string[];
inactiveSurveys: string[];
};
export const ZSegmentUpdateInput = z
.object({
title: z.string(),
description: z.string().nullable(),
isPrivate: z.boolean().default(true),
filters: ZSegmentFilters,
surveys: z.array(z.string()),
})
.partial();
export type TSegmentUpdateInput = z.infer<typeof ZSegmentUpdateInput>;
export type TEvaluateSegmentUserAttributeData = {
[attributeClassName: string]: string | number;
};
export type TEvaluateSegmentUserData = {
personId: string;
userId: string;
environmentId: string;
attributes: TEvaluateSegmentUserAttributeData;
actionIds: string[];
deviceType: "phone" | "desktop";
};

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { ZAllowedFileExtension, ZColor, ZPlacement } from "./common";
import { TPerson } from "./people";
import { ZSegment } from "./segment";
export const ZSurveyThankYouCard = z.object({
enabled: z.boolean(),
@@ -389,14 +390,6 @@ export const ZSurveyQuestions = z.array(ZSurveyQuestion);
export type TSurveyQuestions = z.infer<typeof ZSurveyQuestions>;
export const ZSurveyAttributeFilter = z.object({
attributeClassId: z.string().cuid2(),
condition: z.enum(["equals", "notEquals"]),
value: z.string(),
});
export type TSurveyAttributeFilter = z.infer<typeof ZSurveyAttributeFilter>;
const ZSurveyDisplayOption = z.enum(["displayOnce", "displayMultiple", "respondMultiple"]);
export type TSurveyDisplayOption = z.infer<typeof ZSurveyDisplayOption>;
@@ -418,7 +411,6 @@ export const ZSurvey = z.object({
environmentId: z.string(),
createdBy: z.string().nullable(),
status: ZSurveyStatus,
attributeFilters: z.array(ZSurveyAttributeFilter),
displayOption: ZSurveyDisplayOption,
autoClose: z.number().nullable(),
triggers: z.array(z.string()),
@@ -434,6 +426,7 @@ export const ZSurvey = z.object({
productOverwrites: ZSurveyProductOverwrites.nullable(),
styling: ZSurveyStyling.nullable(),
surveyClosedMessage: ZSurveyClosedMessage.nullable(),
segment: ZSegment.nullable(),
singleUse: ZSurveySingleUse.nullable(),
verifyEmail: ZSurveyVerifyEmail.nullable(),
pin: z.string().nullable().optional(),
@@ -459,7 +452,6 @@ export const ZSurveyInput = z.object({
closeOnDate: z.date().optional(),
surveyClosedMessage: ZSurveyClosedMessage.optional(),
verifyEmail: ZSurveyVerifyEmail.optional(),
attributeFilters: z.array(ZSurveyAttributeFilter).optional(),
triggers: z.array(z.string()).optional(),
});

View File

@@ -6,38 +6,43 @@ import { Modal } from "../Modal";
interface AlertDialogProps {
open: boolean;
setOpen: (open: boolean) => void;
confirmWhat: string;
onDiscard: () => void;
text?: string;
confirmButtonLabel: string;
onSave?: () => void;
headerText: string;
mainText: string;
confirmBtnLabel: string;
declineBtnLabel?: string;
declineBtnVariant?: "warn" | "minimal";
onDecline: () => void;
onConfirm?: () => void;
}
export default function AlertDialog({
open,
setOpen,
confirmWhat,
onDiscard,
text,
confirmButtonLabel,
onSave,
headerText,
mainText = "Are you sure? This action cannot be undone.",
declineBtnLabel,
onDecline,
confirmBtnLabel,
declineBtnVariant = "minimal",
onConfirm,
}: AlertDialogProps) {
return (
<Modal open={open} setOpen={setOpen} title={`Confirm ${confirmWhat}`}>
<p className="mb-6 text-sm">{text || "Are you sure? This action cannot be undone."}</p>
<Modal open={open} setOpen={setOpen} title={headerText}>
<p className="mb-6 text-slate-900">{mainText}</p>
<div className="space-x-2 text-right">
<Button variant="warn" onClick={onDiscard}>
Discard
<Button variant={declineBtnVariant} onClick={onDecline}>
{declineBtnLabel || "Discard"}
</Button>
<Button
variant="darkCTA"
onClick={() => {
if (onSave) {
onSave();
if (onConfirm) {
onConfirm();
} else {
setOpen(false);
}
setOpen(false);
}}>
{confirmButtonLabel}
{confirmBtnLabel}
</Button>
</div>
</Modal>

View File

@@ -34,8 +34,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideCloseButton?: boolean;
}
>(({ className, children, hideCloseButton, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
@@ -47,7 +49,7 @@ const DialogContent = React.forwardRef<
{...props}>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
{!hideCloseButton ? <X className="h-4 w-4" /> : null}
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>

View File

@@ -2,7 +2,6 @@
import { Dialog, Transition } from "@headlessui/react";
import { XMarkIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { Fragment } from "react";
import { cn } from "@formbricks/lib/cn";
@@ -15,7 +14,9 @@ type Modal = {
noPadding?: boolean;
blur?: boolean;
closeOnOutsideClick?: boolean;
className?: string;
size?: "md" | "lg";
hideCloseButton?: boolean;
};
export const Modal: React.FC<Modal> = ({
@@ -26,7 +27,9 @@ export const Modal: React.FC<Modal> = ({
noPadding,
blur = true,
closeOnOutsideClick = true,
className,
size = "md",
hideCloseButton = false,
}) => {
const sizeClassName = {
md: "sm:w-full sm:max-w-xl",
@@ -64,12 +67,17 @@ export const Modal: React.FC<Modal> = ({
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel
className={clsx(
"relative transform rounded-lg bg-white text-left shadow-xl transition-all sm:my-8",
className={cn(
"relative transform rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-xl",
`${noPadding ? "" : "px-4 pb-4 pt-5 sm:p-6"}`,
sizeClassName[size]
sizeClassName[size],
className
)}>
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<div
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 sm:block",
hideCloseButton && "!hidden"
)}>
<button
type="button"
className="rounded-md bg-white text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 focus:ring-offset-2"

View File

@@ -9,6 +9,7 @@ interface ModalWithTabsProps {
label?: string;
description?: string;
tabs: TabProps[];
closeOnOutsideClick?: boolean;
}
type TabProps = {
@@ -16,7 +17,15 @@ type TabProps = {
children: React.ReactNode;
};
export default function ModalWithTabs({ open, setOpen, tabs, icon, label, description }: ModalWithTabsProps) {
export default function ModalWithTabs({
open,
setOpen,
tabs,
icon,
label,
description,
closeOnOutsideClick,
}: ModalWithTabsProps) {
const [activeTab, setActiveTab] = useState(0);
const handleTabClick = (index: number) => {
@@ -30,7 +39,7 @@ export default function ModalWithTabs({ open, setOpen, tabs, icon, label, descri
}, [open]);
return (
<Modal open={open} setOpen={setOpen} noPadding>
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={closeOnOutsideClick} size="lg">
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="mr-20 flex items-center justify-between truncate p-6">

View File

@@ -12,10 +12,10 @@ const SelectGroup: React.ComponentType<SelectPrimitive.SelectGroupProps> = Selec
const SelectValue: React.ComponentType<SelectPrimitive.SelectValueProps> = SelectPrimitive.Value;
const SelectTrigger: React.ComponentType<SelectPrimitive.SelectTriggerProps> = React.forwardRef<
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & { hideArrow?: boolean }
>(({ className, hideArrow, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
@@ -24,7 +24,7 @@ const SelectTrigger: React.ComponentType<SelectPrimitive.SelectTriggerProps> = R
)}
{...props}>
{children}
<ChevronDown className="h-4 w-4 opacity-50" />
{!hideArrow ? <ChevronDown className="h-4 w-4 opacity-50" /> : null}
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;

Some files were not shown because too many files have changed in this diff Show More