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({}) {

-
-
- -
-
-

- This button sends a{" "} - - Code Action - {" "} - to the Formbricks API called 'Code Action'. You will find it in the Actions Tab. -

-
-
-
+

Reset person / pull data from Formbricks app

@@ -135,60 +135,6 @@ export default function AppPage({}) { try again.

- -
-
- -
-
-

- This button sends an Action to the Formbricks API called 'New Session'. You will - find it in the Actions Tab. -

-
-
- -
-
- -
-
-

- 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. -

-
-
- -
-
- -
-
-

- 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({
- +
@@ -145,12 +176,24 @@ export default function ActionSettingsTab({ id="actionDescriptionSettingsInput" placeholder="User clicked Download Button " {...register("description", { - value: actionClass.description, disabled: actionClass.type === "automatic" ? true : false, })} />
)} + + {actionClass.type === "code" && ( +
+ + +
+ )}
{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" ? ( - -
-
-
-
- - -
-
- - -
-
-
- -
- - - -
-
-
-
- - -
-
- - ) : ( -
-
-
-
-
- - -
-
- - -
-
-
- - - How do Code Actions work? - - You can track code action anywhere in your app using{" "} - - formbricks.track("{watch("name")}") - {" "} - in your code. Read more in our{" "} - - docs - - . - - -
-
-
-
- - -
-
-
- )}
); 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 ( +
+
+
+
+
+ + +
+
+ + +
+
+ +
e.stopPropagation()}> + +
+ +
+
+ +
+ {type === "code" ? ( + <> +
+ + +
+ + + How do Code Actions work? + + You can track code action anywhere in your app using{" "} + + formbricks.track("{watch("key")}") + {" "} + in your code. Read more in our{" "} + + docs + + . + + + + ) : ( + <> +
+ +
+ + + + + )} +
+
+
+
+ + +
+
+
+
+ ); +}; 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 ( -
- -
-
- -
-
- 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"}

- -

action is performed

- +
+

+ 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}

- ))} -
- +
+ {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)} + /> +
+ ); + })} + +
+
+ {/* 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 = ({
( - @@ -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 ( -