mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-13 01:59:39 -06:00
feat: Refactor Triggers and combine Action Classes and Inline Triggers (#2562)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com> Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com> Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
@@ -115,7 +115,7 @@ export default function AppPage({}) {
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
@@ -136,26 +136,6 @@ export default function AppPage({}) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
formbricks.track("Code Action");
|
||||
}}>
|
||||
Code Action
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends a{" "}
|
||||
<a href="https://formbricks.com/docs/actions/code" className="underline" target="_blank">
|
||||
Code Action
|
||||
</a>{" "}
|
||||
to the Formbricks API called 'Code Action'. You will find it in the Actions Tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function AppPage({}) {
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
@@ -135,60 +135,6 @@ export default function AppPage({}) {
|
||||
try again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
formbricks.track("New Session");
|
||||
}}>
|
||||
Track New Session
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends an Action to the Formbricks API called 'New Session'. You will
|
||||
find it in the Actions Tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
formbricks.track("Exit Intent");
|
||||
}}>
|
||||
Track Exit Intent
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends an Action to the Formbricks API called 'Exit Intent'. You can also
|
||||
move your mouse to the top of the browser to trigger the exit intent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
formbricks.track("50% Scroll");
|
||||
}}>
|
||||
Track 50% Scroll
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends an Action to the Formbricks API called '50% Scroll'. You can also
|
||||
scroll down to trigger the 50% scroll.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function Loading() {
|
||||
name: "Loading User Acitivity",
|
||||
description: null,
|
||||
type: "automatic",
|
||||
key: "",
|
||||
noCodeConfig: null,
|
||||
environmentId: "testEnvironment",
|
||||
},
|
||||
@@ -39,6 +40,7 @@ export default function Loading() {
|
||||
updatedAt: new Date(),
|
||||
name: "Loading User Acitivity",
|
||||
description: null,
|
||||
key: "",
|
||||
type: "automatic",
|
||||
noCodeConfig: null,
|
||||
environmentId: "testEnvironment",
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function ActionClassesTable({
|
||||
id: "",
|
||||
name: "",
|
||||
type: "noCode",
|
||||
key: "",
|
||||
description: "",
|
||||
noCodeConfig: null,
|
||||
createdAt: new Date(),
|
||||
@@ -85,6 +86,7 @@ export default function ActionClassesTable({
|
||||
environmentId={environmentId}
|
||||
open={isActionDetailModalOpen}
|
||||
setOpen={setActionDetailModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
actionClass={activeActionClass}
|
||||
membershipRole={membershipRole}
|
||||
isUserTargetingEnabled={isUserTargetingEnabled}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ActionDetailModalProps {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
actionClass: TActionClass;
|
||||
actionClasses: TActionClass[];
|
||||
membershipRole?: TMembershipRole;
|
||||
isUserTargetingEnabled: boolean;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ export default function ActionDetailModal({
|
||||
open,
|
||||
setOpen,
|
||||
actionClass,
|
||||
actionClasses,
|
||||
membershipRole,
|
||||
isUserTargetingEnabled,
|
||||
}: ActionDetailModalProps) {
|
||||
@@ -41,6 +43,7 @@ export default function ActionDetailModal({
|
||||
<ActionSettingsTab
|
||||
environmentId={environmentId}
|
||||
actionClass={actionClass}
|
||||
actionClasses={actionClasses}
|
||||
setOpen={setOpen}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
|
||||
@@ -4,14 +4,21 @@ import {
|
||||
deleteActionClassAction,
|
||||
updateActionClassAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TActionClassInput, TActionClassNoCodeConfig, TNoCodeConfig } from "@formbricks/types/actionClasses";
|
||||
import { testURLmatch } from "@formbricks/lib/utils/testUrlMatch";
|
||||
import {
|
||||
TActionClass,
|
||||
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";
|
||||
@@ -19,11 +26,10 @@ import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { testURLmatch } from "../lib/testURLmatch";
|
||||
|
||||
interface ActionSettingsTabProps {
|
||||
environmentId: string;
|
||||
actionClass: any;
|
||||
actionClass: TActionClass;
|
||||
actionClasses: TActionClass[];
|
||||
setOpen: (v: boolean) => void;
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
@@ -31,6 +37,7 @@ interface ActionSettingsTabProps {
|
||||
export default function ActionSettingsTab({
|
||||
environmentId,
|
||||
actionClass,
|
||||
actionClasses,
|
||||
setOpen,
|
||||
membershipRole,
|
||||
}: ActionSettingsTabProps) {
|
||||
@@ -44,11 +51,17 @@ export default function ActionSettingsTab({
|
||||
const [isUpdatingAction, setIsUpdatingAction] = useState(false);
|
||||
const [isDeletingAction, setIsDeletingAction] = useState(false);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
const actionClassNames = useMemo(
|
||||
() =>
|
||||
actionClasses.filter((action) => action.id !== actionClass.id).map((actionClass) => actionClass.name),
|
||||
[actionClass.id, actionClasses]
|
||||
);
|
||||
|
||||
const { register, handleSubmit, control, watch } = useForm({
|
||||
const { register, handleSubmit, control, watch } = useForm<TActionClass>({
|
||||
defaultValues: {
|
||||
name: actionClass.name,
|
||||
description: actionClass.description,
|
||||
key: actionClass.key,
|
||||
noCodeConfig: actionClass.noCodeConfig,
|
||||
},
|
||||
});
|
||||
@@ -73,8 +86,8 @@ export default function ActionSettingsTab({
|
||||
const handleMatchClick = () => {
|
||||
const match = testURLmatch(
|
||||
testUrl,
|
||||
watch("noCodeConfig.[pageUrl].value"),
|
||||
watch("noCodeConfig.[pageUrl].rule")
|
||||
watch("noCodeConfig.pageUrl.value"),
|
||||
watch("noCodeConfig.pageUrl.rule")
|
||||
);
|
||||
setIsMatch(match);
|
||||
if (match === "yes") toast.success("Your survey would be shown on this URL.");
|
||||
@@ -83,21 +96,38 @@ export default function ActionSettingsTab({
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
try {
|
||||
const isCodeAction = actionClass.type === "code";
|
||||
if (isViewer) {
|
||||
throw new Error("You are not authorised to perform this action.");
|
||||
}
|
||||
setIsUpdatingAction(true);
|
||||
if (data.name === "") throw new Error("Please give your action a name");
|
||||
if (!isPageUrl && !isCssSelector && !isInnerHtml && !isCodeAction)
|
||||
throw new Error("Please select at least one selector");
|
||||
if (!data.name || data.name?.trim() === "") {
|
||||
throw new Error("Please give your action a name");
|
||||
}
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
throw new Error(`Action with name ${data.name} already exist`);
|
||||
}
|
||||
|
||||
if (actionClass.type === "noCode") {
|
||||
if (!isPageUrl && !isCssSelector && !isInnerHtml)
|
||||
throw new Error("Please select at least one selector");
|
||||
|
||||
if (isCssSelector && !isValidCssSelector(actionClass.noCodeConfig?.cssSelector?.value))
|
||||
throw new Error("Please enter a valid CSS Selector");
|
||||
|
||||
if (isPageUrl && actionClass.noCodeConfig?.pageUrl?.rule === undefined)
|
||||
throw new Error("Please select a rule for page URL");
|
||||
}
|
||||
|
||||
let filteredNoCodeConfig = data.noCodeConfig;
|
||||
const isCodeAction = actionClass.type === "code";
|
||||
if (!isCodeAction) {
|
||||
filteredNoCodeConfig = filterNoCodeConfig(data.noCodeConfig as TNoCodeConfig);
|
||||
}
|
||||
const updatedData: TActionClassInput = {
|
||||
...data,
|
||||
environmentId,
|
||||
noCodeConfig: filteredNoCodeConfig,
|
||||
type: isCodeAction ? "code" : "noCode",
|
||||
} as TActionClassInput;
|
||||
...(isCodeAction ? {} : { noCodeConfig: filteredNoCodeConfig }),
|
||||
name: data.name.trim(),
|
||||
};
|
||||
await updateActionClassAction(environmentId, actionClass.id, updatedData);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
@@ -128,13 +158,14 @@ export default function ActionSettingsTab({
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="actionNameSettingsInput">What did your user do?</Label>
|
||||
<Label htmlFor="actionNameSettingsInput">
|
||||
{actionClass.type === "noCode" ? "What did your user do?" : "Display name"}
|
||||
</Label>
|
||||
<Input
|
||||
id="actionNameSettingsInput"
|
||||
placeholder="E.g. Clicked Download"
|
||||
{...register("name", {
|
||||
value: actionClass.name,
|
||||
disabled: actionClass.type === "automatic" || actionClass.type === "code" ? true : false,
|
||||
disabled: actionClass.type === "automatic" ? true : false,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@@ -145,12 +176,24 @@ export default function ActionSettingsTab({
|
||||
id="actionDescriptionSettingsInput"
|
||||
placeholder="User clicked Download Button "
|
||||
{...register("description", {
|
||||
value: actionClass.description,
|
||||
disabled: actionClass.type === "automatic" ? true : false,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionClass.type === "code" && (
|
||||
<div className="col-span-1 mt-4">
|
||||
<Label htmlFor="actionKeySettingsInput">Key</Label>
|
||||
<Input
|
||||
id="actionKeySettingsInput"
|
||||
placeholder="E.g. download_button_clicked"
|
||||
{...register("key")}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{actionClass.type === "code" ? (
|
||||
<p className="text-sm text-slate-600">
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { CreateNewActionTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab";
|
||||
import { MousePointerClickIcon } from "lucide-react";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
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";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { Modal } from "@formbricks/ui/Modal";
|
||||
import { TabBar } from "@formbricks/ui/TabBar";
|
||||
|
||||
import { testURLmatch } from "../lib/testURLmatch";
|
||||
|
||||
interface AddNoCodeActionModalProps {
|
||||
environmentId: string;
|
||||
@@ -27,19 +15,6 @@ interface AddNoCodeActionModalProps {
|
||||
isViewer: boolean;
|
||||
}
|
||||
|
||||
function isValidCssSelector(selector?: string) {
|
||||
if (!selector || selector.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const element = document.createElement("div");
|
||||
try {
|
||||
element.querySelector(selector);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function AddNoCodeActionModal({
|
||||
environmentId,
|
||||
open,
|
||||
@@ -48,108 +23,8 @@ export default function AddNoCodeActionModal({
|
||||
setActionClasses,
|
||||
isViewer,
|
||||
}: AddNoCodeActionModalProps) {
|
||||
const { register, control, handleSubmit, watch, reset } = useForm();
|
||||
const [isPageUrl, setIsPageUrl] = useState(false);
|
||||
const [isCssSelector, setIsCssSelector] = useState(false);
|
||||
const [isInnerHtml, setIsInnerText] = useState(false);
|
||||
const [isCreatingAction, setIsCreatingAction] = useState(false);
|
||||
const [testUrl, setTestUrl] = useState("");
|
||||
const [isMatch, setIsMatch] = useState("");
|
||||
const [type, setType] = useState("noCode");
|
||||
const actionClassNames = actionClasses.map((actionClass) => actionClass.name);
|
||||
|
||||
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
|
||||
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
|
||||
const filteredNoCodeConfig: TActionClassNoCodeConfig = {};
|
||||
|
||||
if (isPageUrl && pageUrl?.rule && pageUrl?.value) {
|
||||
filteredNoCodeConfig.pageUrl = { rule: pageUrl.rule, value: pageUrl.value };
|
||||
}
|
||||
if (isInnerHtml && innerHtml?.value) {
|
||||
filteredNoCodeConfig.innerHtml = { value: innerHtml.value };
|
||||
}
|
||||
if (isCssSelector && cssSelector?.value) {
|
||||
filteredNoCodeConfig.cssSelector = { value: cssSelector.value };
|
||||
}
|
||||
|
||||
return filteredNoCodeConfig;
|
||||
};
|
||||
|
||||
const handleMatchClick = () => {
|
||||
const match = testURLmatch(
|
||||
testUrl,
|
||||
watch("noCodeConfig.[pageUrl].value"),
|
||||
watch("noCodeConfig.[pageUrl].rule")
|
||||
);
|
||||
setIsMatch(match);
|
||||
if (match === "yes") toast.success("Your survey would be shown on this URL.");
|
||||
if (match === "no") toast.error("Your survey would not be shown.");
|
||||
};
|
||||
|
||||
const submitEventClass = async (data: Partial<TActionClassInput>): Promise<void> => {
|
||||
const { noCodeConfig } = data;
|
||||
try {
|
||||
if (isViewer) {
|
||||
throw new Error("You are not authorised to perform this action.");
|
||||
}
|
||||
setIsCreatingAction(true);
|
||||
if (!data.name || data.name?.trim() === "") {
|
||||
throw new Error("Please give your action a name");
|
||||
}
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
throw new Error(`Action with name ${data.name} already exist`);
|
||||
}
|
||||
if (type === "noCode") {
|
||||
if (!isPageUrl && !isCssSelector && !isInnerHtml)
|
||||
throw new Error("Please select at least one selector");
|
||||
|
||||
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value)) {
|
||||
throw new Error("Please enter a valid CSS Selector");
|
||||
}
|
||||
|
||||
if (isPageUrl && noCodeConfig?.pageUrl?.rule === undefined) {
|
||||
throw new Error("Please select a rule for page URL");
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAction: TActionClassInput = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
environmentId,
|
||||
type,
|
||||
} as TActionClassInput;
|
||||
|
||||
if (type === "noCode") {
|
||||
const filteredNoCodeConfig = filterNoCodeConfig(noCodeConfig as TActionClassNoCodeConfig);
|
||||
updatedAction.noCodeConfig = filteredNoCodeConfig;
|
||||
}
|
||||
|
||||
const newActionClass: TActionClass = await createActionClassAction(updatedAction);
|
||||
if (setActionClasses) {
|
||||
setActionClasses((prevActionClasses: TActionClass[]) => [...prevActionClasses, newActionClass]);
|
||||
}
|
||||
reset();
|
||||
resetAllStates(false);
|
||||
toast.success("Action added successfully.");
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setIsCreatingAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAllStates = (open: boolean) => {
|
||||
setIsCssSelector(false);
|
||||
setIsPageUrl(false);
|
||||
setIsInnerText(false);
|
||||
setTestUrl("");
|
||||
setIsMatch("");
|
||||
reset();
|
||||
setOpen(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={() => resetAllStates(false)} noPadding closeOnOutsideClick={false}>
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
@@ -166,121 +41,15 @@ export default function AddNoCodeActionModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TabBar
|
||||
tabs={[
|
||||
{ id: "noCode", label: "No Code" },
|
||||
{ id: "code", label: "Code" },
|
||||
]}
|
||||
activeId={type}
|
||||
setActiveId={setType}
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<CreateNewActionTab
|
||||
actionClasses={actionClasses}
|
||||
environmentId={environmentId}
|
||||
isViewer={isViewer}
|
||||
setActionClasses={setActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
{type === "noCode" ? (
|
||||
<form onSubmit={handleSubmit(submitEventClass)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="actionNameInput">What did your user do?</Label>
|
||||
<Input id="actionNameInput" placeholder="E.g. Clicked Download" {...register("name")} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="actionDescriptionInput">Description</Label>
|
||||
<Input
|
||||
id="actionDescriptionInput"
|
||||
placeholder="User clicked Download Button "
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Select By</Label>
|
||||
</div>
|
||||
<CssSelector
|
||||
isCssSelector={isCssSelector}
|
||||
setIsCssSelector={setIsCssSelector}
|
||||
register={register}
|
||||
/>
|
||||
<PageUrlSelector
|
||||
isPageUrl={isPageUrl}
|
||||
setIsPageUrl={setIsPageUrl}
|
||||
register={register}
|
||||
control={control}
|
||||
testUrl={testUrl}
|
||||
setTestUrl={setTestUrl}
|
||||
isMatch={isMatch}
|
||||
setIsMatch={setIsMatch}
|
||||
handleMatchClick={handleMatchClick}
|
||||
/>
|
||||
<InnerHtmlSelector
|
||||
isInnerHtml={isInnerHtml}
|
||||
setIsInnerHtml={setIsInnerText}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
|
||||
Create Action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(submitEventClass)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="codeActionNameInput">Identifier</Label>
|
||||
<Input
|
||||
id="codeActionNameInput"
|
||||
placeholder="E.g. clicked-download"
|
||||
{...register("name", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="codeActionDescriptionInput">Description</Label>
|
||||
<Input
|
||||
id="codeActionDescriptionInput"
|
||||
placeholder="User clicked Download Button"
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<Alert>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<AlertTitle>How do Code Actions work?</AlertTitle>
|
||||
<AlertDescription>
|
||||
You can track code action anywhere in your app using{" "}
|
||||
<span className="rounded bg-slate-100 px-2 py-1 text-xs">
|
||||
formbricks.track("{watch("name")}")
|
||||
</span>{" "}
|
||||
in your code. Read more in our{" "}
|
||||
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
|
||||
docs
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
|
||||
Create Action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { createActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
loadNewSegmentInSurvey,
|
||||
updateSurvey,
|
||||
} from "@formbricks/lib/survey/service";
|
||||
import { TActionClassInput } from "@formbricks/types/actionClasses";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TBaseFilters, TSegmentUpdateInput, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
@@ -248,3 +250,13 @@ export async function triggerDownloadUnsplashImageAction(downloadUrl: string) {
|
||||
throw new Error("Error downloading image from Unsplash");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createActionClassAction(action: TActionClassInput) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, action.environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await createActionClass(action.environmentId, action);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { CreateNewActionTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab";
|
||||
import { SavedActionsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SavedActionsTab";
|
||||
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { ModalWithTabs } from "@formbricks/ui/ModalWithTabs";
|
||||
|
||||
interface AddActionModalProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
setActionClasses: React.Dispatch<React.SetStateAction<TActionClass[]>>;
|
||||
isViewer: boolean;
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
}
|
||||
|
||||
export const AddActionModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
actionClasses,
|
||||
setActionClasses,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
isViewer,
|
||||
environmentId,
|
||||
}: AddActionModalProps) => {
|
||||
const tabs = [
|
||||
{
|
||||
title: "Select saved action",
|
||||
children: (
|
||||
<SavedActionsTab
|
||||
actionClasses={actionClasses}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Capture new action",
|
||||
children: (
|
||||
<CreateNewActionTab
|
||||
actionClasses={actionClasses}
|
||||
setActionClasses={setActionClasses}
|
||||
setOpen={setOpen}
|
||||
isViewer={isViewer}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ModalWithTabs
|
||||
label="Add action"
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
size="md"
|
||||
closeOnOutsideClick={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { MatchType, testURLmatch } from "@formbricks/lib/utils/testUrlMatch";
|
||||
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
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";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { TabBar } from "@formbricks/ui/TabBar";
|
||||
|
||||
import { createActionClassAction } from "../actions";
|
||||
|
||||
interface CreateNewActionTabProps {
|
||||
actionClasses: TActionClass[];
|
||||
setActionClasses: React.Dispatch<React.SetStateAction<TActionClass[]>>;
|
||||
isViewer: boolean;
|
||||
setLocalSurvey?: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const CreateNewActionTab = ({
|
||||
actionClasses,
|
||||
setActionClasses,
|
||||
setOpen,
|
||||
isViewer,
|
||||
setLocalSurvey,
|
||||
environmentId,
|
||||
}: CreateNewActionTabProps) => {
|
||||
const { register, control, handleSubmit, watch, reset } = useForm<TActionClass>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
type: "noCode",
|
||||
key: "",
|
||||
noCodeConfig: {
|
||||
pageUrl: {
|
||||
rule: "contains",
|
||||
value: "",
|
||||
},
|
||||
cssSelector: {
|
||||
value: "",
|
||||
},
|
||||
innerHtml: {
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [type, setType] = useState("noCode");
|
||||
|
||||
const [isPageUrl, setIsPageUrl] = useState(false);
|
||||
const [isCssSelector, setIsCssSelector] = useState(false);
|
||||
const [isInnerHtml, setIsInnerText] = useState(false);
|
||||
const [isCreatingAction, setIsCreatingAction] = useState(false);
|
||||
const [testUrl, setTestUrl] = useState("");
|
||||
const [isMatch, setIsMatch] = useState("");
|
||||
const actionClassNames = useMemo(
|
||||
() => actionClasses.map((actionClass) => actionClass.name),
|
||||
[actionClasses]
|
||||
);
|
||||
|
||||
const actionClassKeys = useMemo(
|
||||
() =>
|
||||
actionClasses
|
||||
.filter((actionClass) => actionClass.type === "code")
|
||||
.map((actionClass) => actionClass.key),
|
||||
[actionClasses]
|
||||
);
|
||||
|
||||
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
|
||||
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
|
||||
const filteredNoCodeConfig: TActionClassNoCodeConfig = {};
|
||||
|
||||
if (isPageUrl && pageUrl?.rule && pageUrl?.value) {
|
||||
filteredNoCodeConfig.pageUrl = { rule: pageUrl.rule, value: pageUrl.value };
|
||||
}
|
||||
if (isInnerHtml && innerHtml?.value) {
|
||||
filteredNoCodeConfig.innerHtml = { value: innerHtml.value };
|
||||
}
|
||||
if (isCssSelector && cssSelector?.value) {
|
||||
filteredNoCodeConfig.cssSelector = { value: cssSelector.value };
|
||||
}
|
||||
|
||||
return filteredNoCodeConfig;
|
||||
};
|
||||
|
||||
const handleMatchClick = () => {
|
||||
const match = testURLmatch(
|
||||
testUrl,
|
||||
watch("noCodeConfig.pageUrl.value"),
|
||||
watch("noCodeConfig.pageUrl.rule") as MatchType
|
||||
);
|
||||
|
||||
setIsMatch(match);
|
||||
if (match === "yes") toast.success("Your survey would be shown on this URL.");
|
||||
if (match === "no") toast.error("Your survey would not be shown.");
|
||||
};
|
||||
|
||||
const submitHandler = async (data: Partial<TActionClass>) => {
|
||||
const { noCodeConfig } = data;
|
||||
try {
|
||||
if (isViewer) {
|
||||
throw new Error("You are not authorised to perform this action.");
|
||||
}
|
||||
setIsCreatingAction(true);
|
||||
|
||||
if (!data.name || data.name?.trim() === "") {
|
||||
throw new Error("Please give your action a name");
|
||||
}
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
throw new Error(`Action with name ${data.name} already exist`);
|
||||
}
|
||||
if (type === "noCode") {
|
||||
if (!isPageUrl && !isCssSelector && !isInnerHtml)
|
||||
throw new Error("Please select at least one selector");
|
||||
|
||||
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value))
|
||||
throw new Error("Please enter a valid CSS Selector");
|
||||
|
||||
if (isPageUrl && noCodeConfig?.pageUrl?.rule === undefined)
|
||||
throw new Error("Please select a rule for page URL");
|
||||
}
|
||||
if (type === "code" && !data.key) {
|
||||
throw new Error("Please enter a code key");
|
||||
}
|
||||
if (data.key && actionClassKeys.includes(data.key)) {
|
||||
throw new Error(`Action with key ${data.key} already exist`);
|
||||
}
|
||||
|
||||
const updatedAction: TActionClassInput = {
|
||||
name: data.name.trim(),
|
||||
description: data.description,
|
||||
environmentId,
|
||||
type: type as TActionClass["type"],
|
||||
};
|
||||
|
||||
if (type === "noCode") {
|
||||
const filteredNoCodeConfig = filterNoCodeConfig(noCodeConfig as TActionClassNoCodeConfig);
|
||||
updatedAction.noCodeConfig = filteredNoCodeConfig;
|
||||
} else {
|
||||
updatedAction.key = data.key;
|
||||
}
|
||||
|
||||
const newActionClass: TActionClass = await createActionClassAction(updatedAction);
|
||||
if (setActionClasses) {
|
||||
setActionClasses((prevActionClasses: TActionClass[]) => [...prevActionClasses, newActionClass]);
|
||||
}
|
||||
|
||||
if (setLocalSurvey) {
|
||||
setLocalSurvey((prev) => ({
|
||||
...prev,
|
||||
triggers: prev.triggers.concat({ actionClass: newActionClass }),
|
||||
}));
|
||||
}
|
||||
|
||||
reset();
|
||||
resetAllStates();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setIsCreatingAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAllStates = () => {
|
||||
setType("noCode");
|
||||
setIsCssSelector(false);
|
||||
setIsPageUrl(false);
|
||||
setIsInnerText(false);
|
||||
setTestUrl("");
|
||||
setIsMatch("");
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="actionNameInput">What did your user do?</Label>
|
||||
<Input id="actionNameInput" placeholder="E.g. Clicked Download" {...register("name")} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="actionDescriptionInput">Description</Label>
|
||||
<Input
|
||||
id="actionDescriptionInput"
|
||||
placeholder="User clicked Download Button "
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Label>Type</Label>
|
||||
<div className="w-3/5">
|
||||
<TabBar
|
||||
tabs={[
|
||||
{
|
||||
id: "noCode",
|
||||
label: "No code",
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
label: "Code",
|
||||
},
|
||||
]}
|
||||
activeId={type}
|
||||
setActiveId={setType}
|
||||
tabStyle="button"
|
||||
className="rounded-md bg-white"
|
||||
activeTabClassName="bg-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{type === "code" ? (
|
||||
<>
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="codeActionKeyInput">Key</Label>
|
||||
<Input
|
||||
id="codeActionKeyInput"
|
||||
placeholder="e.g. download_cta_click_on_home"
|
||||
{...register("key")}
|
||||
className="mb-2 w-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Alert className="bg-slate-100">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<AlertTitle>How do Code Actions work?</AlertTitle>
|
||||
<AlertDescription>
|
||||
You can track code action anywhere in your app using{" "}
|
||||
<span className="rounded bg-white px-2 py-1 text-xs">
|
||||
formbricks.track("{watch("key")}")
|
||||
</span>{" "}
|
||||
in your code. Read more in our{" "}
|
||||
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
|
||||
docs
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Label>Select By</Label>
|
||||
</div>
|
||||
<CssSelector
|
||||
isCssSelector={isCssSelector}
|
||||
setIsCssSelector={setIsCssSelector}
|
||||
register={register}
|
||||
/>
|
||||
<PageUrlSelector
|
||||
isPageUrl={isPageUrl}
|
||||
setIsPageUrl={setIsPageUrl}
|
||||
register={register}
|
||||
control={control}
|
||||
testUrl={testUrl}
|
||||
setTestUrl={setTestUrl}
|
||||
isMatch={isMatch}
|
||||
setIsMatch={setIsMatch}
|
||||
handleMatchClick={handleMatchClick}
|
||||
/>
|
||||
<InnerHtmlSelector
|
||||
isInnerHtml={isInnerHtml}
|
||||
setIsInnerHtml={setIsInnerText}
|
||||
register={register}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="minimal" onClick={resetAllStates}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
|
||||
Create action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,400 +0,0 @@
|
||||
import { MatchType } from "@/app/(app)/environments/[environmentId]/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;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
interface SavedActionsTabProps {
|
||||
actionClasses: TActionClass[];
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const SavedActionsTab = ({
|
||||
actionClasses,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
setOpen,
|
||||
}: SavedActionsTabProps) => {
|
||||
const availableActions = actionClasses.filter(
|
||||
(actionClass) => !localSurvey.triggers.some((trigger) => trigger.actionClass.id === actionClass.id)
|
||||
);
|
||||
const [filteredActionClasses, setFilteredActionClasses] = useState<TActionClass[]>(availableActions);
|
||||
|
||||
const codeActions = filteredActionClasses.filter((actionClass) => actionClass.type === "code");
|
||||
const noCodeActions = filteredActionClasses.filter((actionClass) => actionClass.type === "noCode");
|
||||
const automaticActions = filteredActionClasses.filter((actionClass) => actionClass.type === "automatic");
|
||||
|
||||
const handleActionClick = (action: TActionClass) => {
|
||||
setLocalSurvey((prev) => ({
|
||||
...prev,
|
||||
triggers: prev.triggers.concat({ actionClass: action }),
|
||||
}));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
setFilteredActionClasses(
|
||||
availableActions.filter((actionClass) =>
|
||||
actionClass.name.toLowerCase().includes(e.target.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="mb-2 bg-white"
|
||||
placeholder="Search actions"
|
||||
id="search-actions"
|
||||
/>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{[automaticActions, noCodeActions, codeActions].map(
|
||||
(actions, i) =>
|
||||
actions.length > 0 && (
|
||||
<div key={i} className="me-4">
|
||||
<h2 className="mb-2 mt-4 font-semibold">
|
||||
{i === 0 ? "Automatic" : i === 1 ? "No code" : "Code"}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
className="cursor-pointer rounded-md border border-slate-300 bg-white px-4 py-2 hover:bg-slate-100"
|
||||
onClick={() => handleActionClick(action)}>
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
||||
{action.type === "code" ? (
|
||||
<Code2Icon className="h-4 w-4" />
|
||||
) : action.type === "noCode" ? (
|
||||
<MousePointerClickIcon className="h-4 w-4" />
|
||||
) : action.type === "automatic" ? (
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-semibold text-slate-600">{action.name}</h4>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">{action.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -86,13 +86,8 @@ export const SurveyMenuBar = ({
|
||||
if (localSurvey.type === "link") 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;
|
||||
}
|
||||
if (noTriggers) return true;
|
||||
|
||||
return false;
|
||||
}, [localSurvey]);
|
||||
@@ -113,7 +108,6 @@ export const SurveyMenuBar = ({
|
||||
const handleBack = () => {
|
||||
const { updatedAt, ...localSurveyRest } = localSurvey;
|
||||
const { updatedAt: _, ...surveyRest } = survey;
|
||||
localSurveyRest.triggers = localSurveyRest.triggers.filter((trigger) => Boolean(trigger));
|
||||
|
||||
if (!isEqual(localSurveyRest, surveyRest)) {
|
||||
setConfirmDialogOpen(true);
|
||||
@@ -163,7 +157,7 @@ export const SurveyMenuBar = ({
|
||||
setIsSurveySaving(false);
|
||||
return;
|
||||
}
|
||||
localSurvey.triggers = localSurvey.triggers.filter((trigger) => Boolean(trigger));
|
||||
|
||||
localSurvey.questions = localSurvey.questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
|
||||
import InlineTriggers from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/InlineTriggers";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CheckIcon,
|
||||
Code2Icon,
|
||||
MousePointerClickIcon,
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
@@ -13,15 +18,8 @@ import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@formbricks/ui/Select";
|
||||
import { TabBar } from "@formbricks/ui/TabBar";
|
||||
|
||||
import { AddActionModal } from "./AddActionModal";
|
||||
|
||||
interface WhenToSendCardProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -41,52 +39,16 @@ export default function WhenToSendCard({
|
||||
const [open, setOpen] = useState(
|
||||
localSurvey.type === "app" || localSurvey.type === "website" ? true : false
|
||||
);
|
||||
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [isAddActionModalOpen, setAddActionModalOpen] = useState(false);
|
||||
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;
|
||||
const delay = localSurvey.delay !== 0;
|
||||
|
||||
const addTriggerEvent = useCallback(() => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
updatedSurvey.triggers = [...localSurvey.triggers, ""];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
}, [localSurvey, setLocalSurvey]);
|
||||
|
||||
const setTriggerEvent = useCallback(
|
||||
(idx: number, actionClassName: string) => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
const newActionClass = actionClasses!.find((actionClass) => {
|
||||
return actionClass.name === actionClassName;
|
||||
});
|
||||
if (!newActionClass) {
|
||||
throw new Error("Action class not found");
|
||||
}
|
||||
updatedSurvey.triggers[idx] = newActionClass.name;
|
||||
setLocalSurvey(updatedSurvey);
|
||||
},
|
||||
[actionClasses, localSurvey, setLocalSurvey]
|
||||
);
|
||||
|
||||
const removeTriggerEvent = (idx: number) => {
|
||||
const handleRemoveTriggerEvent = (idx: number) => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
updatedSurvey.triggers = [...localSurvey.triggers.slice(0, idx), ...localSurvey.triggers.slice(idx + 1)];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
@@ -143,60 +105,16 @@ export default function WhenToSendCard({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddEventModalOpen) return;
|
||||
|
||||
if (activeIndex !== null) {
|
||||
const newActionClass = actionClasses[actionClasses.length - 1].name;
|
||||
const currentActionClass = localSurvey.triggers[activeIndex];
|
||||
|
||||
if (newActionClass !== currentActionClass) {
|
||||
setTriggerEvent(activeIndex, newActionClass);
|
||||
}
|
||||
|
||||
setActiveIndex(null);
|
||||
}
|
||||
}, [actionClasses, activeIndex, setTriggerEvent, isAddEventModalOpen, localSurvey.triggers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localSurvey.type === "link") {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [localSurvey.type]);
|
||||
|
||||
//create new empty trigger on page load, remove one click for user
|
||||
useEffect(() => {
|
||||
if (localSurvey.triggers.length === 0) {
|
||||
addTriggerEvent();
|
||||
}
|
||||
}, [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;
|
||||
return !localSurvey.triggers || !localSurvey.triggers.length || !localSurvey.triggers[0];
|
||||
}, [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
|
||||
}
|
||||
@@ -237,77 +155,85 @@ export default function WhenToSendCard({
|
||||
<hr className="py-1 text-slate-600" />
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{!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="filter-scrollbar flex flex-col gap-4 overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-4">
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
Trigger survey when one of the actions is fired...
|
||||
</p>
|
||||
|
||||
{localSurvey.triggers.filter(Boolean).map((trigger, idx) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2" key={trigger.actionClass.id}>
|
||||
{idx !== 0 && <p className="ml-1 text-sm font-bold text-slate-700">or</p>}
|
||||
<div
|
||||
key={trigger.actionClass.id}
|
||||
className="flex grow items-center justify-between rounded-md border border-slate-300 bg-white p-2 px-3">
|
||||
<div>
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
||||
{trigger.actionClass.type === "code" ? (
|
||||
<Code2Icon className="h-4 w-4" />
|
||||
) : trigger.actionClass.type === "noCode" ? (
|
||||
<MousePointerClickIcon className="h-4 w-4" />
|
||||
) : trigger.actionClass.type === "automatic" ? (
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-semibold text-slate-600">{trigger.actionClass.name}</h4>
|
||||
</div>
|
||||
))}
|
||||
<div className="px-6 py-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
addTriggerEvent();
|
||||
}}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add condition
|
||||
</Button>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{trigger.actionClass.description && (
|
||||
<span className="mr-1">{trigger.actionClass.description}</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "code" && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
Key: <b>{trigger.actionClass.key}</b>
|
||||
</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
trigger.actionClass.noCodeConfig?.cssSelector && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
CSS Selector: <b>{trigger.actionClass.noCodeConfig.cssSelector.value}</b>
|
||||
</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
trigger.actionClass.noCodeConfig?.innerHtml && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
Inner Text: <b>{trigger.actionClass.noCodeConfig.innerHtml.value}</b>
|
||||
</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
trigger.actionClass.noCodeConfig?.pageUrl && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
URL {trigger.actionClass.noCodeConfig.pageUrl.rule}:{" "}
|
||||
<b>{trigger.actionClass.noCodeConfig.pageUrl.value}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Trash2Icon
|
||||
className="h-4 w-4 cursor-pointer text-slate-600"
|
||||
onClick={() => handleRemoveTriggerEvent(idx)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddActionModalOpen(true);
|
||||
}}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Survey Display Settings */}
|
||||
<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>
|
||||
@@ -387,13 +313,15 @@ export default function WhenToSendCard({
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
<AddNoCodeActionModal
|
||||
<AddActionModal
|
||||
environmentId={environmentId}
|
||||
open={isAddEventModalOpen}
|
||||
setOpen={setAddEventModalOpen}
|
||||
open={isAddActionModalOpen}
|
||||
setOpen={setAddActionModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
setActionClasses={setActionClasses}
|
||||
isViewer={isViewer}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
TSurveyQuestions,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyWelcomeCard,
|
||||
ZSurveyInlineTriggers,
|
||||
surveyHasBothTriggers,
|
||||
} from "@formbricks/types/surveys";
|
||||
|
||||
// Utility function to check if label is valid for all required languages
|
||||
@@ -451,21 +449,6 @@ export const isSurveyValid = (
|
||||
}
|
||||
}
|
||||
|
||||
// if inlineTriggers are present validate with zod
|
||||
if (!!survey.inlineTriggers) {
|
||||
const parsedInlineTriggers = ZSurveyInlineTriggers.safeParse(survey.inlineTriggers);
|
||||
if (!parsedInlineTriggers.success) {
|
||||
toast.error("Invalid Custom Actions: Please check your custom actions");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// validate that both triggers and inlineTriggers are not present
|
||||
if (surveyHasBothTriggers(survey)) {
|
||||
toast.error("Survey cannot have both custom and saved actions, please remove one.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(survey, selectedLanguageCode);
|
||||
if (questionWithEmptyFallback) {
|
||||
toast.error("Fallback missing");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyDisplayOption,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyInput,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyQuestionType,
|
||||
TSurveyStatus,
|
||||
@@ -2613,7 +2615,6 @@ export const minimalSurvey: TSurvey = {
|
||||
recontactDays: null,
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [],
|
||||
inlineTriggers: null,
|
||||
thankYouCard: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -2636,7 +2637,7 @@ export const minimalSurvey: TSurvey = {
|
||||
languages: [],
|
||||
};
|
||||
|
||||
export const getExampleSurveyTemplate = (webAppUrl: string) => ({
|
||||
export const getExampleSurveyTemplate = (webAppUrl: string, trigger: TActionClass): TSurveyInput => ({
|
||||
...customSurvey.preset,
|
||||
questions: customSurvey.preset.questions.map(
|
||||
(question) =>
|
||||
@@ -2655,7 +2656,7 @@ export const getExampleSurveyTemplate = (webAppUrl: string) => ({
|
||||
name: "Example survey",
|
||||
type: "website" as TSurveyType,
|
||||
autoComplete: 2,
|
||||
triggers: ["New Session"],
|
||||
triggers: [{ actionClass: trigger }],
|
||||
status: "inProgress" as TSurveyStatus,
|
||||
displayOption: "respondMultiple" as TSurveyDisplayOption,
|
||||
recontactDays: 0,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { TJsAppState, TJsLegacyState } from "@formbricks/types/js";
|
||||
|
||||
export const transformLegacySurveys = (state: TJsAppState): TJsLegacyState => {
|
||||
const updatedState: any = { ...state };
|
||||
updatedState.surveys = updatedState.surveys.map((survey) => {
|
||||
const updatedSurvey = { ...survey };
|
||||
updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger }));
|
||||
return updatedSurvey;
|
||||
});
|
||||
return { ...updatedState, session: {} };
|
||||
};
|
||||
@@ -25,7 +25,7 @@ import { TSurvey } from "@formbricks/types/surveys";
|
||||
export const transformLegacySurveys = (surveys: TSurvey[]): TSurveyWithTriggers[] => {
|
||||
const updatedSurveys = surveys.map((survey) => {
|
||||
const updatedSurvey: any = { ...reverseTranslateSurvey(survey) };
|
||||
updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger }));
|
||||
updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger.actionClass.name }));
|
||||
return updatedSurvey;
|
||||
});
|
||||
return updatedSurveys;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
|
||||
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TJsAppStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TJsAppLegacyStateSync, TJsAppStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function GET(
|
||||
await sendFreeLimitReachedEventToPosthogBiWeekly(environmentId, "inAppSurvey");
|
||||
}
|
||||
|
||||
const [surveys, noCodeActionClasses, product] = await Promise.all([
|
||||
const [surveys, actionClasses, product] = await Promise.all([
|
||||
getSyncSurveys(environmentId, person.id, device.type === "mobile" ? "phone" : "desktop", {
|
||||
version: version ?? undefined,
|
||||
}),
|
||||
@@ -147,25 +147,6 @@ export async function GET(
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
|
||||
let transformedSurveys: TLegacySurvey[] | TSurvey[];
|
||||
|
||||
// Backwards compatibility for versions less than 1.7.0 (no multi-language support).
|
||||
if (version && isVersionGreaterThanOrEqualTo(version, "1.7.0")) {
|
||||
// Scenario 1: Multi language supported
|
||||
// Use the surveys as they are.
|
||||
transformedSurveys = surveys;
|
||||
} else {
|
||||
// Scenario 2: Multi language not supported
|
||||
// Convert to legacy surveys with default language.
|
||||
transformedSurveys = await Promise.all(
|
||||
surveys.map((survey) => {
|
||||
const languageCode = "default";
|
||||
return transformToLegacySurvey(survey, languageCode);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const updatedProduct: TProduct = {
|
||||
...product,
|
||||
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
@@ -175,16 +156,41 @@ export async function GET(
|
||||
};
|
||||
|
||||
const language = await getAttribute("language", person.id);
|
||||
const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode");
|
||||
|
||||
// return state
|
||||
const state: TJsAppStateSync = {
|
||||
...(version && !isVersionGreaterThanOrEqualTo(version, "2.0.0") && { person }),
|
||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||
// Use the surveys as they are.
|
||||
let transformedSurveys: TLegacySurvey[] | TSurvey[] = surveys;
|
||||
|
||||
// creating state object
|
||||
let state: TJsAppStateSync | TJsAppLegacyStateSync = {
|
||||
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
actionClasses,
|
||||
language,
|
||||
product: updatedProduct,
|
||||
};
|
||||
|
||||
// Backwards compatibility for versions less than 2.0.0 (no multi-language support and updated trigger action classes).
|
||||
if (!isVersionGreaterThanOrEqualTo(version ?? "", "2.0.0")) {
|
||||
// Scenario 2: Multi language and updated trigger action classes not supported
|
||||
// Convert to legacy surveys with default language
|
||||
// convert triggers to array of actionClasses Names
|
||||
transformedSurveys = await Promise.all(
|
||||
surveys.map((survey: TSurvey | TLegacySurvey) => {
|
||||
const languageCode = "default";
|
||||
return transformToLegacySurvey(survey as TSurvey, languageCode);
|
||||
})
|
||||
);
|
||||
|
||||
state = {
|
||||
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
|
||||
person,
|
||||
noCodeActionClasses,
|
||||
language,
|
||||
product: updatedProduct,
|
||||
};
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
{ ...state },
|
||||
true,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { getActionClassByEnvironmentIdAndName, getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import {
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
PRICING_APPSURVEYS_FREE_RESPONSES,
|
||||
@@ -17,7 +17,7 @@ import { createSurvey, getSurveys, transformToLegacySurvey } from "@formbricks/l
|
||||
import { getMonthlyTeamResponseCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
|
||||
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
|
||||
import { TJsWebsiteStateSync, ZJsWebsiteSyncInput } from "@formbricks/types/js";
|
||||
import { TJsWebsiteLegacyStateSync, TJsWebsiteStateSync, ZJsWebsiteSyncInput } from "@formbricks/types/js";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
@@ -77,12 +77,16 @@ export async function GET(
|
||||
}
|
||||
|
||||
if (!environment?.widgetSetupCompleted) {
|
||||
const firstSurvey = getExampleSurveyTemplate(WEBAPP_URL);
|
||||
const exampleTrigger = await getActionClassByEnvironmentIdAndName(environmentId, "New Session");
|
||||
if (!exampleTrigger) {
|
||||
throw new Error("Example trigger not found");
|
||||
}
|
||||
const firstSurvey = getExampleSurveyTemplate(WEBAPP_URL, exampleTrigger);
|
||||
await createSurvey(environmentId, firstSurvey);
|
||||
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
|
||||
}
|
||||
|
||||
const [surveys, noCodeActionClasses, product] = await Promise.all([
|
||||
const [surveys, actionClasses, product] = await Promise.all([
|
||||
getSurveys(environmentId),
|
||||
getActionClasses(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
@@ -99,25 +103,6 @@ export async function GET(
|
||||
// && (!survey.segment || survey.segment.filters.length === 0)
|
||||
);
|
||||
|
||||
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
|
||||
let transformedSurveys: TLegacySurvey[] | TSurvey[];
|
||||
|
||||
// Backwards compatibility for versions less than 1.7.0 (no multi-language support).
|
||||
if (version && isVersionGreaterThanOrEqualTo(version, "1.7.0")) {
|
||||
// Scenario 1: Multi language supported
|
||||
// Use the surveys as they are.
|
||||
transformedSurveys = filteredSurveys;
|
||||
} else {
|
||||
// Scenario 2: Multi language not supported
|
||||
// Convert to legacy surveys with default language.
|
||||
transformedSurveys = await Promise.all(
|
||||
filteredSurveys.map((survey) => {
|
||||
const languageCode = "default";
|
||||
return transformToLegacySurvey(survey, languageCode);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const updatedProduct: TProduct = {
|
||||
...product,
|
||||
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
@@ -126,13 +111,35 @@ export async function GET(
|
||||
}),
|
||||
};
|
||||
|
||||
// Create the 'state' object with surveys, noCodeActionClasses, product, and person.
|
||||
const state: TJsWebsiteStateSync = {
|
||||
surveys: isInAppSurveyLimitReached ? [] : transformedSurveys,
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode");
|
||||
|
||||
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
|
||||
let transformedSurveys: TLegacySurvey[] | TSurvey[] = surveys;
|
||||
let state: TJsWebsiteStateSync | TJsWebsiteLegacyStateSync = {
|
||||
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
|
||||
actionClasses,
|
||||
product: updatedProduct,
|
||||
};
|
||||
|
||||
// Backwards compatibility for versions less than 2.0.0 (no multi-language support and updated trigger action classes).
|
||||
if (!isVersionGreaterThanOrEqualTo(version ?? "", "2.0.0")) {
|
||||
// Scenario 2: Multi language and updated trigger action classes not supported
|
||||
// Convert to legacy surveys with default language
|
||||
// convert triggers to array of actionClasses Names
|
||||
transformedSurveys = await Promise.all(
|
||||
filteredSurveys.map((survey) => {
|
||||
const languageCode = "default";
|
||||
return transformToLegacySurvey(survey, languageCode);
|
||||
})
|
||||
);
|
||||
|
||||
state = {
|
||||
surveys: isInAppSurveyLimitReached ? [] : transformedSurveys,
|
||||
noCodeActionClasses,
|
||||
product: updatedProduct,
|
||||
};
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
{ ...state },
|
||||
true,
|
||||
|
||||
12
apps/web/app/lib/actionClass/actionClass.ts
Normal file
12
apps/web/app/lib/actionClass/actionClass.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const isValidCssSelector = (selector?: string) => {
|
||||
if (!selector || selector.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const element = document.createElement("div");
|
||||
try {
|
||||
element.querySelector(selector);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "1.7.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
|
||||
@@ -36,7 +36,7 @@ const createNoCodeActionByCSSSelector = async (
|
||||
// User fills the CSS Selector to track
|
||||
await expect(page.locator("[name='noCodeConfig.cssSelector.value']")).toBeVisible();
|
||||
await page.locator("[name='noCodeConfig.cssSelector.value']").fill(selector);
|
||||
await page.getByRole("button", { name: "Create Action", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Create action", exact: true }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(500);
|
||||
};
|
||||
@@ -76,20 +76,20 @@ const createNoCodeActionByPageURL = async (
|
||||
await page.locator("#PageURL").click();
|
||||
|
||||
// User opens the dropdown and selects the URL match type
|
||||
await expect(page.locator("[name='noCodeConfig.[pageUrl].rule']")).toBeVisible();
|
||||
await page.locator("[name='noCodeConfig.[pageUrl].rule']").selectOption({ label: matcher.label });
|
||||
await expect(page.locator("[name='noCodeConfig.pageUrl.rule']")).toBeVisible();
|
||||
await page.locator("[name='noCodeConfig.pageUrl.rule']").selectOption({ label: matcher.label });
|
||||
|
||||
// User fills the Page URL to track
|
||||
await page.locator("[name='noCodeConfig.[pageUrl].value']").fill(matcher.value);
|
||||
await page.locator("[name='noCodeConfig.pageUrl.value']").fill(matcher.value);
|
||||
|
||||
// User fills the Test URL to track
|
||||
await page.locator("[name='noCodeConfig.[pageUrl].testUrl']").fill(testURL);
|
||||
await page.locator("[name='noCodeConfig.pageUrl.testUrl']").fill(testURL);
|
||||
|
||||
// User clicks the Test Match button
|
||||
await page.getByRole("button", { name: "Test Match", exact: true }).click();
|
||||
|
||||
// User clicks the Create Action button
|
||||
await page.getByRole("button", { name: "Create Action", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Create action", exact: true }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(500);
|
||||
};
|
||||
@@ -127,7 +127,7 @@ const createNoCodeActionByInnerText = async (
|
||||
// User fills the Inner Text to track
|
||||
await expect(page.locator("[name='noCodeConfig.innerHtml.value']")).toBeVisible();
|
||||
await page.locator("[name='noCodeConfig.innerHtml.value']").fill(innerText);
|
||||
await page.getByRole("button", { name: "Create Action", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Create action", exact: true }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(500);
|
||||
};
|
||||
@@ -215,16 +215,14 @@ test.describe("Create and Edit No Code Action by Page URL", async () => {
|
||||
await expect(page.getByLabel("Description")).toBeVisible();
|
||||
await page.getByLabel("Description").fill(actions.edit.noCode.pageURL.description);
|
||||
|
||||
await expect(page.locator("[name='noCodeConfig.[pageUrl].rule']")).toBeVisible();
|
||||
await expect(page.locator("[name='noCodeConfig.pageUrl.rule']")).toBeVisible();
|
||||
await page
|
||||
.locator("[name='noCodeConfig.[pageUrl].rule']")
|
||||
.locator("[name='noCodeConfig.pageUrl.rule']")
|
||||
.selectOption({ label: actions.edit.noCode.pageURL.matcher.label });
|
||||
|
||||
await page
|
||||
.locator("[name='noCodeConfig.[pageUrl].value']")
|
||||
.fill(actions.edit.noCode.pageURL.matcher.value);
|
||||
await page.locator("[name='noCodeConfig.pageUrl.value']").fill(actions.edit.noCode.pageURL.matcher.value);
|
||||
|
||||
await page.locator("[name='noCodeConfig.[pageUrl].testUrl']").fill(actions.edit.noCode.pageURL.testURL);
|
||||
await page.locator("[name='noCodeConfig.pageUrl.testUrl']").fill(actions.edit.noCode.pageURL.testURL);
|
||||
await page.getByRole("button", { name: "Test Match", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save changes", exact: true }).click();
|
||||
});
|
||||
@@ -287,13 +285,16 @@ test.describe("Create and Edit Code Action", async () => {
|
||||
// User selects the Code tab
|
||||
await page.getByRole("button", { name: "Code", exact: true }).click();
|
||||
|
||||
await expect(page.getByLabel("Identifier")).toBeVisible();
|
||||
await page.getByLabel("Identifier").fill(actions.create.code.name);
|
||||
await expect(page.getByLabel("What did your user do?")).toBeVisible();
|
||||
await page.getByLabel("What did your user do?").fill(actions.create.code.name);
|
||||
|
||||
await expect(page.getByLabel("Description")).toBeVisible();
|
||||
await page.getByLabel("Description").fill(actions.create.code.description);
|
||||
|
||||
await page.getByRole("button", { name: "Create Action", exact: true }).click();
|
||||
await expect(page.getByLabel("Key")).toBeVisible();
|
||||
await page.getByLabel("Key").fill(actions.create.code.key);
|
||||
|
||||
await page.getByRole("button", { name: "Create action", exact: true }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
@@ -30,8 +30,8 @@ test.describe("JS Package Test", async () => {
|
||||
|
||||
await expect(page.getByText("Survey Trigger")).toBeVisible();
|
||||
|
||||
await page.getByRole("combobox").click();
|
||||
await page.getByLabel("New Session").click();
|
||||
await page.getByRole("button", { name: "Add action" }).click();
|
||||
await page.getByText("New SessionGets fired when a").click();
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
|
||||
environmentId =
|
||||
|
||||
@@ -25,44 +25,6 @@ test.describe("Survey Create & Submit Response", async () => {
|
||||
url = await page.evaluate("navigator.clipboard.readText()");
|
||||
});
|
||||
|
||||
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 page.locator("#howToSendCardTrigger").click();
|
||||
await page.locator("#howToSendCardOption-website").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]+$/);
|
||||
|
||||
@@ -243,7 +243,7 @@ export const actions = {
|
||||
name: "Create Action (Page URL)",
|
||||
description: "This is my Create Action (Page URL)",
|
||||
matcher: {
|
||||
label: "Starts with",
|
||||
label: "Contains",
|
||||
value: "custom-url",
|
||||
},
|
||||
testURL: "http://localhost:3000/custom-url",
|
||||
@@ -257,6 +257,7 @@ export const actions = {
|
||||
code: {
|
||||
name: "Create Action (Code)",
|
||||
description: "This is my Create Action (Code)",
|
||||
key: "Create Action (Code)",
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { init } from "@paralleldrive/cuid2";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
const createId = init({ length: 5 });
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const startTime = Date.now();
|
||||
console.log("Starting data migration...");
|
||||
|
||||
// 1. copy value of name to key for all action classes where type is code
|
||||
const codeActionClasses = await tx.actionClass.findMany({
|
||||
where: {
|
||||
type: "code",
|
||||
},
|
||||
});
|
||||
console.log(`Found ${codeActionClasses.length} saved code action classes to update.`);
|
||||
|
||||
await Promise.all(
|
||||
codeActionClasses.map((codeActionClass) => {
|
||||
return tx.actionClass.update({
|
||||
where: {
|
||||
id: codeActionClass.id,
|
||||
},
|
||||
data: {
|
||||
key: codeActionClass.name,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
console.log("Updated keys for saved code action classes.");
|
||||
|
||||
// 2. find all surveys with inlineTriggers and create action classes for them
|
||||
const surveysWithInlineTriggers = await tx.survey.findMany({
|
||||
where: {
|
||||
inlineTriggers: {
|
||||
not: Prisma.JsonNull,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(`Found ${surveysWithInlineTriggers.length} surveys with inline triggers to process.`);
|
||||
|
||||
// 3. Create action classes for inlineTriggers and update survey to use the newly created action classes
|
||||
const getActionClassIdByCode = async (code: string, environmentId: string): Promise<string> => {
|
||||
const existingActionClass = await tx.actionClass.findFirst({
|
||||
where: {
|
||||
type: "code",
|
||||
key: code,
|
||||
environmentId: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
let codeActionId = "";
|
||||
|
||||
if (existingActionClass) {
|
||||
codeActionId = existingActionClass.id;
|
||||
} else {
|
||||
let codeActionClassName = code;
|
||||
|
||||
// check if there is an existing noCode action class with this name
|
||||
const existingNoCodeActionClass = await tx.actionClass.findFirst({
|
||||
where: {
|
||||
name: code,
|
||||
environmentId: environmentId,
|
||||
NOT: {
|
||||
type: "code",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingNoCodeActionClass) {
|
||||
codeActionClassName = `${code}-${createId()}`;
|
||||
}
|
||||
|
||||
// create a new private action for codeConfig
|
||||
const codeActionClass = await tx.actionClass.create({
|
||||
data: {
|
||||
name: codeActionClassName,
|
||||
key: code,
|
||||
type: "code",
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
codeActionId = codeActionClass.id;
|
||||
}
|
||||
|
||||
return codeActionId;
|
||||
};
|
||||
|
||||
for (const survey of surveysWithInlineTriggers) {
|
||||
const { codeConfig, noCodeConfig } = survey.inlineTriggers ?? {};
|
||||
|
||||
if (
|
||||
noCodeConfig &&
|
||||
Object.keys(noCodeConfig).length > 0 &&
|
||||
(!codeConfig || codeConfig.identifier === "")
|
||||
) {
|
||||
// surveys with only noCodeConfig
|
||||
|
||||
// create a new private action for noCodeConfig
|
||||
const noCodeActionClass = await tx.actionClass.create({
|
||||
data: {
|
||||
name: `Custom Action-${createId()}`,
|
||||
noCodeConfig,
|
||||
type: "noCode",
|
||||
environment: {
|
||||
connect: {
|
||||
id: survey.environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// update survey to use the newly created action class
|
||||
await tx.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
triggers: {
|
||||
create: {
|
||||
actionClassId: noCodeActionClass.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if ((!noCodeConfig || Object.keys(noCodeConfig).length === 0) && codeConfig?.identifier) {
|
||||
const codeActionId = await getActionClassIdByCode(codeConfig.identifier, survey.environmentId);
|
||||
|
||||
await tx.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
triggers: {
|
||||
create: {
|
||||
actionClassId: codeActionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (codeConfig?.identifier && noCodeConfig) {
|
||||
// create a new private action for noCodeConfig
|
||||
|
||||
const noCodeActionClass = await tx.actionClass.create({
|
||||
data: {
|
||||
name: `Custom Action-${createId()}`,
|
||||
noCodeConfig,
|
||||
type: "noCode",
|
||||
environment: {
|
||||
connect: {
|
||||
id: survey.environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const codeActionId = await getActionClassIdByCode(codeConfig.identifier, survey.environmentId);
|
||||
|
||||
// update survey to use the newly created action classes
|
||||
await tx.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
triggers: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
actionClassId: noCodeActionClass.id,
|
||||
},
|
||||
{
|
||||
actionClassId: codeActionId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
console.log(`Data migration completed. Total time: ${(endTime - startTime) / 1000}s`);
|
||||
},
|
||||
{
|
||||
timeout: 180000, // 3 minutes
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e: Error) => {
|
||||
console.error("Error during migration: ", e.message);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { TBaseFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
TSurveyClosedMessage,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyInlineTriggers,
|
||||
TSurveyProductOverwrites,
|
||||
TSurveyQuestions,
|
||||
TSurveySingleUse,
|
||||
@@ -38,7 +37,6 @@ declare global {
|
||||
export type TeamBilling = TTeamBilling;
|
||||
export type UserNotificationSettings = TUserNotificationSettings;
|
||||
export type SegmentFilter = TBaseFilters;
|
||||
export type SurveyInlineTriggers = TSurveyInlineTriggers;
|
||||
export type Styling = TProductStyling;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[key,environmentId]` on the table `ActionClass` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "ActionClass" ADD COLUMN "key" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ActionClass_key_environmentId_key" ON "ActionClass"("key", "environmentId");
|
||||
@@ -31,6 +31,7 @@
|
||||
"data-migration:mls-fix": "ts-node ./data-migrations/20240318050527_add_languages_and_survey_languages/data-migration-fix.ts",
|
||||
"data-migration:mls-range-fix": "ts-node ./data-migrations/20240318050527_add_languages_and_survey_languages/data-migration-range-fix.ts",
|
||||
"data-migration:userId": "ts-node ./data-migrations/20240408123456_userid_migration/data-migration.ts",
|
||||
"data-migration:refactor-actions": "ts-node ./data-migrations/20240501111944_refactors_actions_and_removes_inline_triggers/data-migration.ts",
|
||||
"data-migration:mls-welcomeCard-fix": "ts-node ./data-migrations/20240318050527_add_languages_and_survey_languages/data-migration-welcomeCard-fix.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -332,6 +332,7 @@ model ActionClass {
|
||||
name String
|
||||
description String?
|
||||
type ActionType
|
||||
key String?
|
||||
/// @zod.custom(imports.ZActionClassNoCodeConfig)
|
||||
/// [ActionClassNoCodeConfig]
|
||||
noCodeConfig Json?
|
||||
@@ -340,6 +341,7 @@ model ActionClass {
|
||||
surveys SurveyTrigger[]
|
||||
actions Action[]
|
||||
|
||||
@@unique([key, environmentId])
|
||||
@@unique([name, environmentId])
|
||||
@@index([environmentId, createdAt])
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import z from "zod";
|
||||
|
||||
export { ZProductStyling } from "@formbricks/types/styling";
|
||||
|
||||
export const ZActionProperties = z.record(z.string());
|
||||
export { ZActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
|
||||
export { ZIntegrationConfig } from "@formbricks/types/integration";
|
||||
@@ -28,5 +26,4 @@ export {
|
||||
|
||||
export { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
export { ZTeamBilling } from "@formbricks/types/teams";
|
||||
export { ZLanguages } from "@formbricks/types/product";
|
||||
export { ZUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TJsAppConfigInput } from "@formbricks/types/js";
|
||||
import { CommandQueue } from "../shared/commandQueue";
|
||||
import { ErrorHandler } from "../shared/errors";
|
||||
import { Logger } from "../shared/logger";
|
||||
import { trackAction } from "./lib/actions";
|
||||
import { trackCodeAction } from "./lib/actions";
|
||||
import { getApi } from "./lib/api";
|
||||
import { setAttributeInApp } from "./lib/attributes";
|
||||
import { initialize } from "./lib/initialize";
|
||||
@@ -42,7 +42,7 @@ const reset = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
const track = async (name: string, properties: any = {}): Promise<void> => {
|
||||
queue.add<any>(true, "app", trackAction, name, properties);
|
||||
queue.add<any>(true, "app", trackCodeAction, name, properties);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { TJsActionInput } from "@formbricks/types/js";
|
||||
|
||||
import { NetworkError, Result, err, okVoid } from "../../shared/errors";
|
||||
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { AppConfig } from "./config";
|
||||
@@ -13,22 +13,9 @@ const inAppConfig = AppConfig.getInstance();
|
||||
|
||||
const intentsToNotCreateOnApp = ["Exit Intent (Desktop)", "50% Scroll"];
|
||||
|
||||
export const trackAction = async (name: string): Promise<Result<void, NetworkError>> => {
|
||||
const {
|
||||
userId,
|
||||
state: { surveys = [] },
|
||||
} = inAppConfig.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 triggerSurvey(survey);
|
||||
return;
|
||||
}
|
||||
});
|
||||
export const trackAction = async (name: string, alias?: string): Promise<Result<void, NetworkError>> => {
|
||||
const aliasName = alias || name;
|
||||
const { userId } = inAppConfig.get();
|
||||
|
||||
const input: TJsActionInput = {
|
||||
environmentId: inAppConfig.get().environmentId,
|
||||
@@ -38,7 +25,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
|
||||
|
||||
// don't send actions to the backend if the person is not identified
|
||||
if (userId && !intentsToNotCreateOnApp.includes(name)) {
|
||||
logger.debug(`Sending action "${name}" to backend`);
|
||||
logger.debug(`Sending action "${aliasName}" to backend`);
|
||||
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
@@ -52,7 +39,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
|
||||
if (!res.ok) {
|
||||
return err({
|
||||
code: "network_error",
|
||||
message: `Error tracking action ${name}`,
|
||||
message: `Error tracking action ${aliasName}`,
|
||||
status: 500,
|
||||
url: `${inAppConfig.get().apiHost}/api/v1/client/${inAppConfig.get().environmentId}/actions`,
|
||||
responseMessage: res.error.message,
|
||||
@@ -75,7 +62,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Formbricks: Action "${name}" tracked`);
|
||||
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
|
||||
|
||||
// get a list of surveys that are collecting insights
|
||||
const activeSurveys = inAppConfig.get().state?.surveys;
|
||||
@@ -83,7 +70,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
|
||||
if (!!activeSurveys && activeSurveys.length > 0) {
|
||||
for (const survey of activeSurveys) {
|
||||
for (const trigger of survey.triggers) {
|
||||
if (trigger === name) {
|
||||
if (trigger.actionClass.name === name) {
|
||||
await triggerSurvey(survey, name);
|
||||
}
|
||||
}
|
||||
@@ -94,3 +81,27 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const trackCodeAction = (
|
||||
code: string
|
||||
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
||||
const {
|
||||
state: { actionClasses = [] },
|
||||
} = inAppConfig.get();
|
||||
|
||||
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
|
||||
const action = codeActionClasses.find((action) => action.key === code);
|
||||
|
||||
if (!action) {
|
||||
return err({
|
||||
code: "invalid_code",
|
||||
message: `${code} action unknown. Please add this action in Formbricks first in order to use it in your code.`,
|
||||
});
|
||||
}
|
||||
|
||||
return trackAction(action.name, code);
|
||||
};
|
||||
|
||||
export const trackNoCodeAction = (name: string): Promise<Result<void, NetworkError>> => {
|
||||
return trackAction(name);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { trackAction } from "./actions";
|
||||
import { trackNoCodeAction } from "./actions";
|
||||
import { updateAttributes } from "./attributes";
|
||||
import { AppConfig, IN_APP_LOCAL_STORAGE_KEY } from "./config";
|
||||
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
|
||||
@@ -151,7 +151,7 @@ export const initialize = async (
|
||||
handleErrorOnFirstInit();
|
||||
}
|
||||
// and track the new session event
|
||||
await trackAction("New Session");
|
||||
await trackNoCodeAction("New Session");
|
||||
}
|
||||
// update attributes in config
|
||||
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import type { TActionClassPageUrlRule } from "@formbricks/types/actionClasses";
|
||||
import { TSurveyInlineTriggers } from "@formbricks/types/surveys";
|
||||
|
||||
import {
|
||||
ErrorHandler,
|
||||
@@ -13,9 +12,8 @@ import {
|
||||
okVoid,
|
||||
} from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { trackAction } from "./actions";
|
||||
import { trackNoCodeAction } from "./actions";
|
||||
import { AppConfig } from "./config";
|
||||
import { triggerSurvey } from "./widget";
|
||||
|
||||
const inAppConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
@@ -24,18 +22,15 @@ const errorHandler = ErrorHandler.getInstance();
|
||||
export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError | NetworkError>> => {
|
||||
logger.debug(`Checking page url: ${window.location.href}`);
|
||||
const { state } = inAppConfig.get();
|
||||
const { noCodeActionClasses = [], surveys = [] } = state ?? {};
|
||||
const { actionClasses = [] } = state ?? {};
|
||||
|
||||
const noCodeActionClasses = actionClasses.filter((action) => action.type === "noCode");
|
||||
|
||||
const actionsWithPageUrl: TActionClass[] = noCodeActionClasses.filter((action) => {
|
||||
const { innerHtml, cssSelector, pageUrl } = action.noCodeConfig || {};
|
||||
return pageUrl && !innerHtml && !cssSelector;
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -52,28 +47,12 @@ export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError
|
||||
|
||||
if (match.value === false) continue;
|
||||
|
||||
const trackResult = await trackAction(event.name);
|
||||
const trackResult = await trackNoCodeAction(event.name);
|
||||
|
||||
if (trackResult.ok !== true) return err(trackResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (surveysWithInlineTriggers.length > 0) {
|
||||
surveysWithInlineTriggers.forEach((survey) => {
|
||||
const { noCodeConfig } = survey.inlineTriggers ?? {};
|
||||
const { pageUrl } = noCodeConfig ?? {};
|
||||
|
||||
if (pageUrl) {
|
||||
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
|
||||
|
||||
if (match.ok !== true) return err(match.error);
|
||||
if (match.value === false) return;
|
||||
|
||||
triggerSurvey(survey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
@@ -119,10 +98,7 @@ export function checkUrlMatch(
|
||||
}
|
||||
}
|
||||
|
||||
const evaluateNoCodeConfig = (
|
||||
targetElement: HTMLElement,
|
||||
action: TActionClass | TSurveyInlineTriggers
|
||||
): boolean => {
|
||||
const evaluateNoCodeConfig = (targetElement: HTMLElement, action: TActionClass): boolean => {
|
||||
const innerHtml = action.noCodeConfig?.innerHtml?.value;
|
||||
const cssSelectors = action.noCodeConfig?.cssSelector?.value;
|
||||
const pageUrl = action.noCodeConfig?.pageUrl?.value;
|
||||
@@ -162,8 +138,10 @@ export const checkClickMatch = (event: MouseEvent) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { noCodeActionClasses } = state;
|
||||
if (!noCodeActionClasses) {
|
||||
const { actionClasses = [] } = state;
|
||||
const noCodeActionClasses = actionClasses.filter((action) => action.type === "noCode");
|
||||
|
||||
if (!noCodeActionClasses.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,7 +150,7 @@ export const checkClickMatch = (event: MouseEvent) => {
|
||||
noCodeActionClasses.forEach((action: TActionClass) => {
|
||||
const isMatch = evaluateNoCodeConfig(targetElement, action);
|
||||
if (isMatch) {
|
||||
trackAction(action.name).then((res) => {
|
||||
trackNoCodeAction(action.name).then((res) => {
|
||||
match(
|
||||
res,
|
||||
(_value: unknown) => {},
|
||||
@@ -189,16 +167,6 @@ export const checkClickMatch = (event: MouseEvent) => {
|
||||
if (!activeSurveys || activeSurveys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSurveys.forEach((survey) => {
|
||||
const { inlineTriggers } = survey;
|
||||
if (inlineTriggers) {
|
||||
const isMatch = evaluateNoCodeConfig(targetElement, inlineTriggers);
|
||||
if (isMatch) {
|
||||
triggerSurvey(survey);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let isClickEventListenerAdded = false;
|
||||
|
||||
@@ -65,7 +65,7 @@ export const sync = async (params: TJsAppSyncParams, noCache = false): Promise<v
|
||||
|
||||
let state: TJsAppState = {
|
||||
surveys: syncResult.value.surveys as TSurvey[],
|
||||
noCodeActionClasses: syncResult.value.noCodeActionClasses,
|
||||
actionClasses: syncResult.value.actionClasses,
|
||||
product: syncResult.value.product,
|
||||
attributes,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TJsPackageType } from "@formbricks/types/js";
|
||||
|
||||
import { trackAction as trackInAppAction } from "../app/lib/actions";
|
||||
import { trackAction as trackWebsiteAction } from "../website/lib/actions";
|
||||
import { trackNoCodeAction as trackInAppAction } from "../app/lib/actions";
|
||||
import { trackNoCodeAction as trackWebsiteAction } from "../website/lib/actions";
|
||||
import { err } from "./errors";
|
||||
|
||||
let exitIntentListenerAdded = false;
|
||||
|
||||
@@ -96,6 +96,11 @@ export type AttributeAlreadyExistsError = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type InvalidCodeError = {
|
||||
code: "invalid_code";
|
||||
message: string;
|
||||
};
|
||||
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
export class ErrorHandler {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CommandQueue } from "../shared/commandQueue";
|
||||
import { ErrorHandler } from "../shared/errors";
|
||||
import { Logger } from "../shared/logger";
|
||||
// Website package specific imports
|
||||
import { trackAction } from "./lib/actions";
|
||||
import { trackCodeAction } from "./lib/actions";
|
||||
import { resetConfig } from "./lib/common";
|
||||
import { initialize } from "./lib/initialize";
|
||||
import { checkPageUrl } from "./lib/noCodeActions";
|
||||
@@ -27,7 +27,7 @@ const reset = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
const track = async (name: string, properties: any = {}): Promise<void> => {
|
||||
queue.add<any>(true, "website", trackAction, name, properties);
|
||||
queue.add<any>(true, "website", trackCodeAction, name, properties);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NetworkError, Result, okVoid } from "../../shared/errors";
|
||||
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { WebsiteConfig } from "./config";
|
||||
import { triggerSurvey } from "./widget";
|
||||
@@ -6,23 +6,9 @@ import { triggerSurvey } from "./widget";
|
||||
const logger = Logger.getInstance();
|
||||
const websiteConfig = WebsiteConfig.getInstance();
|
||||
|
||||
export const trackAction = async (name: string): Promise<Result<void, NetworkError>> => {
|
||||
const {
|
||||
state: { surveys = [] },
|
||||
} = websiteConfig.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 triggerSurvey(survey);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`Formbricks: Action "${name}" tracked`);
|
||||
export const trackAction = async (name: string, alias?: string): Promise<Result<void, NetworkError>> => {
|
||||
const aliasName = alias || name;
|
||||
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
|
||||
|
||||
// get a list of surveys that are collecting insights
|
||||
const activeSurveys = websiteConfig.get().state?.surveys;
|
||||
@@ -30,7 +16,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
|
||||
if (!!activeSurveys && activeSurveys.length > 0) {
|
||||
for (const survey of activeSurveys) {
|
||||
for (const trigger of survey.triggers) {
|
||||
if (trigger === name) {
|
||||
if (trigger.actionClass.name === name) {
|
||||
await triggerSurvey(survey, name);
|
||||
}
|
||||
}
|
||||
@@ -41,3 +27,27 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const trackCodeAction = (
|
||||
code: string
|
||||
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
||||
const {
|
||||
state: { actionClasses = [] },
|
||||
} = websiteConfig.get();
|
||||
|
||||
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
|
||||
const action = codeActionClasses.find((action) => action.key === code);
|
||||
|
||||
if (!action) {
|
||||
return err({
|
||||
code: "invalid_code",
|
||||
message: `${code} action unknown. Please add this action in Formbricks first in order to use it in your code.`,
|
||||
});
|
||||
}
|
||||
|
||||
return trackAction(action.name, code);
|
||||
};
|
||||
|
||||
export const trackNoCodeAction = (name: string): Promise<Result<void, NetworkError>> => {
|
||||
return trackAction(name);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { trackAction } from "./actions";
|
||||
import { trackNoCodeAction } from "./actions";
|
||||
import { WEBSITE_LOCAL_STORAGE_KEY, WebsiteConfig } from "./config";
|
||||
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
|
||||
import { checkPageUrl } from "./noCodeActions";
|
||||
@@ -143,7 +143,7 @@ export const initialize = async (
|
||||
}
|
||||
|
||||
// and track the new session event
|
||||
await trackAction("New Session");
|
||||
await trackNoCodeAction("New Session");
|
||||
}
|
||||
|
||||
logger.debug("Adding event listeners");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import type { TActionClassPageUrlRule } from "@formbricks/types/actionClasses";
|
||||
import { TSurveyInlineTriggers } from "@formbricks/types/surveys";
|
||||
|
||||
import {
|
||||
ErrorHandler,
|
||||
@@ -13,9 +12,8 @@ import {
|
||||
okVoid,
|
||||
} from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { trackAction } from "./actions";
|
||||
import { trackNoCodeAction } from "./actions";
|
||||
import { WebsiteConfig } from "./config";
|
||||
import { triggerSurvey } from "./widget";
|
||||
|
||||
const websiteConfig = WebsiteConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
@@ -24,18 +22,15 @@ const errorHandler = ErrorHandler.getInstance();
|
||||
export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError | NetworkError>> => {
|
||||
logger.debug(`Checking page url: ${window.location.href}`);
|
||||
const { state } = websiteConfig.get();
|
||||
const { noCodeActionClasses = [], surveys = [] } = state ?? {};
|
||||
const { actionClasses = [] } = state ?? {};
|
||||
|
||||
const noCodeActionClasses = actionClasses.filter((action) => action.type === "noCode");
|
||||
|
||||
const actionsWithPageUrl: TActionClass[] = noCodeActionClasses.filter((action) => {
|
||||
const { innerHtml, cssSelector, pageUrl } = action.noCodeConfig || {};
|
||||
return pageUrl && !innerHtml && !cssSelector;
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -52,28 +47,12 @@ export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError
|
||||
|
||||
if (match.value === false) continue;
|
||||
|
||||
const trackResult = await trackAction(event.name);
|
||||
const trackResult = await trackNoCodeAction(event.name);
|
||||
|
||||
if (trackResult.ok !== true) return err(trackResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (surveysWithInlineTriggers.length > 0) {
|
||||
surveysWithInlineTriggers.forEach((survey) => {
|
||||
const { noCodeConfig } = survey.inlineTriggers ?? {};
|
||||
const { pageUrl } = noCodeConfig ?? {};
|
||||
|
||||
if (pageUrl) {
|
||||
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
|
||||
|
||||
if (match.ok !== true) return err(match.error);
|
||||
if (match.value === false) return;
|
||||
|
||||
triggerSurvey(survey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
@@ -119,10 +98,7 @@ export function checkUrlMatch(
|
||||
}
|
||||
}
|
||||
|
||||
const evaluateNoCodeConfig = (
|
||||
targetElement: HTMLElement,
|
||||
action: TActionClass | TSurveyInlineTriggers
|
||||
): boolean => {
|
||||
const evaluateNoCodeConfig = (targetElement: HTMLElement, action: TActionClass): boolean => {
|
||||
const innerHtml = action.noCodeConfig?.innerHtml?.value;
|
||||
const cssSelectors = action.noCodeConfig?.cssSelector?.value;
|
||||
const pageUrl = action.noCodeConfig?.pageUrl?.value;
|
||||
@@ -162,7 +138,9 @@ export const checkClickMatch = (event: MouseEvent) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { noCodeActionClasses } = state;
|
||||
const { actionClasses = [] } = state;
|
||||
const noCodeActionClasses = actionClasses.filter((action) => action.type === "noCode");
|
||||
|
||||
if (!noCodeActionClasses) {
|
||||
return;
|
||||
}
|
||||
@@ -172,7 +150,7 @@ export const checkClickMatch = (event: MouseEvent) => {
|
||||
noCodeActionClasses.forEach((action: TActionClass) => {
|
||||
const isMatch = evaluateNoCodeConfig(targetElement, action);
|
||||
if (isMatch) {
|
||||
trackAction(action.name).then((res) => {
|
||||
trackNoCodeAction(action.name).then((res) => {
|
||||
match(
|
||||
res,
|
||||
(_value) => {},
|
||||
@@ -189,16 +167,6 @@ export const checkClickMatch = (event: MouseEvent) => {
|
||||
if (!activeSurveys || activeSurveys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSurveys.forEach((survey) => {
|
||||
const { inlineTriggers } = survey;
|
||||
if (inlineTriggers) {
|
||||
const isMatch = evaluateNoCodeConfig(targetElement, inlineTriggers);
|
||||
if (isMatch) {
|
||||
triggerSurvey(survey);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let isClickEventListenerAdded = false;
|
||||
|
||||
@@ -67,7 +67,7 @@ export const sync = async (params: TJsWebsiteSyncParams, noCache = false): Promi
|
||||
|
||||
let state: TJsWebsiteState = {
|
||||
surveys: syncResult.value.surveys as TSurvey[],
|
||||
noCodeActionClasses: syncResult.value.noCodeActionClasses,
|
||||
actionClasses: syncResult.value.actionClasses,
|
||||
product: syncResult.value.product,
|
||||
displays: oldState?.displays || [],
|
||||
};
|
||||
|
||||
@@ -4,14 +4,13 @@ import { Prisma } from "@prisma/client";
|
||||
import { differenceInDays } from "date-fns";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TActionClassType } from "@formbricks/types/actionClasses";
|
||||
import { TAction, TActionInput, ZActionInput } from "@formbricks/types/actions";
|
||||
import { ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
|
||||
import { actionClassCache } from "../actionClass/cache";
|
||||
import { createActionClass, getActionClassByEnvironmentIdAndName } from "../actionClass/service";
|
||||
import { getActionClassByEnvironmentIdAndName } from "../actionClass/service";
|
||||
import { cache } from "../cache";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { activePersonCache } from "../person/cache";
|
||||
@@ -117,19 +116,12 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
|
||||
try {
|
||||
const { environmentId, name, userId } = data;
|
||||
|
||||
let actionType: TActionClassType = "code";
|
||||
if (name === "Exit Intent (Desktop)" || name === "50% Scroll") {
|
||||
actionType = "automatic";
|
||||
}
|
||||
|
||||
let actionClass = await getActionClassByEnvironmentIdAndName(environmentId, name);
|
||||
|
||||
if (!actionClass) {
|
||||
actionClass = await createActionClass(environmentId, {
|
||||
name,
|
||||
type: actionType,
|
||||
environmentId,
|
||||
});
|
||||
throw new OperationNotAllowedError(
|
||||
`${name} action unknown. Please add this action in Formbricks first in order to use it in your code.`
|
||||
);
|
||||
}
|
||||
|
||||
const action = await prisma.action.create({
|
||||
|
||||
@@ -23,6 +23,7 @@ const select = {
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
key: true,
|
||||
noCodeConfig: true,
|
||||
environmentId: true,
|
||||
};
|
||||
@@ -54,6 +55,7 @@ export const getActionClasses = (environmentId: string, page?: number): Promise<
|
||||
}
|
||||
)();
|
||||
|
||||
// This function is used to get an action by its name and environmentId(it can return private actions as well)
|
||||
export const getActionClassByEnvironmentIdAndName = (
|
||||
environmentId: string,
|
||||
name: string
|
||||
@@ -147,6 +149,7 @@ export const createActionClass = async (
|
||||
name: actionClass.name,
|
||||
description: actionClass.description,
|
||||
type: actionClass.type,
|
||||
key: actionClass.type === "code" ? actionClass.key : undefined,
|
||||
noCodeConfig: actionClass.noCodeConfig ? structuredClone(actionClass.noCodeConfig) : undefined,
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
@@ -161,7 +164,12 @@ export const createActionClass = async (
|
||||
|
||||
return actionClassPrisma;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new DatabaseError(
|
||||
`Action with ${error.meta?.target?.[0]} ${actionClass[error.meta?.target?.[0]]} already exists`
|
||||
);
|
||||
}
|
||||
|
||||
throw new DatabaseError(`Database error when creating an action for environment ${environmentId}`);
|
||||
}
|
||||
};
|
||||
@@ -182,9 +190,14 @@ export const updateActionClass = async (
|
||||
name: inputActionClass.name,
|
||||
description: inputActionClass.description,
|
||||
type: inputActionClass.type,
|
||||
noCodeConfig: inputActionClass.noCodeConfig
|
||||
? structuredClone(inputActionClass.noCodeConfig)
|
||||
: undefined,
|
||||
|
||||
...(inputActionClass.type === "code"
|
||||
? { key: inputActionClass.key }
|
||||
: {
|
||||
noCodeConfig: inputActionClass.noCodeConfig
|
||||
? structuredClone(inputActionClass.noCodeConfig)
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
select,
|
||||
});
|
||||
@@ -198,6 +211,12 @@ export const updateActionClass = async (
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new DatabaseError(
|
||||
`Action with ${error.meta?.target?.[0]} ${inputActionClass[error.meta?.target?.[0]]} already exists`
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
@@ -305,7 +305,6 @@ export const mockSurvey: TSurvey = {
|
||||
},
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
inlineTriggers: {},
|
||||
triggers: [],
|
||||
languages: mockSurveyLanguages,
|
||||
segment: mockSegment,
|
||||
|
||||
@@ -131,5 +131,4 @@ export const PREVIEW_SURVEY = {
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
inlineTriggers: null,
|
||||
} as TSurvey;
|
||||
|
||||
@@ -10,12 +10,7 @@ import { ZId } from "@formbricks/types/environment";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyFilterCriteria,
|
||||
TSurveyInput,
|
||||
ZSurveyWithRefinements,
|
||||
} from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyFilterCriteria, TSurveyInput, ZSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { getActionsByPersonId } from "../action/service";
|
||||
import { getActionClasses } from "../actionClass/service";
|
||||
@@ -105,12 +100,12 @@ export const selectSurvey = {
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
key: true,
|
||||
noCodeConfig: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inlineTriggers: true,
|
||||
segment: {
|
||||
include: {
|
||||
surveys: {
|
||||
@@ -122,62 +117,70 @@ export const selectSurvey = {
|
||||
},
|
||||
};
|
||||
|
||||
const getActionClassIdFromName = (actionClasses: TActionClass[], actionClassName: string): string => {
|
||||
return actionClasses.find((actionClass) => actionClass.name === actionClassName)!.id;
|
||||
};
|
||||
const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: TActionClass[]) => {
|
||||
if (!triggers) return;
|
||||
|
||||
const revalidateSurveyByActionClassName = (
|
||||
actionClasses: TActionClass[],
|
||||
actionClassNames: string[]
|
||||
): void => {
|
||||
for (const actionClassName of actionClassNames) {
|
||||
const actionClassId: string = getActionClassIdFromName(actionClasses, actionClassName);
|
||||
surveyCache.revalidate({
|
||||
actionClassId,
|
||||
});
|
||||
// check if all the triggers are valid
|
||||
triggers.forEach((trigger) => {
|
||||
if (!actionClasses.find((actionClass) => actionClass.id === trigger.actionClass.id)) {
|
||||
throw new InvalidInputError("Invalid trigger id");
|
||||
}
|
||||
});
|
||||
|
||||
// check if all the triggers are unique
|
||||
const triggerIds = triggers.map((trigger) => trigger.actionClass.id);
|
||||
|
||||
if (new Set(triggerIds).size !== triggerIds.length) {
|
||||
throw new InvalidInputError("Duplicate trigger id");
|
||||
}
|
||||
};
|
||||
|
||||
const processTriggerUpdates = (
|
||||
triggers: string[],
|
||||
currentSurveyTriggers: string[],
|
||||
const handleTriggerUpdates = (
|
||||
updatedTriggers: TSurvey["triggers"],
|
||||
currentTriggers: TSurvey["triggers"],
|
||||
actionClasses: TActionClass[]
|
||||
) => {
|
||||
const newTriggers: string[] = [];
|
||||
const removedTriggers: string[] = [];
|
||||
console.log("updatedTriggers", updatedTriggers, currentTriggers);
|
||||
if (!updatedTriggers) return {};
|
||||
checkTriggersValidity(updatedTriggers, actionClasses);
|
||||
|
||||
// find added triggers
|
||||
for (const trigger of triggers) {
|
||||
if (!trigger || currentSurveyTriggers.includes(trigger)) {
|
||||
continue;
|
||||
}
|
||||
newTriggers.push(trigger);
|
||||
}
|
||||
const currentTriggerIds = currentTriggers.map((trigger) => trigger.actionClass.id);
|
||||
const updatedTriggerIds = updatedTriggers.map((trigger) => trigger.actionClass.id);
|
||||
|
||||
// find removed triggers
|
||||
for (const trigger of currentSurveyTriggers) {
|
||||
if (!triggers.includes(trigger)) {
|
||||
removedTriggers.push(trigger);
|
||||
}
|
||||
}
|
||||
// added triggers are triggers that are not in the current triggers and are there in the new triggers
|
||||
const addedTriggers = updatedTriggers.filter(
|
||||
(trigger) => !currentTriggerIds.includes(trigger.actionClass.id)
|
||||
);
|
||||
|
||||
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
|
||||
const deletedTriggers = currentTriggers.filter(
|
||||
(trigger) => !updatedTriggerIds.includes(trigger.actionClass.id)
|
||||
);
|
||||
|
||||
// Construct the triggers update object
|
||||
const triggersUpdate: TriggerUpdate = {};
|
||||
|
||||
if (newTriggers.length > 0) {
|
||||
triggersUpdate.create = newTriggers.map((trigger) => ({
|
||||
actionClassId: getActionClassIdFromName(actionClasses, trigger),
|
||||
if (addedTriggers.length > 0) {
|
||||
triggersUpdate.create = addedTriggers.map((trigger) => ({
|
||||
actionClassId: trigger.actionClass.id,
|
||||
}));
|
||||
}
|
||||
|
||||
if (removedTriggers.length > 0) {
|
||||
if (deletedTriggers.length > 0) {
|
||||
// disconnect the public triggers from the survey
|
||||
triggersUpdate.deleteMany = {
|
||||
actionClassId: {
|
||||
in: removedTriggers.map((trigger) => getActionClassIdFromName(actionClasses, trigger)),
|
||||
in: deletedTriggers.map((trigger) => trigger.actionClass.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
revalidateSurveyByActionClassName(actionClasses, [...newTriggers, ...removedTriggers]);
|
||||
|
||||
[...addedTriggers, ...deletedTriggers].forEach((trigger) => {
|
||||
surveyCache.revalidate({
|
||||
actionClassId: trigger.actionClass.id,
|
||||
});
|
||||
});
|
||||
|
||||
return triggersUpdate;
|
||||
};
|
||||
|
||||
@@ -309,8 +312,15 @@ export const transformToLegacySurvey = async (
|
||||
languageCode?: string
|
||||
): Promise<TLegacySurvey> => {
|
||||
const targetLanguage = languageCode ?? "default";
|
||||
const transformedSurvey = reverseTranslateSurvey(survey, targetLanguage);
|
||||
|
||||
// workaround to handle triggers for legacy surveys
|
||||
// because we dont wanna do this in the `reverseTranslateSurvey` function
|
||||
const surveyToTransform: any = {
|
||||
...structuredClone(survey),
|
||||
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
|
||||
};
|
||||
|
||||
const transformedSurvey = reverseTranslateSurvey(surveyToTransform as TSurvey, targetLanguage);
|
||||
return transformedSurvey;
|
||||
};
|
||||
|
||||
@@ -342,7 +352,7 @@ export const getSurveyCount = async (environmentId: string): Promise<number> =>
|
||||
)();
|
||||
|
||||
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
|
||||
validateInputs([updatedSurvey, ZSurveyWithRefinements]);
|
||||
validateInputs([updatedSurvey, ZSurvey]);
|
||||
|
||||
try {
|
||||
const surveyId = updatedSurvey.id;
|
||||
@@ -406,8 +416,9 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
}
|
||||
|
||||
if (triggers) {
|
||||
data.triggers = processTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
|
||||
data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
|
||||
}
|
||||
|
||||
// if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey
|
||||
if (segment) {
|
||||
if (type === "app") {
|
||||
@@ -489,7 +500,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
// @ts-expect-error
|
||||
const modifiedSurvey: TSurvey = {
|
||||
...prismaSurvey, // Properties from prismaSurvey
|
||||
triggers: updatedSurvey.triggers ? updatedSurvey.triggers : [], // Include triggers from updatedSurvey
|
||||
segment: surveySegment,
|
||||
};
|
||||
|
||||
@@ -557,7 +567,7 @@ export async function deleteSurvey(surveyId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Revalidate triggers by actionClassId
|
||||
// Revalidate public triggers by actionClassId
|
||||
deletedSurvey.triggers.forEach((trigger) => {
|
||||
surveyCache.revalidate({
|
||||
actionClassId: trigger.actionClass.id,
|
||||
@@ -579,23 +589,15 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
try {
|
||||
// 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);
|
||||
revalidateSurveyByActionClassName(actionClasses, surveyBody.triggers);
|
||||
}
|
||||
const createdBy = surveyBody.createdBy;
|
||||
delete surveyBody.createdBy;
|
||||
|
||||
const actionClasses = await getActionClasses(environmentId);
|
||||
const data: Omit<Prisma.SurveyCreateInput, "environment"> = {
|
||||
...surveyBody,
|
||||
// TODO: Create with attributeFilters
|
||||
triggers: surveyBody.triggers
|
||||
? processTriggerUpdates(surveyBody.triggers, [], await getActionClasses(environmentId))
|
||||
? handleTriggerUpdates(surveyBody.triggers, [], actionClasses)
|
||||
: undefined,
|
||||
attributeFilters: undefined,
|
||||
};
|
||||
@@ -658,7 +660,6 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
// @ts-expect-error
|
||||
const transformedSurvey: TSurvey = {
|
||||
...survey,
|
||||
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
|
||||
...(survey.segment && {
|
||||
segment: {
|
||||
...survey.segment,
|
||||
@@ -697,8 +698,6 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
|
||||
|
||||
const defaultLanguageId = existingSurvey.languages.find((l) => l.default)?.language.id;
|
||||
|
||||
const actionClasses = await getActionClasses(environmentId);
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
@@ -720,10 +719,9 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
|
||||
},
|
||||
triggers: {
|
||||
create: existingSurvey.triggers.map((trigger) => ({
|
||||
actionClassId: getActionClassIdFromName(actionClasses, trigger),
|
||||
actionClassId: trigger.actionClass.id,
|
||||
})),
|
||||
},
|
||||
inlineTriggers: existingSurvey.inlineTriggers ?? undefined,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
@@ -804,8 +802,11 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
|
||||
environmentId: newSurvey.environmentId,
|
||||
});
|
||||
|
||||
// Revalidate surveys by actionClassId
|
||||
revalidateSurveyByActionClassName(actionClasses, existingSurvey.triggers);
|
||||
existingSurvey.triggers.forEach((trigger) => {
|
||||
surveyCache.revalidate({
|
||||
actionClassId: trigger.actionClass.id,
|
||||
});
|
||||
});
|
||||
|
||||
return newSurvey;
|
||||
} catch (error) {
|
||||
@@ -1063,7 +1064,6 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
|
||||
// @ts-expect-error
|
||||
const modifiedSurvey: TSurvey = {
|
||||
...prismaSurvey, // Properties from prismaSurvey
|
||||
triggers: prismaSurvey.triggers.map((trigger) => trigger.actionClass.name),
|
||||
segment: surveySegment,
|
||||
};
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ export const mockActionClass: TActionClass = {
|
||||
type: "code",
|
||||
description: "mock desc",
|
||||
noCodeConfig: null,
|
||||
key: "mock action class",
|
||||
...commonMockProperties,
|
||||
};
|
||||
|
||||
@@ -253,7 +254,7 @@ export const createSurveyInput: TSurveyInput = {
|
||||
type: "website",
|
||||
status: "inProgress",
|
||||
displayOption: "respondMultiple",
|
||||
triggers: [mockActionClass.name],
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
...baseSurveyProperties,
|
||||
};
|
||||
|
||||
@@ -261,7 +262,7 @@ export const updateSurveyInput: TSurvey = {
|
||||
type: "website",
|
||||
status: "inProgress",
|
||||
displayOption: "respondMultiple",
|
||||
triggers: [mockActionClass.name],
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
productOverwrites: null,
|
||||
styling: null,
|
||||
singleUse: null,
|
||||
@@ -270,7 +271,6 @@ export const updateSurveyInput: TSurvey = {
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
inlineTriggers: null,
|
||||
languages: [],
|
||||
...commonMockProperties,
|
||||
...baseSurveyProperties,
|
||||
@@ -278,10 +278,8 @@ export const updateSurveyInput: TSurvey = {
|
||||
|
||||
export const mockTransformedSurveyOutput = {
|
||||
...mockSurveyOutput,
|
||||
triggers: mockSurveyOutput.triggers.map((trigger) => trigger.actionClass.name),
|
||||
};
|
||||
|
||||
export const mockTransformedSyncSurveyOutput = {
|
||||
...mockSyncSurveyOutput,
|
||||
triggers: mockSurveyOutput.triggers.map((trigger) => trigger.actionClass.name),
|
||||
};
|
||||
|
||||
@@ -18,7 +18,6 @@ export const transformPrismaSurvey = (surveyPrisma: any): TSurvey => {
|
||||
|
||||
const transformedSurvey: TSurvey = {
|
||||
...surveyPrisma,
|
||||
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
|
||||
segment,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const isVersionGreaterThanOrEqualTo = (version: string, specificVersion: string) => {
|
||||
// return true; // uncomment when testing in demo app
|
||||
if (!version || !specificVersion) return false;
|
||||
|
||||
const parts1 = version.split(".").map(Number);
|
||||
const parts2 = specificVersion.split(".").map(Number);
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ export const ZLegacySurvey = ZSurvey.extend({
|
||||
questions: ZLegacySurveyQuestions,
|
||||
thankYouCard: ZLegacySurveyThankYouCard,
|
||||
welcomeCard: ZLegacySurveyWelcomeCard,
|
||||
triggers: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TLegacySurvey = z.infer<typeof ZLegacySurvey>;
|
||||
|
||||
@@ -45,7 +45,8 @@ export const ZActionClass = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
type: ZActionClassType,
|
||||
noCodeConfig: z.union([ZActionClassNoCodeConfig, z.null()]),
|
||||
key: z.string().nullable(),
|
||||
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
|
||||
environmentId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
@@ -54,9 +55,11 @@ export const ZActionClass = z.object({
|
||||
export type TActionClass = z.infer<typeof ZActionClass>;
|
||||
|
||||
export const ZActionClassInput = z.object({
|
||||
id: z.string().optional(),
|
||||
environmentId: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
key: z.string().optional().nullable(),
|
||||
noCodeConfig: ZActionClassNoCodeConfig.nullish(),
|
||||
type: z.enum(["code", "noCode", "automatic"]),
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ZJsAppStateSync = z.object({
|
||||
person: ZJsPerson.nullish(),
|
||||
userId: z.string().optional(),
|
||||
surveys: z.union([z.array(ZSurvey), z.array(ZLegacySurvey)]),
|
||||
noCodeActionClasses: z.array(ZActionClass),
|
||||
actionClasses: z.array(ZActionClass),
|
||||
product: ZProduct,
|
||||
language: z.string().optional(),
|
||||
});
|
||||
@@ -46,7 +46,7 @@ export type TJsWebsiteStateSync = z.infer<typeof ZJsWebsiteStateSync>;
|
||||
export const ZJsAppState = z.object({
|
||||
attributes: ZAttributes,
|
||||
surveys: z.array(ZSurvey),
|
||||
noCodeActionClasses: z.array(ZActionClass),
|
||||
actionClasses: z.array(ZActionClass),
|
||||
product: ZProduct,
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export type TJsAppState = z.infer<typeof ZJsAppState>;
|
||||
|
||||
export const ZJsWebsiteState = z.object({
|
||||
surveys: z.array(ZSurvey),
|
||||
noCodeActionClasses: z.array(ZActionClass),
|
||||
actionClasses: z.array(ZActionClass),
|
||||
product: ZProduct,
|
||||
displays: z.array(ZJSWebsiteStateDisplay),
|
||||
attributes: ZAttributes.optional(),
|
||||
@@ -62,6 +62,21 @@ export const ZJsWebsiteState = z.object({
|
||||
|
||||
export type TJsWebsiteState = z.infer<typeof ZJsWebsiteState>;
|
||||
|
||||
export const ZJsAppLegacyStateSync = z.object({
|
||||
person: ZJsPerson.nullish(),
|
||||
userId: z.string().optional(),
|
||||
surveys: z.union([z.array(ZSurvey), z.array(ZLegacySurvey)]),
|
||||
noCodeActionClasses: z.array(ZActionClass),
|
||||
product: ZProduct,
|
||||
language: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TJsAppLegacyStateSync = z.infer<typeof ZJsAppLegacyStateSync>;
|
||||
|
||||
export const ZJsWebsiteLegacyStateSync = ZJsAppLegacyStateSync.omit({ person: true });
|
||||
|
||||
export type TJsWebsiteLegacyStateSync = z.infer<typeof ZJsWebsiteLegacyStateSync>;
|
||||
|
||||
export const ZJsLegacyState = z.object({
|
||||
person: ZPerson.nullable().or(z.object({})),
|
||||
session: z.object({}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ZNoCodeConfig } from "./actionClasses";
|
||||
import { ZActionClass, ZNoCodeConfig } from "./actionClasses";
|
||||
import { ZAttributes } from "./attributes";
|
||||
import { ZAllowedFileExtension, ZColor, ZPlacement } from "./common";
|
||||
import { ZId } from "./environment";
|
||||
@@ -459,23 +459,6 @@ export const ZSurveyInlineTriggers = z.object({
|
||||
|
||||
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(),
|
||||
@@ -487,8 +470,7 @@ export const ZSurvey = z.object({
|
||||
status: ZSurveyStatus,
|
||||
displayOption: ZSurveyDisplayOption,
|
||||
autoClose: z.number().nullable(),
|
||||
triggers: z.array(z.string()),
|
||||
inlineTriggers: ZSurveyInlineTriggers.nullable(),
|
||||
triggers: z.array(z.object({ actionClass: ZActionClass })),
|
||||
redirectUrl: z.string().url().nullable(),
|
||||
recontactDays: z.number().nullable(),
|
||||
welcomeCard: ZSurveyWelcomeCard,
|
||||
@@ -511,58 +493,33 @@ export const ZSurvey = z.object({
|
||||
languages: z.array(ZSurveyLanguage),
|
||||
});
|
||||
|
||||
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().nullish(),
|
||||
status: ZSurveyStatus.optional(),
|
||||
displayOption: ZSurveyDisplayOption.optional(),
|
||||
autoClose: z.number().nullish(),
|
||||
redirectUrl: z.string().url().nullish(),
|
||||
recontactDays: z.number().nullish(),
|
||||
welcomeCard: ZSurveyWelcomeCard.optional(),
|
||||
questions: ZSurveyQuestions.optional(),
|
||||
thankYouCard: ZSurveyThankYouCard.optional(),
|
||||
hiddenFields: ZSurveyHiddenFields.optional(),
|
||||
delay: z.number().optional(),
|
||||
autoComplete: z.number().nullish(),
|
||||
runOnDate: z.date().nullish(),
|
||||
closeOnDate: z.date().nullish(),
|
||||
styling: ZSurveyStyling.optional(),
|
||||
surveyClosedMessage: ZSurveyClosedMessage.nullish(),
|
||||
singleUse: ZSurveySingleUse.nullish(),
|
||||
verifyEmail: ZSurveyVerifyEmail.optional(),
|
||||
pin: z.string().nullish(),
|
||||
resultShareKey: z.string().nullish(),
|
||||
displayPercentage: z.number().min(1).max(100).nullish(),
|
||||
triggers: z.array(z.object({ actionClass: ZActionClass })).optional(),
|
||||
});
|
||||
|
||||
export const ZSurveyInput = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: ZSurveyType.optional(),
|
||||
createdBy: z.string().cuid().nullish(),
|
||||
status: ZSurveyStatus.optional(),
|
||||
displayOption: ZSurveyDisplayOption.optional(),
|
||||
autoClose: z.number().nullish(),
|
||||
redirectUrl: z.string().url().nullish(),
|
||||
recontactDays: z.number().nullish(),
|
||||
welcomeCard: ZSurveyWelcomeCard.optional(),
|
||||
questions: ZSurveyQuestions.optional(),
|
||||
thankYouCard: ZSurveyThankYouCard.optional(),
|
||||
hiddenFields: ZSurveyHiddenFields.optional(),
|
||||
delay: z.number().optional(),
|
||||
autoComplete: z.number().nullish(),
|
||||
runOnDate: z.date().nullish(),
|
||||
closeOnDate: z.date().nullish(),
|
||||
styling: ZSurveyStyling.optional(),
|
||||
surveyClosedMessage: ZSurveyClosedMessage.nullish(),
|
||||
singleUse: ZSurveySingleUse.nullish(),
|
||||
verifyEmail: ZSurveyVerifyEmail.optional(),
|
||||
pin: z.string().nullish(),
|
||||
resultShareKey: z.string().nullish(),
|
||||
displayPercentage: z.number().min(1).max(100).nullish(),
|
||||
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,12 +1,14 @@
|
||||
import { UseFormRegister } from "react-hook-form";
|
||||
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
|
||||
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
|
||||
import { Input } from "../../Input";
|
||||
|
||||
interface CssSelectorProps {
|
||||
isCssSelector: boolean;
|
||||
setIsCssSelector: (value: boolean) => void;
|
||||
register: UseFormRegister<any>;
|
||||
register: UseFormRegister<TActionClass>;
|
||||
}
|
||||
|
||||
export const CssSelector = ({ isCssSelector, setIsCssSelector, register }: CssSelectorProps) => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { UseFormRegister } from "react-hook-form";
|
||||
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
|
||||
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
|
||||
import { Input } from "../../Input";
|
||||
|
||||
interface InnerHtmlSelectorProps {
|
||||
isInnerHtml: boolean;
|
||||
setIsInnerHtml: (value: boolean) => void;
|
||||
register: UseFormRegister<any>;
|
||||
register: UseFormRegister<TActionClass>;
|
||||
}
|
||||
|
||||
export const InnerHtmlSelector = ({ isInnerHtml, setIsInnerHtml, register }: InnerHtmlSelectorProps) => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Label } from "@radix-ui/react-dropdown-menu";
|
||||
import clsx from "clsx";
|
||||
import { Control, Controller, UseFormRegister } from "react-hook-form";
|
||||
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
|
||||
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
|
||||
import { Button } from "../../Button";
|
||||
import { Input } from "../../Input";
|
||||
@@ -15,8 +17,8 @@ interface PageUrlSelectorProps {
|
||||
isMatch: string;
|
||||
setIsMatch: (value: string) => void;
|
||||
handleMatchClick: () => void;
|
||||
control: Control<any>;
|
||||
register: UseFormRegister<any>;
|
||||
control: Control<TActionClass>;
|
||||
register: UseFormRegister<TActionClass>;
|
||||
}
|
||||
|
||||
export const PageUrlSelector = ({
|
||||
@@ -45,10 +47,10 @@ export const PageUrlSelector = ({
|
||||
<div className="col-span-1">
|
||||
<Label>URL</Label>
|
||||
<Controller
|
||||
name="noCodeConfig.[pageUrl].rule"
|
||||
name="noCodeConfig.pageUrl.rule"
|
||||
control={control}
|
||||
render={({ field: { onChange, value, name } }) => (
|
||||
<Select onValueChange={onChange} {...value} value={value} name={name}>
|
||||
<Select onValueChange={onChange} value={value} name={name}>
|
||||
<SelectTrigger className="w-[160px] bg-white">
|
||||
<SelectValue placeholder="Select match type" />
|
||||
</SelectTrigger>
|
||||
@@ -69,7 +71,7 @@ export const PageUrlSelector = ({
|
||||
type="text"
|
||||
className="bg-white"
|
||||
placeholder="e.g. https://app.com/dashboard"
|
||||
{...register("noCodeConfig.[pageUrl].value", { required: isPageUrl })}
|
||||
{...register("noCodeConfig.pageUrl.value", { required: isPageUrl })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +85,7 @@ export const PageUrlSelector = ({
|
||||
<Input
|
||||
type="text"
|
||||
value={testUrl}
|
||||
name="noCodeConfig.[pageUrl].testUrl"
|
||||
name="noCodeConfig.pageUrl.testUrl"
|
||||
onChange={(e) => {
|
||||
setTestUrl(e.target.value);
|
||||
setIsMatch("default");
|
||||
|
||||
@@ -68,7 +68,7 @@ export const Modal: React.FC<Modal> = ({
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<Dialog.Panel
|
||||
className={cn(
|
||||
"relative transform rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-xl",
|
||||
"relative transform overflow-hidden 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],
|
||||
className
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ModalWithTabsProps {
|
||||
description?: string;
|
||||
tabs: TabProps[];
|
||||
closeOnOutsideClick?: boolean;
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
interface TabProps {
|
||||
@@ -25,6 +26,7 @@ export const ModalWithTabs = ({
|
||||
label,
|
||||
description,
|
||||
closeOnOutsideClick,
|
||||
size = "lg",
|
||||
}: ModalWithTabsProps) => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
@@ -39,7 +41,7 @@ export const ModalWithTabs = ({
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={closeOnOutsideClick} size="lg">
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={closeOnOutsideClick} size={size}>
|
||||
<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">
|
||||
|
||||
@@ -87,7 +87,9 @@ export const copyToOtherEnvironmentAction = async (
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
name: trigger.actionClass.name,
|
||||
...(trigger.actionClass.type === "code"
|
||||
? { key: trigger.actionClass.key }
|
||||
: { name: trigger.actionClass.name }),
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
@@ -105,9 +107,13 @@ export const copyToOtherEnvironmentAction = async (
|
||||
},
|
||||
description: trigger.actionClass.description,
|
||||
type: trigger.actionClass.type,
|
||||
noCodeConfig: trigger.actionClass.noCodeConfig
|
||||
? structuredClone(trigger.actionClass.noCodeConfig)
|
||||
: undefined,
|
||||
...(trigger.actionClass.type === "code"
|
||||
? { key: trigger.actionClass.key }
|
||||
: {
|
||||
noCodeConfig: trigger.actionClass.noCodeConfig
|
||||
? structuredClone(trigger.actionClass.noCodeConfig)
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
@@ -160,7 +166,6 @@ export const copyToOtherEnvironmentAction = async (
|
||||
status: "draft",
|
||||
questions: structuredClone(existingSurvey.questions),
|
||||
thankYouCard: structuredClone(existingSurvey.thankYouCard),
|
||||
inlineTriggers: JSON.parse(JSON.stringify(existingSurvey.inlineTriggers)),
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((actionClassId) => ({
|
||||
actionClassId: actionClassId,
|
||||
|
||||
@@ -7,7 +7,9 @@ interface TabBarProps {
|
||||
activeId: string;
|
||||
setActiveId: (id: string) => void;
|
||||
className?: string;
|
||||
activeTabClassName?: string;
|
||||
tabStyle?: "bar" | "button";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TabBar: React.FC<TabBarProps> = ({
|
||||
@@ -15,7 +17,9 @@ export const TabBar: React.FC<TabBarProps> = ({
|
||||
activeId,
|
||||
setActiveId,
|
||||
className = "",
|
||||
activeTabClassName,
|
||||
tabStyle = "bar",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const Nav = () => {
|
||||
if (tabStyle === "bar") {
|
||||
@@ -27,7 +31,7 @@ export const TabBar: React.FC<TabBarProps> = ({
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
tab.id === activeId
|
||||
? " border-brand-dark border-b-2 font-semibold text-slate-900"
|
||||
? `border-brand-dark border-b-2 font-semibold text-slate-900 ${activeTabClassName}`
|
||||
: "text-slate-500 hover:text-slate-700",
|
||||
"flex h-full items-center px-3 text-sm font-medium"
|
||||
)}
|
||||
@@ -42,16 +46,23 @@ export const TabBar: React.FC<TabBarProps> = ({
|
||||
|
||||
if (tabStyle === "button") {
|
||||
return (
|
||||
<nav className="flex h-full w-full flex-1 items-center space-x-4" aria-label="Tabs">
|
||||
<nav
|
||||
className={cn(
|
||||
"flex h-full w-full flex-1 items-center space-x-4",
|
||||
disabled ? "cursor-not-allowed opacity-50" : ""
|
||||
)}
|
||||
aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<div className="flex h-full flex-1 justify-center px-3 py-2" key={tab.id}>
|
||||
<button
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
onClick={() => !disabled && setActiveId(tab.id)}
|
||||
type="button"
|
||||
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"
|
||||
? `bg-white font-semibold text-slate-900 ${activeTabClassName}`
|
||||
: "text-slate-500",
|
||||
"h-full w-full items-center rounded-lg text-center text-sm font-medium",
|
||||
disabled ? "cursor-not-allowed" : "hover:text-slate-700"
|
||||
)}
|
||||
aria-current={tab.id === activeId ? "page" : undefined}>
|
||||
{tab.label}
|
||||
|
||||
Reference in New Issue
Block a user