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 <johannes@formbricks.com>
This commit is contained in:
Shubham Palriwala
2023-08-24 13:57:41 +05:30
committed by GitHub
parent debf8433d0
commit 317a5463e9
14 changed files with 865 additions and 705 deletions

View File

@@ -73,18 +73,18 @@ export default function AppPage({}) {
Reset person / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-gray-300">
On formbricks.logout() a few things happen: <strong>New person is created</strong> and{" "}
On formbricks.reset() a few things happen: <strong>New person is created</strong> and{" "}
<strong>surveys & no-code actions are pulled from Formbricks:</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
formbricks.logout();
formbricks.reset();
}}>
Logout
Reset
</button>
<p className="text-xs text-slate-700 dark:text-gray-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Logout&apos; and
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>

View File

@@ -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 (
<div className="h-full bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your in-app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
<button
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => setDarkMode(!darkMode)}>
Toggle Dark Mode
</button>
</div>
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 dark:text-slate-300 sm:flex sm:items-center sm:text-base">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
<LogsContainer />
</div> */}
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-gray-600 dark:bg-gray-800">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-gray-300">
On formbricks.reset() a few things happen: <strong>New person is created</strong> and{" "}
<strong>surveys & no-code actions are pulled from Formbricks:</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
formbricks.reset();
}}>
Reset
</button>
<p className="text-xs text-slate-700 dark:text-gray-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>
<div className="p-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text only</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text + CSS ID");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + Css ID</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text + CSS Class");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + CSS Class</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("ID + Class");
}}>
ID and Class
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">ID + Class</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("ID + Class");
}}>
ID only
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">ID only</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Class only");
}}>
Class only
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Class only</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-1 css-2 mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Class + Class");
}}>
Class + Class
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Class + Class</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<any>;
}
export const CssSelector = ({ isCssSelector, setIsCssSelector, register }: CssSelectorProps) => {
return (
<AdvancedOptionToggle
htmlId="CssSelector"
isChecked={isCssSelector}
onToggle={() => {
setIsCssSelector(!isCssSelector);
}}
title="CSS Selector"
description="If a user clicks a button with a specific CSS class or id"
childBorder={true}>
<div className="w-full rounded-lg border border-slate-100 p-4">
<Input
type="text"
className="bg-white"
placeholder="Add .css-class or #css-id"
{...register("noCodeConfig.cssSelector.value", { required: isCssSelector })}
/>
</div>
</AdvancedOptionToggle>
);
};

View File

@@ -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<any>;
}
export const InnerHtmlSelector = ({ isInnerHtml, setIsInnerHtml, register }: InnerHtmlSelectorProps) => {
return (
<AdvancedOptionToggle
htmlId="InnerText"
isChecked={isInnerHtml}
onToggle={() => {
setIsInnerHtml(!isInnerHtml);
}}
title="Inner Text"
description="If a user clicks a button with a specific text"
childBorder={true}>
<div className="w-full rounded-lg border border-slate-100 p-4">
<div className="grid grid-cols-3 gap-x-8">
<div className="col-span-3 flex items-end">
<Input
type="text"
className="bg-white"
placeholder="e.g. 'Install App'"
{...register("noCodeConfig.innerHtml.value", { required: isInnerHtml })}
/>
</div>
</div>
</div>
</AdvancedOptionToggle>
);
};

View File

@@ -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<any>;
register: UseFormRegister<any>;
}
export const PageUrlSelector = ({
isPageUrl,
setIsPageUrl,
control,
register,
testUrl,
isMatch,
setIsMatch,
setTestUrl,
handleMatchClick,
}: PageUrlSelectorProps) => {
return (
<AdvancedOptionToggle
htmlId="PageURL"
isChecked={isPageUrl}
onToggle={() => {
setIsPageUrl(!isPageUrl);
}}
title="Page URL"
description="If a user visits a specific URL"
childBorder={true}>
<div className="col-span-1 space-y-3 p-4">
<div className="grid grid-cols-3 gap-x-8">
<div className="col-span-1">
<Label>URL</Label>
<Controller
name="noCodeConfig.[pageUrl].rule"
control={control}
render={({ field: { onChange, value } }) => (
<Select onValueChange={onChange} {...value} value={value}>
<SelectTrigger className="w-[160px] bg-white">
<SelectValue placeholder="Select match type" />
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem value="exactMatch">Exactly matches</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="startsWith">Starts with</SelectItem>
<SelectItem value="endsWith">Ends with</SelectItem>
<SelectItem value="notMatch">Does not exactly match</SelectItem>
<SelectItem value="notContains">Does not contain</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="col-span-2 flex items-end">
<Input
type="text"
className="bg-white"
placeholder="e.g. https://app.com/dashboard"
{...register("noCodeConfig.[pageUrl].value", { required: isPageUrl })}
/>
</div>
</div>
<div className="pt-4">
<div className="text-sm text-slate-900">Test your URL</div>
<div className="text-xs text-slate-400">
Enter a URL to see if a user visiting it would be tracked.
</div>
<div className=" rounded bg-slate-50">
<div className="mt-1 flex">
<Input
type="text"
value={testUrl}
onChange={(e) => {
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"
/>
<Button
type="button"
variant="secondary"
className="ml-2 whitespace-nowrap"
onClick={() => {
handleMatchClick();
}}>
Test Match
</Button>
</div>
</div>
</div>
</div>
</AdvancedOptionToggle>
);
};

View File

@@ -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 (
<div>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="">
<Label className="text-slate-600">Display name</Label>
<Input
type="text"
placeholder="e.g. Product Team Info"
{...register("name", {
value: actionClass.name,
disabled: actionClass.type === "automatic" || actionClass.type === "code" ? true : false,
})}
/>
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label>What did your user do?</Label>
<Input
placeholder="E.g. Clicked Download"
{...register("name", {
value: actionClass.name,
disabled: actionClass.type === "automatic" || actionClass.type === "code" ? true : false,
})}
/>
</div>
<div className="col-span-1">
<Label>Description</Label>
<Input
placeholder="User clicked Download Button "
{...register("description", {
value: actionClass.description,
disabled: actionClass.type === "automatic" ? true : false,
})}
/>
</div>
</div>
<div className="">
<Label className="text-slate-600">Display description</Label>
<Input
type="text"
placeholder="e.g. Triggers when user changed subscription"
{...register("description", {
value: actionClass.description,
disabled: actionClass.type === "automatic" ? true : false,
})}
/>
</div>
<div className="">
<Label>Action Type</Label>
{actionClass.type === "code" ? (
<p className="text-sm text-slate-600">
This is a code action. Please make changes in your code base.
</p>
) : actionClass.type === "noCode" ? (
<div className="flex justify-between rounded-lg">
<div className="w-full space-y-4">
<Controller
name="noCodeConfig.type"
defaultValue={"pageUrl"}
control={control}
render={({ field: { onChange, onBlur, value } }) => (
<RadioGroup className="flex" onValueChange={onChange} onBlur={onBlur} value={value}>
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
<RadioGroupItem value="pageUrl" id="pageUrl" className="bg-slate-50" />
<Label htmlFor="pageUrl" className="flex cursor-pointer items-center">
Page URL
</Label>
</div>
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
<RadioGroupItem value="innerHtml" id="innerHtml" className="bg-slate-50" />
<Label htmlFor="innerHtml" className="flex cursor-pointer items-center">
Inner Text
</Label>
</div>
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
<RadioGroupItem value="cssSelector" id="cssSelector" className="bg-slate-50" />
<Label htmlFor="cssSelector" className="flex cursor-pointer items-center">
CSS Selector
</Label>
</div>
</RadioGroup>
)}
/>
{(watch("noCodeConfig.type") === "pageUrl" || !watch("noCodeConfig.type")) && (
<>
<div className="grid w-full grid-cols-3 gap-x-8">
<div className="col-span-1">
<Label>URL</Label>
<Controller
name="noCodeConfig.pageUrl.rule"
defaultValue={"exactMatch"}
control={control}
render={({ field }) => (
<Select
onValueChange={field.onChange}
/* onValueChange={(e) => {
setMatchType(e as MatchType);
setIsMatch("default");
}} */
{...field}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select match type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="exactMatch">Exactly matches</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="startsWith">Starts with</SelectItem>
<SelectItem value="endsWith">Ends with</SelectItem>
<SelectItem value="notMatch">Does not exactly match</SelectItem>
<SelectItem value="notContains">Does not contain</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="col-span-2 flex w-full items-end">
<Input
type="text"
placeholder="e.g. https://app.formbricks.com/dashboard"
{...register("noCodeConfig.[pageUrl].value", { required: true })}
/>
</div>
</div>
<div>
<Label className="mb-2 block">Test Your URL</Label>
<div className=" rounded bg-slate-50 p-4">
<Label className="font-normal text-slate-500">
Enter a URL to see if it matches your event URL
</Label>
<div className="mt-1 flex">
<Input
type="text"
value={testUrl}
onChange={(e) => {
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"
/>
<Button
type="button"
variant="secondary"
className="ml-2 whitespace-nowrap"
onClick={() => {
handleMatchClick();
}}>
Test Match
</Button>
</div>
</div>
</div>
</>
)}
{watch("noCodeConfig.type") === "innerHtml" && (
<div className="grid w-full grid-cols-3 gap-x-8">
<div className="col-span-1">
<Label>Inner Text</Label>
</div>
<div className="col-span-3 flex w-full items-end">
<Input
type="text"
placeholder="e.g. 'Install App'"
{...register("noCodeConfig.innerHtml.value", { required: true })}
/>
</div>
</div>
)}
{watch("noCodeConfig.type") === "cssSelector" && (
<div className="grid w-full grid-cols-3 gap-x-8">
<div className="col-span-1">
<Label>CSS Tag</Label>
</div>
<div className="col-span-3 flex w-full items-end">
<Input
type="text"
placeholder="e.g. #install-button"
{...register("noCodeConfig.cssSelector.value", { required: true })}
/>
</div>
</div>
)}
</div>
{actionClass.type === "code" ? (
<p className="text-sm text-slate-600">
This is a code action. Please make changes in your code base.
</p>
) : actionClass.type === "noCode" ? (
<>
<div>
<Label>Select By</Label>
</div>
) : actionClass.type === "automatic" ? (
<p className="text-sm text-slate-600">
This action was created automatically. You cannot make changes to it.
</p>
) : null}
</div>
<CssSelector
isCssSelector={isCssSelector}
setIsCssSelector={setIsCssSelector}
register={register}
/>
<PageUrlSelector
isPageUrl={isPageUrl}
setIsPageUrl={setIsPageUrl}
register={register}
control={control}
testUrl={testUrl}
setTestUrl={setTestUrl}
isMatch={isMatch}
setIsMatch={setIsMatch}
handleMatchClick={handleMatchClick}
/>
<InnerHtmlSelector
isInnerHtml={isInnerHtml}
setIsInnerHtml={setIsInnerHtml}
register={register}
/>
</>
) : actionClass.type === "automatic" ? (
<p className="text-sm text-slate-600">
This action was created automatically. You cannot make changes to it.
</p>
) : null}
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
{actionClass.type !== "automatic" && (
@@ -287,7 +179,7 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen
</Button>
)}
<Button variant="secondary" href="https://formbricks.com/docs" target="_blank">
<Button variant="secondary" href="https://formbricks.com/docs/actions/no-code" target="_blank">
Read Docs
</Button>
</div>

View File

@@ -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<TActionClassInput>): Promise<void> => {
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<TActionClassInput>): Promise<void> => {
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 (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
<Modal open={open} setOpen={() => resetAllStates(false)} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
@@ -87,9 +107,9 @@ export default function AddNoCodeActionModal({ environmentId, open, setOpen }: A
<CursorArrowRaysIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Add Action</div>
<div className="text-xl font-medium text-slate-700">Track New User Action</div>
<div className="text-sm text-slate-500">
Track a user action to display surveys when it&apos;s performed.
Track a user action to display surveys or create user segment.
</div>
</div>
</div>
@@ -98,169 +118,49 @@ export default function AddNoCodeActionModal({ environmentId, open, setOpen }: A
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<Label>Select By</Label>
<Controller
name="noCodeConfig.type"
defaultValue={"pageUrl"}
control={control}
render={({ field: { onChange, onBlur, value } }) => (
<RadioGroup className="flex" onValueChange={onChange} onBlur={onBlur} value={value}>
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
<RadioGroupItem value="pageUrl" id="pageUrl" className="bg-slate-50" />
<Label htmlFor="pageUrl" className="flex cursor-pointer items-center">
Page URL
</Label>
</div>
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
<RadioGroupItem value="innerHtml" id="innerHtml" className="bg-slate-50" />
<Label htmlFor="innerHtml" className="flex cursor-pointer items-center">
Inner Text
</Label>
</div>
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
<RadioGroupItem value="cssSelector" id="cssSelector" className="bg-slate-50" />
<Label htmlFor="cssSelector" className="flex cursor-pointer items-center">
CSS Selector
</Label>
</div>
</RadioGroup>
)}
/>
</div>
<div className="grid w-full grid-cols-2 gap-x-8">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label>Name</Label>
<Input placeholder="e.g. Dashboard Page View" {...register("name", { required: true })} />
<Label>What did your user do?</Label>
<Input placeholder="E.g. Clicked Download" {...register("name", { required: true })} />
</div>
<div className="col-span-1">
<Label>Description</Label>
<Input placeholder="e.g. User visited dashboard" {...register("description")} />
<Input placeholder="User clicked Download Button " {...register("description")} />
</div>
</div>
{(watch("noCodeConfig.type") === "pageUrl" || !watch("noCodeConfig.type")) && (
<>
<div className="grid w-full grid-cols-3 gap-x-8">
<div className="col-span-1">
<Label>URL</Label>
<Controller
name="noCodeConfig.pageUrl.rule"
defaultValue={"exactMatch"}
control={control}
render={({ field }) => (
<Select
onValueChange={field.onChange}
/* onValueChange={(e) => {
setMatchType(e as MatchType);
setIsMatch("default");
}} */
{...field}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select match type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="exactMatch">Exactly matches</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="startsWith">Starts with</SelectItem>
<SelectItem value="endsWith">Ends with</SelectItem>
<SelectItem value="notMatch">Does not exactly match</SelectItem>
<SelectItem value="notContains">Does not contain</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="col-span-2 flex w-full items-end">
<Input
type="text"
placeholder="e.g. https://app.formbricks.com/dashboard"
{...register("noCodeConfig.[pageUrl].value", { required: true })}
/>
</div>
</div>
<div>
<Label className="mb-2 block">Test Your URL</Label>
<div className=" rounded bg-slate-50 p-4">
<Label className="font-normal text-slate-500">
Enter a URL to see if it matches your event URL
</Label>
<div className="mt-1 flex">
<Input
type="text"
value={testUrl}
onChange={(e) => {
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"
/>
<Button
type="button"
variant="secondary"
className="ml-2 whitespace-nowrap"
onClick={() => {
handleMatchClick();
}}>
Test Match
</Button>
</div>
</div>
</div>
</>
)}
{watch("noCodeConfig.type") === "innerHtml" && (
<div className="grid w-full grid-cols-3 gap-x-8">
<div className="col-span-1">
<Label>Inner Text</Label>
</div>
<div className="col-span-3 flex w-full items-end">
<Input
type="text"
placeholder="e.g. 'Install App'"
{...register("noCodeConfig.innerHtml.value", { required: true })}
/>
</div>
</div>
)}
{watch("noCodeConfig.type") === "cssSelector" && (
<div className="grid w-full grid-cols-3 gap-x-8">
<div className="col-span-1">
<Label>CSS Tag</Label>
</div>
<div className="col-span-3 flex w-full items-end">
<Input
type="text"
placeholder="e.g. #install-button"
{...register("noCodeConfig.cssSelector.value", { required: true })}
/>
</div>
</div>
)}
<div>
<Label>Select By</Label>
</div>
<CssSelector
isCssSelector={isCssSelector}
setIsCssSelector={setIsCssSelector}
register={register}
/>
<PageUrlSelector
isPageUrl={isPageUrl}
setIsPageUrl={setIsPageUrl}
register={register}
control={control}
testUrl={testUrl}
setTestUrl={setTestUrl}
isMatch={isMatch}
setIsMatch={setIsMatch}
handleMatchClick={handleMatchClick}
/>
<InnerHtmlSelector
isInnerHtml={isInnerHtml}
setIsInnerHtml={setIsInnerText}
register={register}
/>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
}}>
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
Cancel
</Button>
<Button variant="darkCTA" type="submit">
Add Action
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Track Action
</Button>
</div>
</div>

View File

@@ -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({
</RadioGroup>
</div>
<div className="p-3">
<div className="ml-2 flex items-center space-x-1">
<Switch id="recontactDays" checked={ignoreWaiting} onCheckedChange={handleCheckMark} />
{/* <Checkbox id="recontactDays" checked={ignoreWaiting} onCheckedChange={handleCheckMark} /> */}
<Label htmlFor="recontactDays" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Ignore waiting time between surveys</h3>
<p className="text-xs font-normal text-slate-500">
This setting overwrites your{" "}
<Link
className="decoration-brand-dark underline"
href={`/environments/${environmentId}/settings/product`}
target="_blank">
waiting period
</Link>
. Use with caution.
</p>
</div>
</Label>
</div>
{ignoreWaiting && localSurvey.recontactDays !== null && (
<div className="p-3">
<RadioGroup
value={localSurvey.recontactDays.toString()}
className="flex flex-col space-y-3"
onValueChange={(v) => {
const updatedSurvey = { ...localSurvey, recontactDays: v === "null" ? null : Number(v) };
setLocalSurvey(updatedSurvey);
}}>
<Label
htmlFor="ignore"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<RadioGroupItem
value="0"
id="ignore"
className="aria-checked:border-brand-dark mx-5 text-sm disabled:border-slate-400 aria-checked:border-2"
/>
<div>
<p className="font-semibold text-slate-700">Always show survey</p>
<AdvancedOptionToggle
htmlId="recontactDays"
isChecked={ignoreWaiting}
onToggle={handleCheckMark}
title="Ignore waiting time between surveys"
childBorder={false}
description={
<>
This setting overwrites your{" "}
<Link
className="decoration-brand-dark underline"
href={`/environments/${environmentId}/settings/product`}
target="_blank">
waiting period
</Link>
. Use with caution.
</>
}>
{localSurvey.recontactDays !== null && (
<RadioGroup
value={localSurvey.recontactDays.toString()}
className="flex w-full flex-col space-y-3 bg-white"
onValueChange={(v) => {
const updatedSurvey = { ...localSurvey, recontactDays: v === "null" ? null : Number(v) };
setLocalSurvey(updatedSurvey);
}}>
<Label
htmlFor="ignore"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<RadioGroupItem
value="0"
id="ignore"
className="aria-checked:border-brand-dark mx-4 text-sm disabled:border-slate-400 aria-checked:border-2"
/>
<div>
<p className="font-semibold text-slate-700">Always show survey</p>
<p className="mt-2 text-xs font-normal text-slate-600">
When conditions match, waiting time will be ignored and survey shown.
</p>
</div>
</Label>
<p className="mt-2 text-xs font-normal text-slate-600">
When conditions match, waiting time will be ignored and survey shown.
</p>
</div>
</Label>
<label
htmlFor="newDays"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<RadioGroupItem
value={inputDays === 0 ? "1" : inputDays.toString()} //Fixes that both radio buttons are checked when inputDays is 0
id="newDays"
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
/>
<div className="">
<p className="text-sm font-semibold text-slate-700">
Wait
<Input
type="number"
min="1"
id="inputDays"
value={inputDays === 0 ? 1 : inputDays}
onChange={handleRecontactDaysChange}
className="ml-2 mr-2 inline w-16 text-center text-sm"
/>
days before showing this survey again.
</p>
<label
htmlFor="newDays"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<RadioGroupItem
value={inputDays === 0 ? "1" : inputDays.toString()} //Fixes that both radio buttons are checked when inputDays is 0
id="newDays"
className="aria-checked:border-brand-dark mx-4 disabled:border-slate-400 aria-checked:border-2"
/>
<div className="">
<p className="text-sm font-semibold text-slate-700">
Wait
<Input
type="number"
min="1"
id="inputDays"
value={inputDays === 0 ? 1 : inputDays}
onChange={handleRecontactDaysChange}
className="ml-2 mr-2 inline w-16 text-center text-sm"
/>
days before showing this survey again.
</p>
<p className="mt-2 text-xs font-normal text-slate-600">
Overwrites waiting period between surveys to {inputDays === 0 ? 1 : inputDays} day(s).
</p>
</div>
</label>
</RadioGroup>
</div>
<p className="mt-2 text-xs font-normal text-slate-600">
Overwrites waiting period between surveys to {inputDays === 0 ? 1 : inputDays} day(s).
</p>
</div>
</label>
</RadioGroup>
)}
</div>
</AdvancedOptionToggle>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);

View File

@@ -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
<hr className="py-1 text-slate-600" />
<div className="p-3">
{/* Close Survey on Limit */}
<div className="p-3">
<div className="ml-2 flex items-center space-x-1">
<Switch id="autoComplete" checked={autoComplete} onCheckedChange={handleCheckMark} />
<Label htmlFor="autoComplete" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Close survey on response limit</h3>
<p className="text-xs font-normal text-slate-500">
Automatically close the survey after a certain number of responses.
</p>
</div>
</Label>
</div>
{autoComplete && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<label
htmlFor="autoCompleteResponses"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="">
<p className="text-sm font-semibold text-slate-700">
Automatically mark the survey as complete after
<Input
autoFocus
type="number"
min={
localSurvey?._count?.responses
? (localSurvey?._count?.responses + 1).toString()
: "1"
}
id="autoCompleteResponses"
value={localSurvey.autoComplete?.toString()}
onChange={handleInputResponse}
onBlur={handleInputResponseBlur}
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
/>
completed responses.
</p>
</div>
</label>
</div>
)}
</div>
<AdvancedOptionToggle
htmlId="closeOnNumberOfResponse"
isChecked={autoComplete}
onToggle={handleCheckMark}
title="Close survey on response limit"
description="Automatically close the survey after a certain number of responses."
childBorder={true}>
<label htmlFor="autoCompleteResponses" className="cursor-pointer bg-slate-50 p-4">
<p className="text-sm font-semibold text-slate-700">
Automatically mark the survey as complete after
<Input
autoFocus
type="number"
min={localSurvey?._count?.responses ? (localSurvey?._count?.responses + 1).toString() : "1"}
id="autoCompleteResponses"
value={localSurvey.autoComplete?.toString()}
onChange={handleInputResponse}
onBlur={handleInputResponseBlur}
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
/>
completed responses.
</p>
</label>
</AdvancedOptionToggle>
{/* Close Survey on Date */}
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="surveyDeadline"
checked={surveyCloseOnDateToggle}
onCheckedChange={handleSurveyCloseOnDateToggle}
/>
<Label htmlFor="surveyDeadline" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Close survey on date</h3>
<p className="text-xs font-normal text-slate-500">
Automatically closes the survey at the beginning of the day (UTC).
</p>
{localSurvey.status === "completed" && (
<p className="text-xs font-normal text-slate-500">This form is already completed.</p>
)}
</div>
</Label>
<AdvancedOptionToggle
htmlId="closeOnDate"
isChecked={surveyCloseOnDateToggle}
onToggle={handleSurveyCloseOnDateToggle}
title="Close survey on date"
description="Automatically closes the survey at the beginning of the day (UTC)."
childBorder={true}>
<div className="flex cursor-pointer p-4">
<p className="mr-2 mt-3 text-sm font-semibold text-slate-700">
Automatically mark survey as complete on:
</p>
<DatePicker date={closeOnDate} handleDateChange={handleCloseOnDateChange} />
</div>
{surveyCloseOnDateToggle && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<p className="mr-2 text-sm font-semibold text-slate-700">
Automatically mark survey as complete on:
</p>
<DatePicker date={closeOnDate} handleDateChange={handleCloseOnDateChange} />
</div>
</div>
)}
</div>
</AdvancedOptionToggle>
{/* Redirect on completion */}
{localSurvey.type === "link" && (
<>
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="redirectUrl"
checked={redirectToggle}
onCheckedChange={handleRedirectCheckMark}
/>
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
</p>
</div>
</Label>
</div>
{redirectToggle && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<p className="mr-2 whitespace-nowrap text-sm font-semibold text-slate-700">
Redirect respondents here:
</p>
<Input
autoFocus
className="bg-white"
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
<AdvancedOptionToggle
htmlId="redirectUrl"
isChecked={redirectToggle}
onToggle={handleRedirectCheckMark}
title="Redirect on completion"
description="Redirect user to specified link on survey completion"
childBorder={true}>
<div className="w-full p-4">
<div className="flex w-full cursor-pointer items-center">
<p className="mr-2 w-[400px] text-sm font-semibold text-slate-700">
Redirect respondents here:
</p>
<Input
autoFocus
className="w-full bg-white"
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
)}
</div>
</div>
</AdvancedOptionToggle>
{/* Adjust Survey Closed Message */}
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="adjustSurveyClosedMessage"
checked={surveyClosedMessageToggle}
onCheckedChange={handleCloseSurveyMessageToggle}
/>
<Label htmlFor="adjustSurveyClosedMessage" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">
{" "}
{"Adjust 'Survey Closed' message"}
</h3>
<p className="text-xs font-normal text-slate-500">
Change the message visitors see when the survey is closed.
</p>
</div>
</Label>
</div>
{surveyClosedMessageToggle && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<Label htmlFor="headline">Heading</Label>
<Input
autoFocus
id="heading"
className="mb-4 mt-2 bg-white"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
/>
<AdvancedOptionToggle
htmlId="adjustSurveyClosedMessage"
isChecked={surveyClosedMessageToggle}
onToggle={handleCloseSurveyMessageToggle}
title="Adjust 'Survey Closed' message"
description="Change the message visitors see when the survey is closed."
childBorder={true}>
<div className="flex w-full items-center space-x-1 p-4 pb-4">
<div className="w-full cursor-pointer items-center bg-slate-50">
<Label htmlFor="headline">Heading</Label>
<Input
autoFocus
id="heading"
className="mb-4 mt-2 bg-white"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
/>
<Label htmlFor="headline">Subheading</Label>
<Input
className="mt-2 bg-white"
id="subheading"
name="subheading"
defaultValue={surveyClosedMessage.subheading}
onChange={(e) => handleClosedSurveyMessageChange({ subheading: e.target.value })}
/>
</div>
<Label htmlFor="headline">Subheading</Label>
<Input
className="mt-2 bg-white"
id="subheading"
name="subheading"
defaultValue={surveyClosedMessage.subheading}
onChange={(e) => handleClosedSurveyMessageChange({ subheading: e.target.value })}
/>
</div>
)}
</div>
</div>
</AdvancedOptionToggle>
</>
)}
</div>

View File

@@ -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
</div>
)}
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch id="autoClose" checked={autoClose} onCheckedChange={handleCheckMark} />
<Label htmlFor="autoClose" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Auto close on inactivity</h3>
</div>
</Label>
</div>
{autoClose && (
<div className="ml-2 flex items-center space-x-1 px-4 pb-4">
<label
htmlFor="autoCloseSeconds"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="">
<p className="text-sm font-semibold text-slate-700">
Automatically close survey after
<Input
type="number"
min="1"
id="autoCloseSeconds"
value={localSurvey.autoClose?.toString()}
onChange={(e) => handleInputSeconds(e)}
className="ml-2 mr-2 inline w-16 text-center text-sm"
/>
seconds with no initial interaction.
</p>
</div>
</label>
</div>
)}
<AdvancedOptionToggle
htmlId="autoClose"
isChecked={autoClose}
onToggle={handleCheckMark}
title="Auto close on inactivity"
description="Automatically close the survey if the user does not respond after certain number of seconds"
childBorder={true}>
<label htmlFor="autoCloseSeconds" className="cursor-pointer p-4">
<p className="text-sm font-semibold text-slate-700">
Automatically close survey after
<Input
type="number"
min="1"
id="autoCloseSeconds"
value={localSurvey.autoClose?.toString()}
onChange={(e) => handleInputSeconds(e)}
className="mx-2 inline w-16 bg-white text-center text-sm"
/>
seconds with no initial interaction.
</p>
</label>
</AdvancedOptionToggle>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
<AddNoCodeActionModal

View File

@@ -16,9 +16,10 @@ export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError
return okVoid();
}
const pageUrlEvents: TActionClass[] = state?.noCodeActionClasses.filter(
(e) => 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);
}
);
});
});
};

View File

@@ -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(),

View File

@@ -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 (
<div className="px-4 py-2">
<div className="flex items-center space-x-1">
<Switch id={htmlId} checked={isChecked} onCheckedChange={onToggle} />
<Label htmlFor={htmlId} className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">{title}</h3>
<p className="text-xs font-normal text-slate-500">{description}</p>
</div>
</Label>
</div>
{isChecked && (
<div
className={`mt-4 flex w-full items-center space-x-1 rounded-lg ${
childBorder ? "border" : ""
} bg-slate-50`}>
{children}
</div>
)}
</div>
);
}

View File

@@ -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";