feat: on the fly triggers (#2110)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-02-23 20:07:35 +05:30
committed by GitHub
parent a680a62220
commit 70b7f6614e
28 changed files with 1043 additions and 305 deletions
+3
View File
@@ -51,3 +51,6 @@ Zone.Identifier
/playwright-report/
/blob-report/
/playwright/.cache/
# uploads
packages/lib/uploads
@@ -4,9 +4,6 @@ import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { TrashIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -16,6 +13,7 @@ import { toast } from "react-hot-toast";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TActionClassInput, TActionClassNoCodeConfig, TNoCodeConfig } from "@formbricks/types/actionClasses";
import { TMembershipRole } from "@formbricks/types/memberships";
import { CssSelector, InnerHtmlSelector, PageUrlSelector } from "@formbricks/ui/Actions";
import { Button } from "@formbricks/ui/Button";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
import { Input } from "@formbricks/ui/Input";
@@ -1,9 +1,6 @@
"use client";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { Terminal } from "lucide-react";
import { useState } from "react";
@@ -11,6 +8,7 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { CssSelector, InnerHtmlSelector, PageUrlSelector } from "@formbricks/ui/Actions";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -82,7 +82,10 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 rounded-lg border border-slate-300 bg-white "
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<Collapsible.CollapsibleTrigger
asChild
className="h-full w-full cursor-pointer"
id="howToSendCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckCircleIcon className="h-8 w-8 text-green-400" />
@@ -112,7 +115,8 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
: option.id === localSurvey.type
? "border-brand-dark cursor-pointer bg-slate-50"
: "cursor-pointer bg-slate-50"
)}>
)}
id={`howToSendCardOption-${option.id}`}>
<RadioGroupItem
value={option.id}
id={option.id}
@@ -0,0 +1,400 @@
import { MatchType } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/lib/testURLmatch";
import { HelpCircleIcon } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { TSurvey, TSurveyInlineTriggers } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Input } from "@formbricks/ui/Input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
const updateInlineTriggers = (
localSurvey: TSurvey,
update: (inlineTriggers: TSurveyInlineTriggers | null) => Partial<TSurveyInlineTriggers>
): TSurvey => {
return {
...localSurvey,
inlineTriggers: {
...localSurvey.inlineTriggers,
...update(localSurvey.inlineTriggers),
},
};
};
const CodeActionSelector = ({
localSurvey,
setLocalSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isCodeAction, setIsCodeAction] = useState(!!localSurvey.inlineTriggers?.codeConfig?.identifier);
const codeActionIdentifier = localSurvey.inlineTriggers?.codeConfig?.identifier || "";
const onChange = (val: string) => {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => ({
...triggers,
codeConfig: {
identifier: val,
},
}));
setLocalSurvey(updatedSurvey);
};
const onCodeActionToggle = (checked: boolean) => {
setIsCodeAction(!isCodeAction);
// reset the code action state if the user toggles off
if (!checked) {
setLocalSurvey((prevSurvey) => {
const { codeConfig, ...withoutCodeAction } = prevSurvey.inlineTriggers ?? {};
return {
...prevSurvey,
inlineTriggers: {
...withoutCodeAction,
},
};
});
}
};
return (
<div>
<AdvancedOptionToggle
title="Code Action"
description="Trigger this survey on a Code Action"
isChecked={isCodeAction}
onToggle={onCodeActionToggle}
htmlId="codeAction">
<div className="w-full rounded-lg border border-slate-100 p-4">
<Input
type="text"
value={codeActionIdentifier || ""}
onChange={(e) => onChange(e.target.value)}
className="bg-white"
placeholder="Identifier e.g. clicked-download"
id="codeActionIdentifierInput"
/>
</div>
</AdvancedOptionToggle>
</div>
);
};
const CssSelector = ({
setLocalSurvey,
localSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isCssSelector, setIsCssSelector] = useState(
!!localSurvey.inlineTriggers?.noCodeConfig?.cssSelector?.value
);
const cssSelectorValue = localSurvey.inlineTriggers?.noCodeConfig?.cssSelector?.value || "";
const onChange = (val: string) => {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => ({
...triggers,
noCodeConfig: {
...triggers?.noCodeConfig,
cssSelector: {
value: val,
},
},
}));
setLocalSurvey(updatedSurvey);
};
const onCssSelectorToggle = (checked: boolean) => {
setIsCssSelector(!isCssSelector);
// reset the css selector state if the user toggles off
if (!checked) {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => {
const { noCodeConfig } = triggers ?? {};
const { cssSelector, ...withoutCssSelector } = noCodeConfig ?? {};
return {
...triggers,
noCodeConfig: {
...withoutCssSelector,
},
};
});
setLocalSurvey(updatedSurvey);
}
};
return (
<div>
<AdvancedOptionToggle
htmlId="cssSelectorToggle"
isChecked={isCssSelector}
onToggle={onCssSelectorToggle}
customContainerClass="p-0"
title="CSS Selector"
description="If a user clicks a button with a specific CSS class or id"
childBorder={true}>
<div className="w-full rounded-lg">
<Input
type="text"
value={cssSelectorValue}
onChange={(e) => onChange(e.target.value)}
className="bg-white"
placeholder="Add .css-class or #css-id"
id="cssSelectorInput"
/>
</div>
</AdvancedOptionToggle>
</div>
);
};
const PageUrlSelector = ({
localSurvey,
setLocalSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isPageUrl, setIsPageUrl] = useState(!!localSurvey.inlineTriggers?.noCodeConfig?.pageUrl?.value);
const matchValue = localSurvey.inlineTriggers?.noCodeConfig?.pageUrl?.rule || "exactMatch";
const pageUrlValue = localSurvey.inlineTriggers?.noCodeConfig?.pageUrl?.value || "";
const updatePageUrlState = (match: MatchType, pageUrl: string): TSurvey =>
updateInlineTriggers(localSurvey, (triggers) => ({
...triggers,
noCodeConfig: {
...triggers?.noCodeConfig,
pageUrl: {
rule: match,
value: pageUrl,
},
},
}));
const onMatchChange = (match: MatchType) => {
const updatedSurvey = updatePageUrlState(match, pageUrlValue);
setLocalSurvey(updatedSurvey);
};
const onPageUrlChange = (pageUrl: string) => {
const updatedSurvey = updatePageUrlState(matchValue, pageUrl);
setLocalSurvey(updatedSurvey);
};
const onPageUrlToggle = (checked: boolean) => {
setIsPageUrl(!isPageUrl);
// reset the page url state if the user toggles off
if (!checked) {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => {
const { noCodeConfig } = triggers ?? {};
const { pageUrl, ...withoutPageUrl } = noCodeConfig ?? {};
return {
...triggers,
noCodeConfig: {
...withoutPageUrl,
},
};
});
setLocalSurvey(updatedSurvey);
}
};
return (
<div>
<AdvancedOptionToggle
htmlId="pageURLToggle"
isChecked={isPageUrl}
onToggle={onPageUrlToggle}
title="Page URL"
customContainerClass="p-0"
description="If a user visits a specific URL"
childBorder={false}>
<div className="flex w-full gap-2">
<div>
<Select
onValueChange={onMatchChange}
defaultValue={localSurvey.inlineTriggers?.noCodeConfig?.pageUrl?.rule || "exactMatch"}>
<SelectTrigger className="w-[160px] bg-white">
<SelectValue placeholder="Select match type" />
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem value="exactMatch">Exactly matches</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="startsWith">Starts with</SelectItem>
<SelectItem value="endsWith">Ends with</SelectItem>
<SelectItem value="notMatch">Does not exactly match</SelectItem>
<SelectItem value="notContains">Does not contain</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full">
<Input
type="text"
value={pageUrlValue}
onChange={(e) => onPageUrlChange(e.target.value)}
className="bg-white"
placeholder="e.g. https://app.com/dashboard"
id="pageURLInput"
/>
</div>
</div>
</AdvancedOptionToggle>
</div>
);
};
const InnerHtmlSelector = ({
localSurvey,
setLocalSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isInnerHtml, setIsInnerHtml] = useState(
!!localSurvey.inlineTriggers?.noCodeConfig?.innerHtml?.value
);
const innerHtmlValue = localSurvey.inlineTriggers?.noCodeConfig?.innerHtml?.value || "";
const onChange = (val: string) => {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => ({
...triggers,
noCodeConfig: {
...triggers?.noCodeConfig,
innerHtml: {
value: val,
},
},
}));
setLocalSurvey(updatedSurvey);
};
const onInnerHtmlToggle = (checked: boolean) => {
setIsInnerHtml(!isInnerHtml);
// reset the inner html state if the user toggles off
if (!checked) {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => {
const { noCodeConfig } = triggers ?? {};
const { innerHtml, ...withoutInnerHtml } = noCodeConfig ?? {};
return {
...triggers,
noCodeConfig: {
...withoutInnerHtml,
},
};
});
setLocalSurvey(updatedSurvey);
}
};
return (
<div>
<AdvancedOptionToggle
htmlId="innerHTMLToggle"
isChecked={isInnerHtml}
onToggle={onInnerHtmlToggle}
customContainerClass="p-0"
title="Inner Text"
description="If a user clicks a button with a specific text"
childBorder={true}>
<div className="w-full">
<div className="grid grid-cols-3 gap-x-8">
<div className="col-span-3 flex items-end">
<Input
type="text"
value={innerHtmlValue}
onChange={(e) => onChange(e.target.value)}
className="bg-white"
placeholder="e.g. 'Install App'"
id="innerHTMLInput"
/>
</div>
</div>
</div>
</AdvancedOptionToggle>
</div>
);
};
const InlineTriggers = ({
localSurvey,
setLocalSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isNoCodeAction, setIsNoCodeAction] = useState(!!localSurvey.inlineTriggers?.noCodeConfig);
const onNoCodeActionToggle = useCallback(
(checked: boolean) => {
setIsNoCodeAction(checked);
if (!checked) {
setLocalSurvey((prevSurvey) => {
const { noCodeConfig, ...withoutNoCodeConfig } = prevSurvey.inlineTriggers ?? {};
return {
...prevSurvey,
inlineTriggers: {
...withoutNoCodeConfig,
},
};
});
}
},
[setLocalSurvey]
);
// inside the no code config, if no selector is present, then the no code action is not present
useEffect(() => {
const noCodeConfig = localSurvey.inlineTriggers?.noCodeConfig ?? {};
if (Object.keys(noCodeConfig).length === 0) {
setLocalSurvey((prevSurvey) => {
const { noCodeConfig, ...withoutNoCodeConfig } = prevSurvey.inlineTriggers ?? {};
return {
...prevSurvey,
inlineTriggers: {
...withoutNoCodeConfig,
},
};
});
}
}, [localSurvey.inlineTriggers?.noCodeConfig, setLocalSurvey]);
return (
<div className="flex flex-col gap-4">
<div className="mx-4 mt-2 flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-slate-700">
<HelpCircleIcon className="h-3 w-3" />
<span className="text-xs">Custom Actions can only be used in this survey. They are not saved.</span>
</div>
<CodeActionSelector localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
<AdvancedOptionToggle
title="No Code Action"
description="Trigger this survey on a No Code Action"
htmlId="noCodeAction"
isChecked={isNoCodeAction}
onToggle={onNoCodeActionToggle}
childBorder={false}>
<div className="flex w-full flex-col gap-8 rounded-lg border border-slate-200 bg-slate-50 p-6">
<CssSelector localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
<PageUrlSelector localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
<InnerHtmlSelector localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
</div>
</AdvancedOptionToggle>
</div>
);
};
export default InlineTriggers;
@@ -4,14 +4,19 @@ import SurveyStatusDropdown from "@/app/(app)/environments/[environmentId]/surve
import { ArrowLeftIcon, Cog8ToothIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { isEqual } from "lodash";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
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 {
TSurvey,
TSurveyQuestionType,
ZSurveyInlineTriggers,
surveyHasBothTriggers,
} from "@formbricks/types/surveys";
import AlertDialog from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -73,18 +78,25 @@ export default function SurveyMenuBar({
};
}, [localSurvey, survey]);
const containsEmptyTriggers = useCallback(() => {
return (
localSurvey.type === "web" &&
localSurvey.triggers &&
(localSurvey.triggers[0] === "" || localSurvey.triggers.length === 0)
);
}, [localSurvey.triggers, localSurvey.type]);
const containsEmptyTriggers = useMemo(() => {
if (localSurvey.type !== "web") return false;
const noTriggers = !localSurvey.triggers || localSurvey.triggers.length === 0 || !localSurvey.triggers[0];
const noInlineTriggers =
!localSurvey.inlineTriggers ||
(!localSurvey.inlineTriggers?.codeConfig && !localSurvey.inlineTriggers?.noCodeConfig);
if (noTriggers && noInlineTriggers) {
return true;
}
return false;
}, [localSurvey]);
const disableSave = useMemo(() => {
if (isSurveySaving) return true;
if (localSurvey.status !== "draft" && containsEmptyTriggers()) return true;
if (localSurvey.status !== "draft" && containsEmptyTriggers) return true;
}, [containsEmptyTriggers, isSurveySaving, localSurvey.status]);
// write a function which updates the local survey status
@@ -240,6 +252,7 @@ export default function SurveyMenuBar({
return rest;
}),
};
if (!validateSurvey(localSurvey)) {
setIsSurveySaving(false);
return;
@@ -279,6 +292,22 @@ export default function SurveyMenuBar({
}
}
// if inlineTriggers are present validate with zod
if (!!strippedSurvey.inlineTriggers) {
const parsedInlineTriggers = ZSurveyInlineTriggers.safeParse(strippedSurvey.inlineTriggers);
if (!parsedInlineTriggers.success) {
toast.error("Invalid Custom Actions: Please check your custom actions");
return;
}
}
// validate that both triggers and inlineTriggers are not present
if (surveyHasBothTriggers(strippedSurvey)) {
setIsSurveySaving(false);
toast.error("Survey cannot have both custom and saved actions, please remove one.");
return;
}
try {
await updateSurveyAction({ ...strippedSurvey });
setIsSurveySaving(false);
@@ -391,7 +420,7 @@ export default function SurveyMenuBar({
)}
{localSurvey.status === "draft" && !audiencePrompt && (
<Button
disabled={isSurveySaving || containsEmptyTriggers()}
disabled={isSurveySaving || containsEmptyTriggers}
variant="darkCTA"
loading={isSurveyPublishing}
onClick={handleSurveyPublish}>
@@ -1,9 +1,10 @@
"use client";
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddActionModal";
import InlineTriggers from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/InlineTriggers";
import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TActionClass } from "@formbricks/types/actionClasses";
@@ -20,10 +21,11 @@ import {
SelectTrigger,
SelectValue,
} from "@formbricks/ui/Select";
import { TabBar } from "@formbricks/ui/TabBar";
interface WhenToSendCardProps {
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
environmentId: string;
propActionClasses: TActionClass[];
membershipRole?: TMembershipRole;
@@ -41,6 +43,21 @@ export default function WhenToSendCard({
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [actionClasses, setActionClasses] = useState<TActionClass[]>(propActionClasses);
const [randomizerToggle, setRandomizerToggle] = useState(localSurvey.displayPercentage ? true : false);
const [activeTriggerTab, setActiveTriggerTab] = useState(
!!localSurvey?.inlineTriggers ? "inline" : "relation"
);
const tabs = [
{
id: "relation",
label: "Saved Actions",
},
{
id: "inline",
label: "Custom Actions",
},
];
const { isViewer } = getAccessFlags(membershipRole);
const autoClose = localSurvey.autoClose !== null;
@@ -152,6 +169,32 @@ export default function WhenToSendCard({
}
}, [addTriggerEvent, localSurvey.triggers.length]);
const containsEmptyTriggers = useMemo(() => {
const noTriggers = !localSurvey.triggers || !localSurvey.triggers.length || !localSurvey.triggers[0];
const noInlineTriggers =
!localSurvey.inlineTriggers ||
(!localSurvey.inlineTriggers?.codeConfig && !localSurvey.inlineTriggers?.noCodeConfig);
if (noTriggers && noInlineTriggers) {
return true;
}
return false;
}, [localSurvey]);
// for inline triggers, if both the codeConfig and noCodeConfig are empty, we consider it as empty
useEffect(() => {
const inlineTriggers = localSurvey?.inlineTriggers ?? {};
if (Object.keys(inlineTriggers).length === 0) {
setLocalSurvey((prevSurvey) => {
return {
...prevSurvey,
inlineTriggers: null,
};
});
}
}, [localSurvey?.inlineTriggers, setLocalSurvey]);
if (localSurvey.type === "link") {
return null; // Hide card completely
}
@@ -171,7 +214,7 @@ export default function WhenToSendCard({
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
{!localSurvey.triggers || localSurvey.triggers.length === 0 || !localSurvey.triggers[0] ? (
{containsEmptyTriggers ? (
<div className="h-8 w-8 rounded-full border border-amber-500 bg-amber-50" />
) : (
<CheckCircleIcon className="h-8 w-8 text-green-400" />
@@ -187,60 +230,83 @@ export default function WhenToSendCard({
<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 className="px-3 pb-3 pt-1">
<div className="flex flex-col overflow-hidden rounded-lg border-2 border-slate-100">
<TabBar
tabs={tabs}
activeId={activeTriggerTab}
setActiveId={setActiveTriggerTab}
tabStyle="button"
className="bg-slate-100"
/>
<div className="p-3">
{activeTriggerTab === "inline" ? (
<div className="flex flex-col">
<InlineTriggers localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
</div>
</div>
))}
<div className="px-6 py-4">
<Button
variant="secondary"
onClick={() => {
addTriggerEvent();
}}>
<PlusIcon className="mr-2 h-4 w-4" />
Add condition
</Button>
) : (
<>
{!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 className="px-6 py-4">
<Button
variant="secondary"
onClick={() => {
addTriggerEvent();
}}>
<PlusIcon className="mr-2 h-4 w-4" />
Add condition
</Button>
</div>
</>
)}
</div>
</div>
<div className="ml-2 flex items-center space-x-1 px-4 pb-4"></div>
<div className="mb-4 mt-8 space-y-1 px-4">
<h3 className="font-semibold text-slate-800">Survey Display Settings</h3>
<p className="text-sm text-slate-500">Add a delay or auto-close the survey</p>
</div>
<AdvancedOptionToggle
htmlId="delay"
isChecked={delay}
@@ -2534,6 +2534,7 @@ export const minimalSurvey: TSurvey = {
recontactDays: null,
welcomeCard: welcomeCardDefault,
questions: [],
inlineTriggers: null,
thankYouCard: {
enabled: false,
},
@@ -9,7 +9,7 @@ export default function Testimonial() {
<div className="3xl:w-2/3 mb-10 space-y-8 px-12 xl:px-20 ">
<div>
<h2 className="text-3xl font-bold text-slate-800">
Versatile in-app surveys. Valuable user insights.
Turn customer insights into irresistible experiences.
</h2>
</div>
{/* <p className="text-slate-600">
+48 -129
View File
@@ -1,142 +1,15 @@
import { surveys, users } from "@/playwright/utils/mock";
import { expect, test } from "@playwright/test";
import { signUpAndLogin, skipOnboarding } from "./utils/helper";
import { createSurvey } from "./utils/helper";
test.describe("Survey Create & Submit Response", async () => {
test.describe.configure({ mode: "serial" });
let url: string | null;
const { name, email, password } = users.survey[0];
let addQuestion = "Add QuestionAdd a new question to your survey";
test("Create Survey", async ({ page }) => {
await signUpAndLogin(page, name, email, password);
await skipOnboarding(page);
await page.getByRole("heading", { name: "Start from Scratch" }).click();
// Welcome Card
await expect(page.locator("#welcome-toggle")).toBeVisible();
await page.getByText("Welcome Card").click();
await page.locator("#welcome-toggle").check();
await page.getByLabel("Headline").fill(surveys.createAndSubmit.welcomeCard.headline);
await page
.locator("form")
.getByText("Thanks for providing your")
.fill(surveys.createAndSubmit.welcomeCard.description);
await page.getByText("Welcome CardEnabled").click();
// Open Text Question
await page.getByRole("button", { name: "1 What would you like to know" }).click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.openTextQuestion.question);
await page.getByLabel("Description").fill(surveys.createAndSubmit.openTextQuestion.description);
await page.getByLabel("Placeholder").fill(surveys.createAndSubmit.openTextQuestion.placeholder);
await page.getByRole("button", { name: surveys.createAndSubmit.openTextQuestion.question }).click();
// Single Select Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Single-Select" }).click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.singleSelectQuestion.question);
await page.getByLabel("Description").fill(surveys.createAndSubmit.singleSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
// Multi Select Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select" }).click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.multiSelectQuestion.question);
await page.getByRole("button", { name: "Add Description", exact: true }).click();
await page.getByLabel("Description").fill(surveys.createAndSubmit.multiSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.multiSelectQuestion.options[2]);
// Rating Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Rating" }).click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.ratingQuestion.question);
await page.getByLabel("Description").fill(surveys.createAndSubmit.ratingQuestion.description);
await page.getByPlaceholder("Not good").fill(surveys.createAndSubmit.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").fill(surveys.createAndSubmit.ratingQuestion.highLabel);
// NPS Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.npsQuestion.question);
await page.getByLabel("Lower label").fill(surveys.createAndSubmit.npsQuestion.lowLabel);
await page
.locator("div")
.filter({ hasText: /^Upper label$/ })
.locator("#subheader")
.fill(surveys.createAndSubmit.npsQuestion.highLabel);
// CTA Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Call-to-Action" }).click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.ctaQuestion.question);
await page.getByPlaceholder("Finish").fill(surveys.createAndSubmit.ctaQuestion.buttonLabel);
// Consent Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Consent" }).click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.consentQuestion.question);
await page
.getByPlaceholder("I agree to the terms and")
.fill(surveys.createAndSubmit.consentQuestion.checkboxLabel);
// Picture Select Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Picture Selection" }).click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.pictureSelectQuestion.question);
await page.getByLabel("Description").fill(surveys.createAndSubmit.pictureSelectQuestion.description);
// File Upload Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "File Upload" }).click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.fileUploadQuestion.question);
// Thank You Card
await page
.locator("div")
.filter({ hasText: /^Thank You CardShown$/ })
.nth(1)
.click();
await page.getByLabel("Question").fill(surveys.createAndSubmit.thankYouCard.headline);
await page.getByLabel("Description").fill(surveys.createAndSubmit.thankYouCard.description);
await createSurvey(page, name, email, password, surveys.createAndSubmit);
// Save & Publish Survey
await page.getByRole("button", { name: "Continue to Settings" }).click();
await page.getByRole("button", { name: "Publish" }).click();
@@ -149,6 +22,52 @@ test.describe("Survey Create & Submit Response", async () => {
.innerText();
});
test("Create Survey with Custom Actions", async ({ page }) => {
const { name, email, password } = users.survey[1];
await createSurvey(page, name, email, password, surveys.createAndSubmit);
// Save & Publish Survey
await page.getByRole("button", { name: "Continue to Settings" }).click();
await expect(page.locator("#howToSendCardTrigger")).toBeVisible();
await page.locator("#howToSendCardTrigger").click();
await expect(page.locator("#howToSendCardOption-web")).toBeVisible();
await page.locator("#howToSendCardOption-web").click();
await expect(page.getByText("Survey Trigger")).toBeVisible();
await page.getByText("Survey Trigger").click();
await page.getByRole("button", { name: "Custom Actions" }).click();
await expect(page.locator("#codeAction")).toBeVisible();
await page.locator("#codeAction").click();
await expect(page.locator("#codeActionIdentifierInput")).toBeVisible();
await page.locator("#codeActionIdentifierInput").fill("my-custom-code-action");
await expect(page.locator("#noCodeAction")).toBeVisible();
await page.locator("#noCodeAction").click();
await expect(page.locator("#cssSelectorToggle")).toBeVisible();
await expect(page.locator("#pageURLToggle")).toBeVisible();
await expect(page.locator("#innerHTMLToggle")).toBeVisible();
await page.locator("#cssSelectorToggle").click();
await expect(page.locator("#cssSelectorInput")).toBeVisible();
await page.locator("#cssSelectorInput").fill(".my-custom-class");
await page.locator("#pageURLToggle").click();
await expect(page.locator("#pageURLInput")).toBeVisible();
await page.locator("#pageURLInput").fill("custom-url");
await page.locator("#innerHTMLToggle").click();
await expect(page.locator("#innerHTMLInput")).toBeVisible();
await page.locator("#innerHTMLInput").fill("Download");
await page.getByRole("button", { name: "Publish" }).click();
});
test("Submit Survey Response", async ({ page }) => {
await page.goto(url!);
await page.waitForURL(/\/s\/[A-Za-z0-9]+$/);
+133
View File
@@ -1,3 +1,4 @@
import { CreateSurveyParams } from "@/playwright/utils/mock";
import { expect } from "@playwright/test";
import { readFileSync, writeFileSync } from "fs";
import { Page } from "playwright";
@@ -75,3 +76,135 @@ export const signupUsingInviteToken = async (page: Page, name: string, email: st
await page.getByPlaceholder("*******").fill(password);
await page.getByRole("button", { name: "Login with Email" }).click();
};
export const createSurvey = async (
page: Page,
name: string,
email: string,
password: string,
params: CreateSurveyParams
) => {
const addQuestion = "Add QuestionAdd a new question to your survey";
await signUpAndLogin(page, name, email, password);
await skipOnboarding(page);
await page.getByRole("heading", { name: "Start from Scratch" }).click();
// Welcome Card
await expect(page.locator("#welcome-toggle")).toBeVisible();
await page.getByText("Welcome Card").click();
await page.locator("#welcome-toggle").check();
await page.getByLabel("Headline").fill(params.welcomeCard.headline);
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
await page.getByText("Welcome CardEnabled").click();
// Open Text Question
await page.getByRole("button", { name: "1 What would you like to know" }).click();
await page.getByLabel("Question").fill(params.openTextQuestion.question);
await page.getByLabel("Description").fill(params.openTextQuestion.description);
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
await page.getByRole("button", { name: params.openTextQuestion.question }).click();
// Single Select Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Single-Select" }).click();
await page.getByLabel("Question").fill(params.singleSelectQuestion.question);
await page.getByLabel("Description").fill(params.singleSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
// Multi Select Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select" }).click();
await page.getByLabel("Question").fill(params.multiSelectQuestion.question);
await page.getByRole("button", { name: "Add Description", exact: true }).click();
await page.getByLabel("Description").fill(params.multiSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
// Rating Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Rating" }).click();
await page.getByLabel("Question").fill(params.ratingQuestion.question);
await page.getByLabel("Description").fill(params.ratingQuestion.description);
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
// NPS Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
await page.getByLabel("Question").fill(params.npsQuestion.question);
await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel);
await page
.locator("div")
.filter({ hasText: /^Upper label$/ })
.locator("#subheader")
.fill(params.npsQuestion.highLabel);
// CTA Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Call-to-Action" }).click();
await page.getByLabel("Question").fill(params.ctaQuestion.question);
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
// Consent Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Consent" }).click();
await page.getByLabel("Question").fill(params.consentQuestion.question);
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
// Picture Select Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Picture Selection" }).click();
await page.getByLabel("Question").fill(params.pictureSelectQuestion.question);
await page.getByLabel("Description").fill(params.pictureSelectQuestion.description);
// File Upload Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "File Upload" }).click();
await page.getByLabel("Question").fill(params.fileUploadQuestion.question);
// Thank You Card
await page
.locator("div")
.filter({ hasText: /^Thank You CardShown$/ })
.nth(1)
.click();
await page.getByLabel("Question").fill(params.thankYouCard.headline);
await page.getByLabel("Description").fill(params.thankYouCard.description);
};
+7
View File
@@ -24,6 +24,11 @@ export const users = {
email: "survey1@formbricks.com",
password: "Y1I*EpURUSb32j5XijP",
},
{
name: "Survey User 2",
email: "survey2@formbricks.com",
password: "Y1I*EpURUSb32j5XijP",
},
],
js: [
{
@@ -137,6 +142,8 @@ export const surveys = {
},
};
export type CreateSurveyParams = typeof surveys.createAndSubmit;
export const actions = {
create: {
noCode: {
+2
View File
@@ -5,6 +5,7 @@ import { TBaseFilters } from "@formbricks/types/segment";
import {
TSurveyClosedMessage,
TSurveyHiddenFields,
TSurveyInlineTriggers,
TSurveyProductOverwrites,
TSurveyQuestions,
TSurveySingleUse,
@@ -36,5 +37,6 @@ declare global {
export type TeamBilling = TTeamBilling;
export type UserNotificationSettings = TUserNotificationSettings;
export type SegmentFilter = TBaseFilters;
export type SurveyInlineTriggers = TSurveyInlineTriggers;
}
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "inlineTriggers" JSONB;
+3
View File
@@ -276,6 +276,9 @@ model Survey {
displayOption displayOptions @default(displayOnce)
recontactDays Int?
triggers SurveyTrigger[]
/// @zod.custom(imports.ZSurveyInlineTriggers)
/// [SurveyInlineTriggers]
inlineTriggers Json?
attributeFilters SurveyAttributeFilter[]
displays Display[]
autoClose Int?
+1
View File
@@ -21,6 +21,7 @@ export {
ZSurveyStyling,
ZSurveyVerifyEmail,
ZSurveySingleUse,
ZSurveyInlineTriggers,
} from "@formbricks/types/surveys";
export { ZSegmentFilters } from "@formbricks/types/segment";
+16 -1
View File
@@ -20,7 +20,22 @@ const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
};
export const trackAction = async (name: string): Promise<Result<void, NetworkError>> => {
const { userId } = config.get();
const {
userId,
state: { surveys = [] },
} = config.get();
// if surveys have a inline triggers, we need to check the name of the action in the code action config
surveys.forEach(async (survey) => {
const { inlineTriggers } = survey;
const { codeConfig } = inlineTriggers ?? {};
if (name === codeConfig?.identifier) {
await renderWidget(survey);
return;
}
});
const input: TJsActionInput = {
environmentId: config.get().environmentId,
userId,
+106 -54
View File
@@ -1,10 +1,12 @@
import type { TActionClass } from "@formbricks/types/actionClasses";
import type { TActionClassPageUrlRule } from "@formbricks/types/actionClasses";
import { TSurveyInlineTriggers } from "@formbricks/types/surveys";
import { trackAction } from "./actions";
import { Config } from "./config";
import { ErrorHandler, InvalidMatchTypeError, NetworkError, Result, err, match, ok, okVoid } from "./errors";
import { Logger } from "./logger";
import { renderWidget } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
@@ -13,37 +15,53 @@ const errorHandler = ErrorHandler.getInstance();
export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError | NetworkError>> => {
logger.debug(`Checking page url: ${window.location.href}`);
const { state } = config.get();
if (state?.noCodeActionClasses === undefined) {
return okVoid();
}
const { noCodeActionClasses = [], surveys = [] } = state ?? {};
const pageUrlEvents: TActionClass[] = (state?.noCodeActionClasses || []).filter((action) => {
const actionsWithPageUrl: TActionClass[] = noCodeActionClasses.filter((action) => {
const { innerHtml, cssSelector, pageUrl } = action.noCodeConfig || {};
return pageUrl && !innerHtml && !cssSelector;
});
if (pageUrlEvents.length === 0) {
return okVoid();
const surveysWithInlineTriggers = surveys.filter((survey) => {
const { pageUrl, cssSelector, innerHtml } = survey.inlineTriggers?.noCodeConfig || {};
return pageUrl && !cssSelector && !innerHtml;
});
if (actionsWithPageUrl.length > 0) {
for (const event of actionsWithPageUrl) {
if (!event.noCodeConfig?.pageUrl) {
continue;
}
const {
noCodeConfig: { pageUrl },
} = event;
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
if (match.ok !== true) return err(match.error);
if (match.value === false) continue;
const trackResult = await trackAction(event.name);
if (trackResult.ok !== true) return err(trackResult.error);
}
}
for (const event of pageUrlEvents) {
if (!event.noCodeConfig?.pageUrl) {
continue;
}
if (surveysWithInlineTriggers.length > 0) {
surveysWithInlineTriggers.forEach((survey) => {
const { noCodeConfig } = survey.inlineTriggers ?? {};
const { pageUrl } = noCodeConfig ?? {};
const {
noCodeConfig: { pageUrl },
} = event;
if (pageUrl) {
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
if (match.ok !== true) return err(match.error);
if (match.ok !== true) return err(match.error);
if (match.value === false) continue;
const trackResult = await trackAction(event.name);
if (trackResult.ok !== true) return err(trackResult.error);
renderWidget(survey);
}
});
}
return okVoid();
@@ -102,51 +120,85 @@ export function checkUrlMatch(
}
}
const evaluateNoCodeConfig = (
targetElement: HTMLElement,
action: TActionClass | TSurveyInlineTriggers
): boolean => {
const innerHtml = action.noCodeConfig?.innerHtml?.value;
const cssSelectors = action.noCodeConfig?.cssSelector?.value;
const pageUrl = action.noCodeConfig?.pageUrl?.value;
const pageUrlRule = action.noCodeConfig?.pageUrl?.rule;
if (!innerHtml && !cssSelectors && !pageUrl) {
return false;
}
if (innerHtml && targetElement.innerHTML !== innerHtml) {
return false;
}
if (cssSelectors) {
// Split selectors that start with a . or # including the . or #
const individualSelectors = cssSelectors.split(/\s*(?=[.#])/);
for (let selector of individualSelectors) {
if (!targetElement.matches(selector)) {
return false;
}
}
}
if (pageUrl && pageUrlRule) {
const urlMatch = checkUrlMatch(window.location.href, pageUrl, pageUrlRule);
if (!urlMatch.ok || !urlMatch.value) {
return false;
}
}
return true;
};
export const checkClickMatch = (event: MouseEvent) => {
const { state } = config.get();
if (!state) {
return;
}
const { noCodeActionClasses } = state;
if (!noCodeActionClasses) {
return;
}
const targetElement = event.target as HTMLElement;
(state?.noCodeActionClasses || []).forEach((action: TActionClass) => {
const innerHtml = action.noCodeConfig?.innerHtml?.value;
const cssSelectors = action.noCodeConfig?.cssSelector?.value;
const pageUrl = action.noCodeConfig?.pageUrl?.value;
const pageUrlRule = action.noCodeConfig?.pageUrl?.rule;
if (!innerHtml && !cssSelectors && !pageUrl) {
return;
noCodeActionClasses.forEach((action: TActionClass) => {
const shouldTrack = evaluateNoCodeConfig(targetElement, action);
if (shouldTrack) {
trackAction(action.name).then((res) => {
match(
res,
(_value) => {},
(err) => {
errorHandler.handle(err);
}
);
});
}
});
if (innerHtml && targetElement.innerHTML !== innerHtml) {
return;
}
// check for the inline triggers as well
const activeSurveys = state.surveys;
if (!activeSurveys || activeSurveys.length === 0) {
return;
}
if (cssSelectors) {
// Split selectors that start with a . or # including the . or #
const individualSelectors = cssSelectors.split(/\s*(?=[.#])/);
for (let selector of individualSelectors) {
if (!targetElement.matches(selector)) {
return;
}
activeSurveys.forEach((survey) => {
const { inlineTriggers } = survey;
if (inlineTriggers) {
const shouldTrack = evaluateNoCodeConfig(targetElement, inlineTriggers);
if (shouldTrack) {
renderWidget(survey);
}
}
if (pageUrl && pageUrlRule) {
const urlMatch = checkUrlMatch(window.location.href, pageUrl, pageUrlRule);
if (!urlMatch.ok || !urlMatch.value) {
return;
}
}
trackAction(action.name).then((res) => {
match(
res,
(_value) => {},
(err) => {
errorHandler.handle(err);
}
);
});
});
};
+9 -2
View File
@@ -10,7 +10,7 @@ import { ZId } from "@formbricks/types/environment";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TPerson } from "@formbricks/types/people";
import { TSegment, ZSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey, TSurveyInput, ZSurvey } from "@formbricks/types/surveys";
import { TSurvey, TSurveyInput, ZSurvey, ZSurveyWithRefinements } from "@formbricks/types/surveys";
import { getActionsByPersonId } from "../action/service";
import { getActionClasses } from "../actionClass/service";
@@ -75,6 +75,7 @@ export const selectSurvey = {
},
},
},
inlineTriggers: true,
segment: {
include: {
surveys: {
@@ -265,7 +266,7 @@ export const getSurveys = async (environmentId: string, page?: number): Promise<
};
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
validateInputs([updatedSurvey, ZSurvey]);
validateInputs([updatedSurvey, ZSurveyWithRefinements]);
const surveyId = updatedSurvey.id;
let data: any = {};
@@ -425,6 +426,11 @@ export async function deleteSurvey(surveyId: string) {
export const createSurvey = async (environmentId: string, surveyBody: TSurveyInput): Promise<TSurvey> => {
validateInputs([environmentId, ZId]);
// if the survey body has both triggers and inlineTriggers, we throw an error
if (surveyBody.triggers && surveyBody.inlineTriggers) {
throw new InvalidInputError("Survey body cannot have both triggers and inlineTriggers");
}
if (surveyBody.triggers) {
const actionClasses = await getActionClasses(environmentId);
revalidateSurveyByActionClassId(actionClasses, surveyBody.triggers);
@@ -507,6 +513,7 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
actionClassId: getActionClassIdFromName(actionClasses, trigger),
})),
},
inlineTriggers: existingSurvey.inlineTriggers ?? undefined,
environment: {
connect: {
id: environmentId,
+2
View File
@@ -189,6 +189,7 @@ export const mockSurveyOutput: SurveyMock = {
segment: null,
segmentId: null,
resultShareKey: null,
inlineTriggers: null,
...baseSurveyProperties,
};
@@ -213,6 +214,7 @@ export const updateSurveyInput: TSurvey = {
pin: null,
resultShareKey: null,
segment: null,
inlineTriggers: null,
...commonMockProperties,
...baseSurveyProperties,
};
+1
View File
@@ -42,6 +42,7 @@ export function formatDateFields<T extends z.ZodRawShape>(
return formattedObject as z.infer<typeof zodSchema>;
}
export const formatDateWithOrdinal = (date: Date): string => {
const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
+70 -19
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { ZNoCodeConfig } from "./actionClasses";
import { ZAllowedFileExtension, ZColor, ZPlacement } from "./common";
import { TPerson } from "./people";
import { ZSegment } from "./segment";
@@ -402,6 +403,30 @@ const ZSurveyStatus = z.enum(["draft", "inProgress", "paused", "completed"]);
export type TSurveyStatus = z.infer<typeof ZSurveyStatus>;
export const ZSurveyInlineTriggers = z.object({
codeConfig: z.object({ identifier: z.string() }).optional(),
noCodeConfig: ZNoCodeConfig.omit({ type: true }).optional(),
});
export type TSurveyInlineTriggers = z.infer<typeof ZSurveyInlineTriggers>;
export const surveyHasBothTriggers = (survey: TSurvey) => {
// if the triggers array has a single empty string, it means the survey has no triggers
if (survey.triggers?.[0] === "") {
return false;
}
const hasTriggers = survey.triggers?.length > 0;
const hasInlineTriggers = !!survey.inlineTriggers?.codeConfig || !!survey.inlineTriggers?.noCodeConfig;
// Survey cannot have both triggers and inlineTriggers
if (hasTriggers && hasInlineTriggers) {
return true;
}
return false;
};
export const ZSurvey = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
@@ -414,6 +439,7 @@ export const ZSurvey = z.object({
displayOption: ZSurveyDisplayOption,
autoClose: z.number().nullable(),
triggers: z.array(z.string()),
inlineTriggers: ZSurveyInlineTriggers.nullable(),
redirectUrl: z.string().url().nullable(),
recontactDays: z.number().nullable(),
welcomeCard: ZSurveyWelcomeCard,
@@ -434,27 +460,52 @@ export const ZSurvey = z.object({
displayPercentage: z.number().min(1).max(100).nullable(),
});
export const ZSurveyInput = z.object({
name: z.string(),
type: ZSurveyType.optional(),
createdBy: z.string().cuid().optional(),
status: ZSurveyStatus.optional(),
displayOption: ZSurveyDisplayOption.optional(),
autoClose: z.number().optional(),
redirectUrl: z.string().url().optional(),
recontactDays: z.number().optional(),
welcomeCard: ZSurveyWelcomeCard.optional(),
questions: ZSurveyQuestions.optional(),
thankYouCard: ZSurveyThankYouCard.optional(),
hiddenFields: ZSurveyHiddenFields,
delay: z.number().optional(),
autoComplete: z.number().optional(),
closeOnDate: z.date().optional(),
surveyClosedMessage: ZSurveyClosedMessage.optional(),
verifyEmail: ZSurveyVerifyEmail.optional(),
triggers: z.array(z.string()).optional(),
export const ZSurveyWithRefinements = ZSurvey.refine((survey) => !surveyHasBothTriggers(survey), {
message: "Survey cannot have both triggers and inlineTriggers",
});
export const ZSurveyInput = z
.object({
name: z.string(),
type: ZSurveyType.optional(),
createdBy: z.string().cuid().optional(),
status: ZSurveyStatus.optional(),
displayOption: ZSurveyDisplayOption.optional(),
autoClose: z.number().optional(),
redirectUrl: z.string().url().optional(),
recontactDays: z.number().optional(),
welcomeCard: ZSurveyWelcomeCard.optional(),
questions: ZSurveyQuestions.optional(),
thankYouCard: ZSurveyThankYouCard.optional(),
hiddenFields: ZSurveyHiddenFields,
delay: z.number().optional(),
autoComplete: z.number().optional(),
closeOnDate: z.date().optional(),
surveyClosedMessage: ZSurveyClosedMessage.optional(),
verifyEmail: ZSurveyVerifyEmail.optional(),
triggers: z.array(z.string()).optional(),
inlineTriggers: ZSurveyInlineTriggers.optional(),
})
.refine(
(survey) => {
// if the triggers array has a single empty string, it means the survey has no triggers
if (survey.triggers?.[0] === "") {
return true;
}
const hasTriggers = !!survey.triggers?.length;
const hasInlineTriggers = !!survey.inlineTriggers?.codeConfig || !!survey.inlineTriggers?.noCodeConfig;
// Survey cannot have both triggers and inlineTriggers
if (hasTriggers && hasInlineTriggers) {
return false;
}
return true;
},
{ message: "Survey cannot have both triggers and inlineTriggers" }
);
export type TSurvey = z.infer<typeof ZSurvey>;
export type TSurveyDates = {
@@ -1,7 +1,7 @@
import { UseFormRegister } from "react-hook-form";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Input } from "@formbricks/ui/Input";
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
import { Input } from "../../Input";
interface CssSelectorProps {
isCssSelector: boolean;
@@ -1,7 +1,7 @@
import { UseFormRegister } from "react-hook-form";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Input } from "@formbricks/ui/Input";
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
import { Input } from "../../Input";
interface InnerHtmlSelectorProps {
isInnerHtml: boolean;
@@ -2,10 +2,10 @@ import { Label } from "@radix-ui/react-dropdown-menu";
import clsx from "clsx";
import { Control, Controller, UseFormRegister } from "react-hook-form";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
import { Button } from "../../Button";
import { Input } from "../../Input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../Select";
interface PageUrlSelectorProps {
isPageUrl: boolean;
+5
View File
@@ -0,0 +1,5 @@
import { CssSelector } from "./components/CssSelector";
import { InnerHtmlSelector } from "./components/InnerHtmlSelector";
import { PageUrlSelector } from "./components/PageUrlSelector";
export { CssSelector, InnerHtmlSelector, PageUrlSelector };
+1
View File
@@ -148,6 +148,7 @@ export async function copyToOtherEnvironmentAction(
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
inlineTriggers: JSON.parse(JSON.stringify(existingSurvey.inlineTriggers)),
triggers: {
create: targetEnvironmentTriggers.map((actionClassId) => ({
actionClassId: actionClassId,
+56 -18
View File
@@ -7,28 +7,66 @@ interface TabBarProps {
activeId: string;
setActiveId: (id: string) => void;
className?: string;
tabStyle?: "bar" | "button";
}
export const TabBar: React.FC<TabBarProps> = ({ tabs, activeId, setActiveId, className = "" }) => {
export const TabBar: React.FC<TabBarProps> = ({
tabs,
activeId,
setActiveId,
className = "",
tabStyle = "bar",
}) => {
const Nav = () => {
if (tabStyle === "bar") {
return (
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
tab.id === activeId
? " border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"flex h-full items-center px-3 text-sm font-medium"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
{tab.icon && <div className="flex h-5 w-5 items-center">{tab.icon}</div>}
{tab.label}
</button>
))}
</nav>
);
}
if (tabStyle === "button") {
return (
<nav className="flex h-full w-full flex-1 items-center space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
<div className="flex h-full flex-1 justify-center px-3 py-2">
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
tab.id === activeId
? "bg-white font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"h-full w-full items-center rounded-lg text-center text-sm font-medium"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
{tab.label}
</button>
</div>
))}
</nav>
);
}
};
return (
<div className={cn("flex h-14 w-full items-center justify-center border-b bg-slate-50", className)}>
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
tab.id === activeId
? " border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"flex h-full items-center px-3 text-sm font-medium"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
{tab.icon && <div className="flex h-5 w-5 items-center">{tab.icon}</div>}
{tab.label}
</button>
))}
</nav>
<Nav />
</div>
);
};