mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 14:29:20 -06:00
feat: Advanced Targeting (#758)
Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
import base from "../../packages/tailwind-config/tailwind.config";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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[] }> => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -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");
|
||||
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
562
packages/ee/advancedTargeting/components/AddFilterModal.tsx
Normal file
562
packages/ee/advancedTargeting/components/AddFilterModal.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
242
packages/ee/advancedTargeting/components/CreateSegmentModal.tsx
Normal file
242
packages/ee/advancedTargeting/components/CreateSegmentModal.tsx
Normal 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;
|
||||
264
packages/ee/advancedTargeting/components/SegmentEditor.tsx
Normal file
264
packages/ee/advancedTargeting/components/SegmentEditor.tsx
Normal 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;
|
||||
1115
packages/ee/advancedTargeting/components/SegmentFilter.tsx
Normal file
1115
packages/ee/advancedTargeting/components/SegmentFilter.tsx
Normal file
File diff suppressed because it is too large
Load Diff
243
packages/ee/advancedTargeting/components/SegmentSettings.tsx
Normal file
243
packages/ee/advancedTargeting/components/SegmentSettings.tsx
Normal 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;
|
||||
132
packages/ee/advancedTargeting/lib/actions.ts
Normal file
132
packages/ee/advancedTargeting/lib/actions.ts
Normal 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);
|
||||
};
|
||||
1
packages/ee/advancedTargeting/lib/constants.ts
Normal file
1
packages/ee/advancedTargeting/lib/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ACTIONS_TO_EXCLUDE = ["Exit Intent (Desktop)", "50% Scroll"];
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
e.parentNode.insertBefore(t, e),
|
||||
setTimeout(function () {
|
||||
window.formbricks.init({
|
||||
environmentId: "clrtawhu7002n7qagsf6t4rxj",
|
||||
environmentId: "clsja4yzr00c1jyj8buxwmyds",
|
||||
apiHost: "http://localhost:3000",
|
||||
debug: true,
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)();
|
||||
|
||||
13
packages/lib/action/utils.ts
Normal file
13
packages/lib/action/utils.ts
Normal 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));
|
||||
};
|
||||
@@ -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}`
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
34
packages/lib/segment/cache.ts
Normal file
34
packages/lib/segment/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
622
packages/lib/segment/service.ts
Normal file
622
packages/lib/segment/service.ts
Normal 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;
|
||||
};
|
||||
170
packages/lib/segment/tests/__mocks__/segment.mock.ts
Normal file
170
packages/lib/segment/tests/__mocks__/segment.mock.ts
Normal 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"],
|
||||
};
|
||||
337
packages/lib/segment/tests/segment.unit.ts
Normal file
337
packages/lib/segment/tests/segment.unit.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
607
packages/lib/segment/utils.ts
Normal file
607
packages/lib/segment/utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
4
packages/lib/utils/headers.ts
Normal file
4
packages/lib/utils/headers.ts
Normal 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";
|
||||
@@ -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
370
packages/types/segment.ts
Normal 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";
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user