diff --git a/apps/demo/pages/app/index.tsx b/apps/demo/pages/app/index.tsx
index acc2cec63a..1c8d816398 100644
--- a/apps/demo/pages/app/index.tsx
+++ b/apps/demo/pages/app/index.tsx
@@ -115,7 +115,7 @@ export default function AppPage({}) {
-
+
Reset person / pull data from Formbricks app
@@ -136,26 +136,6 @@ export default function AppPage({}) {
-
-
- {
- formbricks.track("Code Action");
- }}>
- Code Action
-
-
-
-
- This button sends a{" "}
-
- Code Action
- {" "}
- to the Formbricks API called 'Code Action'. You will find it in the Actions Tab.
-
-
-
diff --git a/apps/demo/pages/website/index.tsx b/apps/demo/pages/website/index.tsx
index e91ea3f158..0b0fc3c1b8 100644
--- a/apps/demo/pages/website/index.tsx
+++ b/apps/demo/pages/website/index.tsx
@@ -115,7 +115,7 @@ export default function AppPage({}) {
-
+
Reset person / pull data from Formbricks app
@@ -135,60 +135,6 @@ export default function AppPage({}) {
try again.
-
-
-
- {
- formbricks.track("New Session");
- }}>
- Track New Session
-
-
-
-
- This button sends an Action to the Formbricks API called 'New Session'. You will
- find it in the Actions Tab.
-
-
-
-
-
-
- {
- formbricks.track("Exit Intent");
- }}>
- Track Exit Intent
-
-
-
-
- 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.
-
-
-
-
-
-
- {
- formbricks.track("50% Scroll");
- }}>
- Track 50% Scroll
-
-
-
-
- This button sends an Action to the Formbricks API called '50% Scroll'. You can also
- scroll down to trigger the 50% scroll.
-
-
-
diff --git a/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/loading.tsx
index b94be0ba74..38d36e4f94 100644
--- a/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/loading.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/loading.tsx
@@ -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",
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx
index 484a3b543b..8012b2c1c3 100644
--- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx
@@ -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}
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx
index 5ffd98392a..7bd11b6646 100644
--- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx
@@ -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({
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx
index 8ad589be76..8fc16a478b 100644
--- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx
@@ -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
({
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({
{actionClass.type === "code" ? (
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx
index 99b58f8c9a..b9306fde3b 100644
--- a/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx
@@ -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): Promise => {
- 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 (
- resetAllStates(false)} noPadding closeOnOutsideClick={false}>
+
@@ -166,121 +41,15 @@ export default function AddNoCodeActionModal({
-
+
+
- {type === "noCode" ? (
-
- ) : (
-
- )}
);
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts
index 09ef811319..c3c943de09 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts
@@ -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);
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx
new file mode 100644
index 0000000000..800a223d29
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx
@@ -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>;
+ environmentId: string;
+ actionClasses: TActionClass[];
+ setActionClasses: React.Dispatch>;
+ isViewer: boolean;
+ localSurvey: TSurvey;
+ setLocalSurvey: React.Dispatch>;
+}
+
+export const AddActionModal = ({
+ open,
+ setOpen,
+ actionClasses,
+ setActionClasses,
+ localSurvey,
+ setLocalSurvey,
+ isViewer,
+ environmentId,
+}: AddActionModalProps) => {
+ const tabs = [
+ {
+ title: "Select saved action",
+ children: (
+
+ ),
+ },
+ {
+ title: "Capture new action",
+ children: (
+
+ ),
+ },
+ ];
+ return (
+
+ );
+};
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx
new file mode 100644
index 0000000000..3bd7837fe4
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx
@@ -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>;
+ isViewer: boolean;
+ setLocalSurvey?: React.Dispatch>;
+ setOpen: React.Dispatch>;
+ environmentId: string;
+}
+
+export const CreateNewActionTab = ({
+ actionClasses,
+ setActionClasses,
+ setOpen,
+ isViewer,
+ setLocalSurvey,
+ environmentId,
+}: CreateNewActionTabProps) => {
+ const { register, control, handleSubmit, watch, reset } = useForm({
+ 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) => {
+ 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 (
+
+ );
+};
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/InlineTriggers.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/InlineTriggers.tsx
deleted file mode 100644
index 06fd5a90e1..0000000000
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/InlineTriggers.tsx
+++ /dev/null
@@ -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
-): TSurvey => {
- return {
- ...localSurvey,
- inlineTriggers: {
- ...localSurvey.inlineTriggers,
- ...update(localSurvey.inlineTriggers),
- },
- };
-};
-
-const CodeActionSelector = ({
- localSurvey,
- setLocalSurvey,
-}: {
- localSurvey: TSurvey;
- setLocalSurvey: React.Dispatch>;
-}) => {
- 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 (
-
-
-
- onChange(e.target.value)}
- className="bg-white"
- placeholder="Identifier e.g. clicked-download"
- id="codeActionIdentifierInput"
- />
-
-
-
- );
-};
-
-const CssSelector = ({
- setLocalSurvey,
- localSurvey,
-}: {
- localSurvey: TSurvey;
- setLocalSurvey: React.Dispatch>;
-}) => {
- 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 (
-
-
-
- onChange(e.target.value)}
- className="bg-white"
- placeholder="Add .css-class or #css-id"
- id="cssSelectorInput"
- />
-
-
-
- );
-};
-
-const PageUrlSelector = ({
- localSurvey,
- setLocalSurvey,
-}: {
- localSurvey: TSurvey;
- setLocalSurvey: React.Dispatch>;
-}) => {
- 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 (
-
-
-
-
-
-
-
-
-
- Exactly matches
- Contains
- Starts with
- Ends with
- Does not exactly match
- Does not contain
-
-
-
-
- onPageUrlChange(e.target.value)}
- className="bg-white"
- placeholder="e.g. https://app.com/dashboard"
- id="pageURLInput"
- />
-
-
-
-
- );
-};
-
-const InnerHtmlSelector = ({
- localSurvey,
- setLocalSurvey,
-}: {
- localSurvey: TSurvey;
- setLocalSurvey: React.Dispatch>;
-}) => {
- 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 (
-
-
-
-
-
- onChange(e.target.value)}
- className="bg-white"
- placeholder="e.g. 'Install App'"
- id="innerHTMLInput"
- />
-
-
-
-
-
- );
-};
-
-const InlineTriggers = ({
- localSurvey,
- setLocalSurvey,
-}: {
- localSurvey: TSurvey;
- setLocalSurvey: React.Dispatch>;
-}) => {
- 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 (
-
-
-
- Custom Actions can only be used in this survey. They are not saved.
-
-
-
-
-
-
-
- );
-};
-
-export default InlineTriggers;
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SavedActionsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SavedActionsTab.tsx
new file mode 100644
index 0000000000..a848d16ad0
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SavedActionsTab.tsx
@@ -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>;
+ setOpen: React.Dispatch>;
+}
+
+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(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 (
+
+
{
+ setFilteredActionClasses(
+ availableActions.filter((actionClass) =>
+ actionClass.name.toLowerCase().includes(e.target.value.toLowerCase())
+ )
+ );
+ }}
+ className="mb-2 bg-white"
+ placeholder="Search actions"
+ id="search-actions"
+ />
+
+ {[automaticActions, noCodeActions, codeActions].map(
+ (actions, i) =>
+ actions.length > 0 && (
+
+
+ {i === 0 ? "Automatic" : i === 1 ? "No code" : "Code"}
+
+
+ {actions.map((action) => (
+
handleActionClick(action)}>
+
+
+ {action.type === "code" ? (
+
+ ) : action.type === "noCode" ? (
+
+ ) : action.type === "automatic" ? (
+
+ ) : null}
+
+
+
{action.name}
+
+
{action.description}
+
+ ))}
+
+
+ )
+ )}
+
+
+ );
+};
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx
index c137f366f2..f1a740f1ef 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx
@@ -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;
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx
index 1f576340c3..2896d7aaff 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx
@@ -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(null);
+ const [isAddActionModalOpen, setAddActionModalOpen] = useState(false);
const [actionClasses, setActionClasses] = useState(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({
-
-
-
- {activeTriggerTab === "inline" ? (
-
-
-
- ) : (
- <>
- {!isAddEventModalOpen &&
- localSurvey.triggers?.map((triggerEventClass, idx) => (
-
-
-
{idx === 0 ? "When" : "or"}
-
setTriggerEvent(idx, actionClassName)}>
-
-
-
-
- {
- setAddEventModalOpen(true);
- setActiveIndex(idx);
- }}>
-
- Add Action
-
-
- {actionClasses.map((actionClass) => (
-
- {actionClass.name}
-
- ))}
-
-
-
action is performed
-
removeTriggerEvent(idx)}>
-
-
+
+
+ Trigger survey when one of the actions is fired...
+
+
+ {localSurvey.triggers.filter(Boolean).map((trigger, idx) => {
+ return (
+
+ {idx !== 0 &&
or
}
+
+
+
+
+ {trigger.actionClass.type === "code" ? (
+
+ ) : trigger.actionClass.type === "noCode" ? (
+
+ ) : trigger.actionClass.type === "automatic" ? (
+
+ ) : null}
+
+
{trigger.actionClass.name}
- ))}
-
-
{
- addTriggerEvent();
- }}>
-
- Add condition
-
+
+ {trigger.actionClass.description && (
+ {trigger.actionClass.description}
+ )}
+ {trigger.actionClass.type === "code" && (
+
+ Key: {trigger.actionClass.key}
+
+ )}
+ {trigger.actionClass.type === "noCode" &&
+ trigger.actionClass.noCodeConfig?.cssSelector && (
+
+ CSS Selector: {trigger.actionClass.noCodeConfig.cssSelector.value}
+
+ )}
+ {trigger.actionClass.type === "noCode" &&
+ trigger.actionClass.noCodeConfig?.innerHtml && (
+
+ Inner Text: {trigger.actionClass.noCodeConfig.innerHtml.value}
+
+ )}
+ {trigger.actionClass.type === "noCode" &&
+ trigger.actionClass.noCodeConfig?.pageUrl && (
+
+ URL {trigger.actionClass.noCodeConfig.pageUrl.rule}:{" "}
+ {trigger.actionClass.noCodeConfig.pageUrl.value}
+
+ )}
+
+
- >
- )}
+
handleRemoveTriggerEvent(idx)}
+ />
+
+ );
+ })}
+
+
+
{
+ setAddActionModalOpen(true);
+ }}>
+
+ Add action
+
+ {/* Survey Display Settings */}
Survey Display Settings
Add a delay or auto-close the survey
@@ -387,13 +313,15 @@ export default function WhenToSendCard({
-
>
);
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation.ts
index 77cb9e3c1b..ca41051e9e 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation.ts
@@ -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");
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts
index 96b0f3ed83..76ace623f3 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts
@@ -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,
diff --git a/apps/web/app/api/v1/(legacy)/js/sync/lib/legacy.ts b/apps/web/app/api/v1/(legacy)/js/sync/lib/legacy.ts
deleted file mode 100644
index 0a9bcb6c45..0000000000
--- a/apps/web/app/api/v1/(legacy)/js/sync/lib/legacy.ts
+++ /dev/null
@@ -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: {} };
-};
diff --git a/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts
index d894362271..913810fddd 100644
--- a/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts
+++ b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts
@@ -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;
diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts
index e8f71f64f9..1c7a59bade 100644
--- a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts
@@ -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,
diff --git a/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts b/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts
index 40ab64862f..62d774c8a4 100644
--- a/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts
@@ -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,
diff --git a/apps/web/app/lib/actionClass/actionClass.ts b/apps/web/app/lib/actionClass/actionClass.ts
new file mode 100644
index 0000000000..a7e12526f7
--- /dev/null
+++ b/apps/web/app/lib/actionClass/actionClass.ts
@@ -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;
+};
diff --git a/apps/web/package.json b/apps/web/package.json
index e77a5df985..72d56a810e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
- "version": "1.7.0",
+ "version": "2.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
diff --git a/apps/web/playwright/action.spec.ts b/apps/web/playwright/action.spec.ts
index c8790c5305..ccfcd8a249 100644
--- a/apps/web/playwright/action.spec.ts
+++ b/apps/web/playwright/action.spec.ts
@@ -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);
});
diff --git a/apps/web/playwright/js.spec.ts b/apps/web/playwright/js.spec.ts
index 59cb966073..801409836d 100644
--- a/apps/web/playwright/js.spec.ts
+++ b/apps/web/playwright/js.spec.ts
@@ -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 =
diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts
index 134538928e..23374f81d2 100644
--- a/apps/web/playwright/survey.spec.ts
+++ b/apps/web/playwright/survey.spec.ts
@@ -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]+$/);
diff --git a/apps/web/playwright/utils/mock.ts b/apps/web/playwright/utils/mock.ts
index 649ec63617..2f2b457c73 100644
--- a/apps/web/playwright/utils/mock.ts
+++ b/apps/web/playwright/utils/mock.ts
@@ -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: {
diff --git a/packages/database/data-migrations/20240501111944_refactors_actions_and_removes_inline_triggers/data-migration.ts b/packages/database/data-migrations/20240501111944_refactors_actions_and_removes_inline_triggers/data-migration.ts
new file mode 100644
index 0000000000..0fca216eff
--- /dev/null
+++ b/packages/database/data-migrations/20240501111944_refactors_actions_and_removes_inline_triggers/data-migration.ts
@@ -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
=> {
+ 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();
+ });
diff --git a/packages/database/jsonTypes.ts b/packages/database/jsonTypes.ts
index 5a666f5255..886c15b7af 100644
--- a/packages/database/jsonTypes.ts
+++ b/packages/database/jsonTypes.ts
@@ -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;
}
}
diff --git a/packages/database/migrations/20240501111944_refactors_actions_and_removes_inline_triggers/migration.sql b/packages/database/migrations/20240501111944_refactors_actions_and_removes_inline_triggers/migration.sql
new file mode 100644
index 0000000000..105601106a
--- /dev/null
+++ b/packages/database/migrations/20240501111944_refactors_actions_and_removes_inline_triggers/migration.sql
@@ -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");
diff --git a/packages/database/package.json b/packages/database/package.json
index d63e611883..b981e1f481 100644
--- a/packages/database/package.json
+++ b/packages/database/package.json
@@ -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": {
diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma
index 9efa32f226..82ab12a623 100644
--- a/packages/database/schema.prisma
+++ b/packages/database/schema.prisma
@@ -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])
}
diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts
index fe8086b416..14432218a8 100644
--- a/packages/database/zod-utils.ts
+++ b/packages/database/zod-utils.ts
@@ -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";
diff --git a/packages/js-core/src/app/index.ts b/packages/js-core/src/app/index.ts
index 26f0404806..978b7cac5d 100644
--- a/packages/js-core/src/app/index.ts
+++ b/packages/js-core/src/app/index.ts
@@ -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 => {
};
const track = async (name: string, properties: any = {}): Promise => {
- queue.add(true, "app", trackAction, name, properties);
+ queue.add(true, "app", trackCodeAction, name, properties);
await queue.wait();
};
diff --git a/packages/js-core/src/app/lib/actions.ts b/packages/js-core/src/app/lib/actions.ts
index 231a10bacd..2ec6705c19 100644
--- a/packages/js-core/src/app/lib/actions.ts
+++ b/packages/js-core/src/app/lib/actions.ts
@@ -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> => {
- 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> => {
+ 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 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 => {
+ 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> => {
+ return trackAction(name);
+};
diff --git a/packages/js-core/src/app/lib/initialize.ts b/packages/js-core/src/app/lib/initialize.ts
index b381805c42..b898c0be56 100644
--- a/packages/js-core/src/app/lib/initialize.ts
+++ b/packages/js-core/src/app/lib/initialize.ts
@@ -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) {
diff --git a/packages/js-core/src/app/lib/noCodeActions.ts b/packages/js-core/src/app/lib/noCodeActions.ts
index b73d545a78..10cff8a7d9 100644
--- a/packages/js-core/src/app/lib/noCodeActions.ts
+++ b/packages/js-core/src/app/lib/noCodeActions.ts
@@ -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> => {
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 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;
diff --git a/packages/js-core/src/app/lib/sync.ts b/packages/js-core/src/app/lib/sync.ts
index c31b6dd32f..5f64ff7730 100644
--- a/packages/js-core/src/app/lib/sync.ts
+++ b/packages/js-core/src/app/lib/sync.ts
@@ -65,7 +65,7 @@ export const sync = async (params: TJsAppSyncParams, noCache = false): Promise => {
};
const track = async (name: string, properties: any = {}): Promise => {
- queue.add(true, "website", trackAction, name, properties);
+ queue.add(true, "website", trackCodeAction, name, properties);
await queue.wait();
};
diff --git a/packages/js-core/src/website/lib/actions.ts b/packages/js-core/src/website/lib/actions.ts
index 0663b77651..cb79da3542 100644
--- a/packages/js-core/src/website/lib/actions.ts
+++ b/packages/js-core/src/website/lib/actions.ts
@@ -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> => {
- 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> => {
+ 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 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 => {
+ 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> => {
+ return trackAction(name);
+};
diff --git a/packages/js-core/src/website/lib/initialize.ts b/packages/js-core/src/website/lib/initialize.ts
index c32893407f..e99764d210 100644
--- a/packages/js-core/src/website/lib/initialize.ts
+++ b/packages/js-core/src/website/lib/initialize.ts
@@ -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");
diff --git a/packages/js-core/src/website/lib/noCodeActions.ts b/packages/js-core/src/website/lib/noCodeActions.ts
index 493a6cb9f4..a8d86d6335 100644
--- a/packages/js-core/src/website/lib/noCodeActions.ts
+++ b/packages/js-core/src/website/lib/noCodeActions.ts
@@ -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> => {
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 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;
diff --git a/packages/js-core/src/website/lib/sync.ts b/packages/js-core/src/website/lib/sync.ts
index b0de3ed0f4..85b490efac 100644
--- a/packages/js-core/src/website/lib/sync.ts
+++ b/packages/js-core/src/website/lib/sync.ts
@@ -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 || [],
};
diff --git a/packages/lib/action/service.ts b/packages/lib/action/service.ts
index 479265b563..5e7e9d9d9c 100644
--- a/packages/lib/action/service.ts
+++ b/packages/lib/action/service.ts
@@ -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 => {
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({
diff --git a/packages/lib/actionClass/service.ts b/packages/lib/actionClass/service.ts
index ce21138bd9..51af8123a4 100644
--- a/packages/lib/actionClass/service.ts
+++ b/packages/lib/actionClass/service.ts
@@ -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);
}
diff --git a/packages/lib/i18n/i18n.mock.ts b/packages/lib/i18n/i18n.mock.ts
index 31568bfd88..b79fec0255 100644
--- a/packages/lib/i18n/i18n.mock.ts
+++ b/packages/lib/i18n/i18n.mock.ts
@@ -305,7 +305,6 @@ export const mockSurvey: TSurvey = {
},
pin: null,
resultShareKey: null,
- inlineTriggers: {},
triggers: [],
languages: mockSurveyLanguages,
segment: mockSegment,
diff --git a/packages/lib/styling/constants.ts b/packages/lib/styling/constants.ts
index c613f91277..8bab6e8ef4 100644
--- a/packages/lib/styling/constants.ts
+++ b/packages/lib/styling/constants.ts
@@ -131,5 +131,4 @@ export const PREVIEW_SURVEY = {
resultShareKey: null,
languages: [],
triggers: [],
- inlineTriggers: null,
} as TSurvey;
diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts
index 5e93112158..c7663eba09 100644
--- a/packages/lib/survey/service.ts
+++ b/packages/lib/survey/service.ts
@@ -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 => {
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 =>
)();
export const updateSurvey = async (updatedSurvey: TSurvey): Promise => {
- validateInputs([updatedSurvey, ZSurveyWithRefinements]);
+ validateInputs([updatedSurvey, ZSurvey]);
try {
const surveyId = updatedSurvey.id;
@@ -406,8 +416,9 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise =>
}
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 =>
// @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 = {
...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,
};
diff --git a/packages/lib/survey/tests/__mock__/survey.mock.ts b/packages/lib/survey/tests/__mock__/survey.mock.ts
index 1669cc7844..6f051bb993 100644
--- a/packages/lib/survey/tests/__mock__/survey.mock.ts
+++ b/packages/lib/survey/tests/__mock__/survey.mock.ts
@@ -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),
};
diff --git a/packages/lib/survey/util.ts b/packages/lib/survey/util.ts
index 88a0192104..e924deb816 100644
--- a/packages/lib/survey/util.ts
+++ b/packages/lib/survey/util.ts
@@ -18,7 +18,6 @@ export const transformPrismaSurvey = (surveyPrisma: any): TSurvey => {
const transformedSurvey: TSurvey = {
...surveyPrisma,
- triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
segment,
};
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/lib/testURLmatch.ts b/packages/lib/utils/testUrlMatch.ts
similarity index 100%
rename from apps/web/app/(app)/environments/[environmentId]/actions/lib/testURLmatch.ts
rename to packages/lib/utils/testUrlMatch.ts
diff --git a/packages/lib/utils/version.ts b/packages/lib/utils/version.ts
index 25a7412077..8e9ed3b39c 100644
--- a/packages/lib/utils/version.ts
+++ b/packages/lib/utils/version.ts
@@ -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);
diff --git a/packages/types/LegacySurvey.ts b/packages/types/LegacySurvey.ts
index 189cf8e63f..7477735fd4 100644
--- a/packages/types/LegacySurvey.ts
+++ b/packages/types/LegacySurvey.ts
@@ -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;
diff --git a/packages/types/actionClasses.ts b/packages/types/actionClasses.ts
index 97fc4f29b2..d94655b5bd 100644
--- a/packages/types/actionClasses.ts
+++ b/packages/types/actionClasses.ts
@@ -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;
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"]),
});
diff --git a/packages/types/js.ts b/packages/types/js.ts
index fc072f4d34..33c9252c8c 100644
--- a/packages/types/js.ts
+++ b/packages/types/js.ts
@@ -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;
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;
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;
+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;
+
+export const ZJsWebsiteLegacyStateSync = ZJsAppLegacyStateSync.omit({ person: true });
+
+export type TJsWebsiteLegacyStateSync = z.infer;
+
export const ZJsLegacyState = z.object({
person: ZPerson.nullable().or(z.object({})),
session: z.object({}),
diff --git a/packages/types/surveys.ts b/packages/types/surveys.ts
index 1c863996c7..2ac657e65b 100644
--- a/packages/types/surveys.ts
+++ b/packages/types/surveys.ts
@@ -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;
-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;
export type TSurveyDates = {
diff --git a/packages/ui/Actions/components/CssSelector.tsx b/packages/ui/Actions/components/CssSelector.tsx
index 508ec50592..e3a7380959 100644
--- a/packages/ui/Actions/components/CssSelector.tsx
+++ b/packages/ui/Actions/components/CssSelector.tsx
@@ -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;
+ register: UseFormRegister;
}
export const CssSelector = ({ isCssSelector, setIsCssSelector, register }: CssSelectorProps) => {
diff --git a/packages/ui/Actions/components/InnerHtmlSelector.tsx b/packages/ui/Actions/components/InnerHtmlSelector.tsx
index 481e0b918c..3b1afe3d5f 100644
--- a/packages/ui/Actions/components/InnerHtmlSelector.tsx
+++ b/packages/ui/Actions/components/InnerHtmlSelector.tsx
@@ -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;
+ register: UseFormRegister;
}
export const InnerHtmlSelector = ({ isInnerHtml, setIsInnerHtml, register }: InnerHtmlSelectorProps) => {
diff --git a/packages/ui/Actions/components/PageUrlSelector.tsx b/packages/ui/Actions/components/PageUrlSelector.tsx
index 20b5f4cd7f..e2558e697e 100644
--- a/packages/ui/Actions/components/PageUrlSelector.tsx
+++ b/packages/ui/Actions/components/PageUrlSelector.tsx
@@ -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;
- register: UseFormRegister;
+ control: Control;
+ register: UseFormRegister;
}
export const PageUrlSelector = ({
@@ -45,10 +47,10 @@ export const PageUrlSelector = ({
URL
(
-
+
@@ -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 })}
/>
@@ -83,7 +85,7 @@ export const PageUrlSelector = ({
{
setTestUrl(e.target.value);
setIsMatch("default");
diff --git a/packages/ui/Modal/index.tsx b/packages/ui/Modal/index.tsx
index dabe14c316..80f1b6b5e8 100644
--- a/packages/ui/Modal/index.tsx
+++ b/packages/ui/Modal/index.tsx
@@ -68,7 +68,7 @@ export const Modal: React.FC
= ({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
{
const [activeTab, setActiveTab] = useState(0);
@@ -39,7 +41,7 @@ export const ModalWithTabs = ({
}, [open]);
return (
-
+
diff --git a/packages/ui/SurveysList/actions.ts b/packages/ui/SurveysList/actions.ts
index c355a39780..b54ab5a823 100644
--- a/packages/ui/SurveysList/actions.ts
+++ b/packages/ui/SurveysList/actions.ts
@@ -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,
diff --git a/packages/ui/TabBar/index.tsx b/packages/ui/TabBar/index.tsx
index b0cfddd272..22f05ff43f 100644
--- a/packages/ui/TabBar/index.tsx
+++ b/packages/ui/TabBar/index.tsx
@@ -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
= ({
@@ -15,7 +17,9 @@ export const TabBar: React.FC = ({
activeId,
setActiveId,
className = "",
+ activeTabClassName,
tabStyle = "bar",
+ disabled = false,
}) => {
const Nav = () => {
if (tabStyle === "bar") {
@@ -27,7 +31,7 @@ export const TabBar: React.FC = ({
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 = ({
if (tabStyle === "button") {
return (
-
+
{tabs.map((tab) => (
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}