From 317a5463e9045406723dee04e4c8a2647cb9cad2 Mon Sep 17 00:00:00 2001 From: Shubham Palriwala Date: Thu, 24 Aug 2023 13:57:41 +0530 Subject: [PATCH] Revamp No-Code-Options: You can now target a combination of conditions (e.g. a specific button on a specific page) (#691) * feat: revamp nocode action across frontend, backend, and js lib * fix: remove console warning for ref forwarding * feat: make advancedToggle component and use it across * feat: use advancedToggle all across survey editor * feat: use advancedToggle all across survey editor for link surveys * remove: unused imports * ui tweaks * fix: form registration * chore: advancedOptionToggle now has grey box inside the div * fix: handle multiple css selectors separately * test no code demo app * replace logout with reset in demo apps --------- Co-authored-by: Johannes --- apps/demo/pages/app/index.tsx | 8 +- apps/demo/pages/test-nocode-app/index.tsx | 202 +++++++++++ .../actions/(selectors)/CssSelector.tsx | 31 ++ .../actions/(selectors)/InnerHtmlSelector.tsx | 35 ++ .../actions/(selectors)/PageUrlSelector.tsx | 121 +++++++ .../actions/ActionSettingsTab.tsx | 320 ++++++------------ .../actions/AddNoCodeActionModal.tsx | 298 ++++++---------- .../[surveyId]/edit/RecontactOptionsCard.tsx | 144 ++++---- .../[surveyId]/edit/ResponseOptionsCard.tsx | 234 +++++-------- .../[surveyId]/edit/WhenToSendCard.tsx | 55 ++- packages/js/src/lib/noCodeEvents.ts | 73 ++-- packages/types/v1/actionClasses.ts | 4 +- .../ui/components/AdvancedOptionToggle.tsx | 44 +++ packages/ui/index.tsx | 1 + 14 files changed, 865 insertions(+), 705 deletions(-) create mode 100644 apps/demo/pages/test-nocode-app/index.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector.tsx create mode 100644 packages/ui/components/AdvancedOptionToggle.tsx diff --git a/apps/demo/pages/app/index.tsx b/apps/demo/pages/app/index.tsx index cf3d6b1b99..6ead097ac2 100644 --- a/apps/demo/pages/app/index.tsx +++ b/apps/demo/pages/app/index.tsx @@ -73,18 +73,18 @@ export default function AppPage({}) { Reset person / pull data from Formbricks app

- On formbricks.logout() a few things happen: New person is created and{" "} + On formbricks.reset() a few things happen: New person is created and{" "} surveys & no-code actions are pulled from Formbricks:.

- If you made a change in Formbricks app and it does not seem to work, hit 'Logout' and + If you made a change in Formbricks app and it does not seem to work, hit 'Reset' and try again.

diff --git a/apps/demo/pages/test-nocode-app/index.tsx b/apps/demo/pages/test-nocode-app/index.tsx new file mode 100644 index 0000000000..9e4baf53b8 --- /dev/null +++ b/apps/demo/pages/test-nocode-app/index.tsx @@ -0,0 +1,202 @@ +import formbricks from "@formbricks/js"; +import Image from "next/image"; +import { useEffect, useState } from "react"; +import fbsetup from "../../public/fb-setup.png"; + +export default function AppPage({}) { + const [darkMode, setDarkMode] = useState(false); + + useEffect(() => { + if (darkMode) { + document.body.classList.add("dark"); + } else { + document.body.classList.remove("dark"); + } + }, [darkMode]); + + return ( +
+
+
+

+ Formbricks In-product Survey Demo App +

+

+ This app helps you test your in-app surveys. You can create and test user actions, create and + update user attributes, etc. +

+
+ +
+ +
+
+
+

1. Setup .env

+

+ Copy the environment ID of your Formbricks app to the env variable in demo/.env +

+ fb setup + +
+

You're connected with env:

+
+ + {process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID} + + + + + +
+
+
+
+

2. Widget Logs

+

+ Look at the logs to understand how the widget works.{" "} + Open your browser console to see the logs. +

+ {/*
+ +
*/} +
+
+ +
+
+

+ Reset person / pull data from Formbricks app +

+

+ On formbricks.reset() a few things happen: New person is created and{" "} + surveys & no-code actions are pulled from Formbricks:. +

+ +

+ If you made a change in Formbricks app and it does not seem to work, hit 'Reset' and + try again. +

+
+
+
+ +
+
+

Inner Text only

+
+
+ +
+
+ +
+
+

Inner Text + Css ID

+
+
+ +
+
+ +
+
+

Inner Text + CSS Class

+
+
+ +
+
+ +
+
+

ID + Class

+
+
+ +
+
+ +
+
+

ID only

+
+
+ +
+
+ +
+
+

Class only

+
+
+ +
+
+ +
+
+

Class + Class

+
+
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector.tsx b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector.tsx new file mode 100644 index 0000000000..593c71c050 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector.tsx @@ -0,0 +1,31 @@ +import { AdvancedOptionToggle, Input } from "@formbricks/ui"; +import { UseFormRegister } from "react-hook-form"; + +interface CssSelectorProps { + isCssSelector: boolean; + setIsCssSelector: (value: boolean) => void; + register: UseFormRegister; +} + +export const CssSelector = ({ isCssSelector, setIsCssSelector, register }: CssSelectorProps) => { + return ( + { + setIsCssSelector(!isCssSelector); + }} + title="CSS Selector" + description="If a user clicks a button with a specific CSS class or id" + childBorder={true}> +
+ +
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector.tsx b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector.tsx new file mode 100644 index 0000000000..7a6e4af945 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector.tsx @@ -0,0 +1,35 @@ +import { AdvancedOptionToggle, Input } from "@formbricks/ui"; +import { UseFormRegister } from "react-hook-form"; + +interface InnerHtmlSelectorProps { + isInnerHtml: boolean; + setIsInnerHtml: (value: boolean) => void; + register: UseFormRegister; +} + +export const InnerHtmlSelector = ({ isInnerHtml, setIsInnerHtml, register }: InnerHtmlSelectorProps) => { + return ( + { + setIsInnerHtml(!isInnerHtml); + }} + title="Inner Text" + description="If a user clicks a button with a specific text" + childBorder={true}> +
+
+
+ +
+
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector.tsx b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector.tsx new file mode 100644 index 0000000000..9edf88bb91 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector.tsx @@ -0,0 +1,121 @@ +import { + AdvancedOptionToggle, + Button, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@formbricks/ui"; +import { Label } from "@radix-ui/react-dropdown-menu"; +import clsx from "clsx"; +import { Control, Controller, UseFormRegister } from "react-hook-form"; + +interface PageUrlSelectorProps { + isPageUrl: boolean; + setIsPageUrl: (value: boolean) => void; + testUrl: string; + setTestUrl: (value: string) => void; + isMatch: string; + setIsMatch: (value: string) => void; + handleMatchClick: () => void; + control: Control; + register: UseFormRegister; +} + +export const PageUrlSelector = ({ + isPageUrl, + setIsPageUrl, + control, + register, + testUrl, + isMatch, + setIsMatch, + setTestUrl, + handleMatchClick, +}: PageUrlSelectorProps) => { + return ( + { + setIsPageUrl(!isPageUrl); + }} + title="Page URL" + description="If a user visits a specific URL" + childBorder={true}> +
+
+
+ + ( + + )} + /> +
+
+ +
+
+
+
Test your URL
+
+ Enter a URL to see if a user visiting it would be tracked. +
+
+
+ { + setTestUrl(e.target.value); + setIsMatch("default"); + }} + className={clsx( + isMatch === "yes" + ? "border-green-500 bg-green-50" + : isMatch === "no" + ? "border-red-200 bg-red-50" + : isMatch === "default" + ? "border-slate-200" + : "bg-white" + )} + placeholder="e.g. https://app.com/dashboard" + /> + +
+
+
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/ActionSettingsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/ActionSettingsTab.tsx index 0479c61049..ea4f37db2b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/ActionSettingsTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/ActionSettingsTab.tsx @@ -2,27 +2,18 @@ import DeleteDialog from "@/components/shared/DeleteDialog"; import type { NoCodeConfig } from "@formbricks/types/events"; -import { - Button, - Input, - Label, - RadioGroup, - RadioGroupItem, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@formbricks/ui"; +import { Button, Input, Label } from "@formbricks/ui"; import { TrashIcon } from "@heroicons/react/24/outline"; -import clsx from "clsx"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; import { testURLmatch } from "./testURLmatch"; import { deleteActionClass, updateActionClass } from "@formbricks/lib/services/actionClass"; -import { TActionClassInput } from "@formbricks/types/v1/actionClasses"; +import { TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses"; +import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector"; +import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector"; +import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector"; interface ActionSettingsTabProps { environmentId: string; @@ -33,6 +24,13 @@ interface ActionSettingsTabProps { export default function ActionSettingsTab({ environmentId, actionClass, setOpen }: ActionSettingsTabProps) { const router = useRouter(); const [openDeleteDialog, setOpenDeleteDialog] = useState(false); + const [testUrl, setTestUrl] = useState(""); + const [isMatch, setIsMatch] = useState(""); + const [isPageUrl, setIsPageUrl] = useState(actionClass.noCodeConfig?.pageUrl ? true : false); + const [isCssSelector, setIsCssSelector] = useState(actionClass.noCodeConfig?.cssSelector ? true : false); + const [isInnerHtml, setIsInnerHtml] = useState(actionClass.noCodeConfig?.innerHtml ? true : false); + const [isUpdatingAction, setIsUpdatingAction] = useState(false); + const [isDeletingAction, setIsDeletingAction] = useState(false); const { register, handleSubmit, control, watch } = useForm({ defaultValues: { @@ -41,36 +39,24 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen noCodeConfig: actionClass.noCodeConfig, }, }); - const [isUpdatingAction, setIsUpdatingAction] = useState(false); - const [isDeletingAction, setIsDeletingAction] = useState(false); - const onSubmit = async (data) => { - const filteredNoCodeConfig = filterNoCodeConfig(data.noCodeConfig as NoCodeConfig); + const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => { + const { pageUrl, innerHtml, cssSelector } = noCodeConfig; + const filteredNoCodeConfig: TActionClassNoCodeConfig = {}; - const updatedData: TActionClassInput = { - ...data, - noCodeConfig: filteredNoCodeConfig, - type: "noCode", - } as TActionClassInput; + 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 }; + } - setIsUpdatingAction(true); - await updateActionClass(environmentId, actionClass.id, updatedData); - router.refresh(); - setIsUpdatingAction(false); - setOpen(false); + return filteredNoCodeConfig; }; - const filterNoCodeConfig = (noCodeConfig: NoCodeConfig): NoCodeConfig => { - const { type } = noCodeConfig; - return { - type, - [type]: noCodeConfig[type], - }; - }; - - const [testUrl, setTestUrl] = useState(""); - const [isMatch, setIsMatch] = useState(""); - const handleMatchClick = () => { const match = testURLmatch( testUrl, @@ -82,6 +68,29 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen if (match === "no") toast.error("Your survey would not be shown."); }; + const onSubmit = async (data) => { + try { + setIsUpdatingAction(true); + if (data.name === "") throw new Error("Please give your action a name"); + if (!isPageUrl && !isCssSelector && !isInnerHtml) throw new Error("Please select atleast one selector"); + + const filteredNoCodeConfig = filterNoCodeConfig(data.noCodeConfig as NoCodeConfig); + const updatedData: TActionClassInput = { + ...data, + noCodeConfig: filteredNoCodeConfig, + type: "noCode", + } as TActionClassInput; + await updateActionClass(environmentId, actionClass.id, updatedData); + setOpen(false); + router.refresh(); + toast.success("Action updated successfully"); + } catch (error) { + toast.error(error.message); + } finally { + setIsUpdatingAction(false); + } + }; + const handleDeleteAction = async () => { try { setIsDeletingAction(true); @@ -99,181 +108,64 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen return (
-
- - +
+
+ + +
+
+ + +
-
- - -
-
- - {actionClass.type === "code" ? ( -

- This is a code action. Please make changes in your code base. -

- ) : actionClass.type === "noCode" ? ( -
-
- ( - -
- - -
-
- - -
-
- - -
-
- )} - /> - {(watch("noCodeConfig.type") === "pageUrl" || !watch("noCodeConfig.type")) && ( - <> -
-
- - ( - - )} - /> -
- -
- -
-
- -
- -
- -
- { - setTestUrl(e.target.value); - setIsMatch("default"); - }} - className={clsx( - isMatch === "yes" - ? "border-green-500 bg-green-50" - : isMatch === "no" - ? "border-red-200 bg-red-50" - : isMatch === "default" - ? "border-slate-200 bg-white" - : null - )} - placeholder="Paste the URL you want the event to trigger on" - /> - -
-
-
- - )} - {watch("noCodeConfig.type") === "innerHtml" && ( -
-
- -
-
- -
-
- )} - {watch("noCodeConfig.type") === "cssSelector" && ( -
-
- -
-
- -
-
- )} -
+ {actionClass.type === "code" ? ( +

+ This is a code action. Please make changes in your code base. +

+ ) : actionClass.type === "noCode" ? ( + <> +
+
- ) : actionClass.type === "automatic" ? ( -

- This action was created automatically. You cannot make changes to it. -

- ) : null} -
+ + + + + ) : actionClass.type === "automatic" ? ( +

+ This action was created automatically. You cannot make changes to it. +

+ ) : null}
{actionClass.type !== "automatic" && ( @@ -287,7 +179,7 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen )} -
diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/AddNoCodeActionModal.tsx b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/AddNoCodeActionModal.tsx index 28055b32d8..086ca8aeea 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/AddNoCodeActionModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/AddNoCodeActionModal.tsx @@ -1,27 +1,18 @@ "use client"; import Modal from "@/components/shared/Modal"; -import { - Button, - Input, - Label, - RadioGroup, - RadioGroupItem, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@formbricks/ui"; +import { Button, Input, Label } from "@formbricks/ui"; import { CursorArrowRaysIcon } from "@heroicons/react/24/solid"; -import clsx from "clsx"; import { useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { testURLmatch } from "./testURLmatch"; import { createActionClass } from "@formbricks/lib/services/actionClass"; import { TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses"; import { useRouter } from "next/navigation"; +import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector"; +import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector"; +import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector"; interface AddNoCodeActionModalProps { environmentId: string; @@ -32,40 +23,30 @@ interface AddNoCodeActionModalProps { export default function AddNoCodeActionModal({ environmentId, open, setOpen }: AddNoCodeActionModalProps) { const router = useRouter(); const { register, control, handleSubmit, watch, reset } = useForm(); - - // clean up noCodeConfig before submitting by removing unnecessary fields - const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => { - const { type } = noCodeConfig; - return { - type, - [type]: noCodeConfig[type], - }; - }; - - const submitEventClass = async (data: Partial): Promise => { - const filteredNoCodeConfig = filterNoCodeConfig(data.noCodeConfig as TActionClassNoCodeConfig); - - const updatedData: TActionClassInput = { - ...data, - noCodeConfig: filteredNoCodeConfig, - type: "noCode", - } as TActionClassInput; - - try { - await createActionClass(environmentId, updatedData); - router.refresh(); - reset(); - setOpen(false); - toast.success("Action added successfully."); - } catch (e) { - toast.error(e.message); - return; - } - }; - + 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 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, @@ -77,8 +58,47 @@ export default function AddNoCodeActionModal({ environmentId, open, setOpen }: A if (match === "no") toast.error("Your survey would not be shown."); }; + const submitEventClass = async (data: Partial): Promise => { + try { + setIsCreatingAction(true); + if (data.name === "") throw new Error("Please give your action a name"); + if (!isPageUrl && !isCssSelector && !isInnerHtml) throw new Error("Please select atleast one selector"); + + if (isPageUrl && data.noCodeConfig?.pageUrl?.rule === undefined) { + throw new Error("Please select a rule for page URL"); + } + + const filteredNoCodeConfig = filterNoCodeConfig(data.noCodeConfig as TActionClassNoCodeConfig); + const updatedData: TActionClassInput = { + ...data, + noCodeConfig: filteredNoCodeConfig, + type: "noCode", + } as TActionClassInput; + + await createActionClass(environmentId, updatedData); + router.refresh(); + 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}>
@@ -87,9 +107,9 @@ export default function AddNoCodeActionModal({ environmentId, open, setOpen }: A
-
Add Action
+
Track New User Action
- Track a user action to display surveys when it's performed. + Track a user action to display surveys or create user segment.
@@ -98,169 +118,49 @@ export default function AddNoCodeActionModal({ environmentId, open, setOpen }: A
-
- - ( - -
- - -
-
- - -
-
- - -
-
- )} - /> -
-
+
- - + +
- +
- {(watch("noCodeConfig.type") === "pageUrl" || !watch("noCodeConfig.type")) && ( - <> -
-
- - ( - - )} - /> -
- -
- -
-
- -
- -
- -
- { - setTestUrl(e.target.value); - setIsMatch("default"); - }} - className={clsx( - isMatch === "yes" - ? "border-green-500 bg-green-50" - : isMatch === "no" - ? "border-red-200 bg-red-50" - : isMatch === "default" - ? "border-slate-200 bg-white" - : null - )} - placeholder="Paste the URL you want the event to trigger on" - /> - -
-
-
- - )} - {watch("noCodeConfig.type") === "innerHtml" && ( -
-
- -
-
- -
-
- )} - {watch("noCodeConfig.type") === "cssSelector" && ( -
-
- -
-
- -
-
- )} +
+ +
+ + +
- -
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/RecontactOptionsCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/RecontactOptionsCard.tsx index 6029aa3cbb..beb6ad5b64 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/RecontactOptionsCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/RecontactOptionsCard.tsx @@ -2,7 +2,7 @@ import { cn } from "@formbricks/lib/cn"; import type { Survey } from "@formbricks/types/surveys"; -import { Badge, Input, Label, RadioGroup, RadioGroupItem, Switch } from "@formbricks/ui"; +import { AdvancedOptionToggle, Badge, Input, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; import * as Collapsible from "@radix-ui/react-collapsible"; import Link from "next/link"; @@ -142,83 +142,79 @@ export default function RecontactOptionsCard({
-
-
- - {/* */} - -
- {ignoreWaiting && localSurvey.recontactDays !== null && ( -
- { - const updatedSurvey = { ...localSurvey, recontactDays: v === "null" ? null : Number(v) }; - setLocalSurvey(updatedSurvey); - }}> - -
+ + )} -
+ ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/ResponseOptionsCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/ResponseOptionsCard.tsx index 857751992f..272424e387 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/ResponseOptionsCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/ResponseOptionsCard.tsx @@ -1,7 +1,7 @@ "use client"; import type { Survey } from "@formbricks/types/surveys"; -import { DatePicker, Input, Label, Switch } from "@formbricks/ui"; +import { AdvancedOptionToggle, DatePicker, Input, Label } from "@formbricks/ui"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useEffect, useState } from "react"; @@ -159,162 +159,104 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
{/* Close Survey on Limit */} -
-
- - -
- {autoComplete && ( -
- -
- )} -
+ + + {/* Close Survey on Date */} -
-
- - + +
+

+ Automatically mark survey as complete on: +

+
- {surveyCloseOnDateToggle && ( -
-
-

- Automatically mark survey as complete on: -

- -
-
- )} -
+ {/* Redirect on completion */} {localSurvey.type === "link" && ( <> -
-
- - -
- {redirectToggle && ( -
-
-

- Redirect respondents here: -

- handleRedirectUrlChange(e.target.value)} - /> -
+ +
+
+

+ Redirect respondents here: +

+ handleRedirectUrlChange(e.target.value)} + />
- )} -
+
+ {/* Adjust Survey Closed Message */} -
-
- - -
- {surveyClosedMessageToggle && ( -
-
- - handleClosedSurveyMessageChange({ heading: e.target.value })} - /> + +
+
+ + handleClosedSurveyMessageChange({ heading: e.target.value })} + /> - - handleClosedSurveyMessageChange({ subheading: e.target.value })} - /> -
+ + handleClosedSurveyMessageChange({ subheading: e.target.value })} + />
- )} -
+
+ )}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/WhenToSendCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/WhenToSendCard.tsx index 798c5a5545..0b278b6ca4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/WhenToSendCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/WhenToSendCard.tsx @@ -6,17 +6,16 @@ import { useEventClasses } from "@/lib/eventClasses/eventClasses"; import { cn } from "@formbricks/lib/cn"; import type { Survey } from "@formbricks/types/surveys"; import { + AdvancedOptionToggle, Badge, Button, Input, - Label, Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue, - Switch, } from "@formbricks/ui"; import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; import * as Collapsible from "@radix-ui/react-collapsible"; @@ -222,36 +221,28 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
)} -
- - -
- {autoClose && ( -
- -
- )} + + + e.noCodeConfig?.type === "pageUrl" - ); + const pageUrlEvents: TActionClass[] = (state?.noCodeActionClasses || []).filter((action) => { + const { innerHtml, cssSelector, pageUrl } = action.noCodeConfig || {}; + return pageUrl && !innerHtml && !cssSelector; + }); if (pageUrlEvents.length === 0) { return okVoid(); @@ -101,43 +102,45 @@ export const checkClickMatch = (event: MouseEvent) => { if (!state) { return; } - const innerHtmlEvents: TActionClass[] = state?.noCodeActionClasses?.filter( - (e) => e.noCodeConfig?.type === "innerHtml" - ); - const cssSelectorEvents: TActionClass[] = state?.noCodeActionClasses?.filter( - (e) => e.noCodeConfig?.type === "cssSelector" - ); - const targetElement = event.target as HTMLElement; + (state?.noCodeActionClasses || []).forEach((action: TActionClass) => { + const innerHtml = action.noCodeConfig?.innerHtml?.value; + const cssSelectors = action.noCodeConfig?.cssSelector?.value; + const pageUrl = action.noCodeConfig?.pageUrl?.value; - innerHtmlEvents.forEach((e) => { - const innerHtml = e.noCodeConfig?.innerHtml; - if (innerHtml && targetElement.innerHTML === innerHtml.value) { - trackAction(e.name).then((res) => { - match( - res, - (_value) => {}, - (err) => { - errorHandler.handle(err); - } - ); - }); + if (!innerHtml && !cssSelectors && !pageUrl) { + return; } - }); - cssSelectorEvents.forEach((e) => { - const cssSelector = e.noCodeConfig?.cssSelector; - if (cssSelector && targetElement.matches(cssSelector.value)) { - trackAction(e.name).then((res) => { - match( - res, - (_value) => {}, - (err) => { - errorHandler.handle(err); - } - ); - }); + if (innerHtml && targetElement.innerHTML !== innerHtml) { + return; } + + if (cssSelectors) { + // Split selectors that start with a . or # including the . or # + const individualSelectors = cssSelectors.split(/\s*(?=[.#])/); + for (let selector of individualSelectors) { + if (!targetElement.matches(selector)) { + return; + } + } + } + if (pageUrl) { + const urlMatch = checkUrlMatch(window.location.href, pageUrl, action.noCodeConfig?.pageUrl?.rule); + if (!urlMatch.ok || !urlMatch.value) { + return; + } + } + + trackAction(action.name).then((res) => { + match( + res, + (_value) => {}, + (err) => { + errorHandler.handle(err); + } + ); + }); }); }; diff --git a/packages/types/v1/actionClasses.ts b/packages/types/v1/actionClasses.ts index 1cb3f34111..e2aa62b0d0 100644 --- a/packages/types/v1/actionClasses.ts +++ b/packages/types/v1/actionClasses.ts @@ -1,7 +1,9 @@ import z from "zod"; export const ZActionClassNoCodeConfig = z.object({ - type: z.union([z.literal("innerHtml"), z.literal("pageUrl"), z.literal("cssSelector")]), + // The "type field has been made optional to allow for multiple selectors in one noCode action from now on + // Use the existence check of the fields to determine the types of the noCode action + type: z.optional(z.union([z.literal("innerHtml"), z.literal("pageUrl"), z.literal("cssSelector")])), pageUrl: z.optional( z.object({ value: z.string(), diff --git a/packages/ui/components/AdvancedOptionToggle.tsx b/packages/ui/components/AdvancedOptionToggle.tsx new file mode 100644 index 0000000000..6d1d806b58 --- /dev/null +++ b/packages/ui/components/AdvancedOptionToggle.tsx @@ -0,0 +1,44 @@ +import { Label } from "./Label"; +import { Switch } from "./Switch"; + +interface AdvancedOptionToggleProps { + isChecked: boolean; + onToggle: (checked: boolean) => void; + htmlId: string; + title: string; + description: any; + children: React.ReactNode; + childBorder?: boolean; +} + +export function AdvancedOptionToggle({ + isChecked, + onToggle, + htmlId, + title, + description, + children, + childBorder, +}: AdvancedOptionToggleProps) { + return ( +
+
+ + +
+ {isChecked && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index ccffcd28da..889132397a 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -1,3 +1,4 @@ +export { AdvancedOptionToggle } from "./components/AdvancedOptionToggle"; export { Alert, AlertDescription, AlertTitle } from "./components/Alert"; export { PersonAvatar, ProfileAvatar } from "./components/Avatars"; export { Badge } from "./components/Badge";