feat: Refactor Triggers and combine Action Classes and Inline Triggers (#2562)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Piyush Gupta
2024-05-07 19:17:41 +05:30
committed by GitHub
parent b016d80cb2
commit 6bfd02794d
64 changed files with 1260 additions and 1381 deletions

View File

@@ -115,7 +115,7 @@ export default function AppPage({}) {
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>
@@ -136,26 +136,6 @@ export default function AppPage({}) {
</p>
</div>
<div className="p-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricks.track("Code Action");
}}>
Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a href="https://formbricks.com/docs/actions/code" className="underline" target="_blank">
Code Action
</a>{" "}
to the Formbricks API called &apos;Code Action&apos;. You will find it in the Actions Tab.
</p>
</div>
</div>
<div className="p-6">
<div>
<button className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">

View File

@@ -115,7 +115,7 @@ export default function AppPage({}) {
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>
@@ -135,60 +135,6 @@ export default function AppPage({}) {
try again.
</p>
</div>
<div className="pt-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricks.track("New Session");
}}>
Track New Session
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends an Action to the Formbricks API called &apos;New Session&apos;. You will
find it in the Actions Tab.
</p>
</div>
</div>
<div className="pt-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricks.track("Exit Intent");
}}>
Track Exit Intent
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends an Action to the Formbricks API called &apos;Exit Intent&apos;. You can also
move your mouse to the top of the browser to trigger the exit intent.
</p>
</div>
</div>
<div className="pt-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricks.track("50% Scroll");
}}>
Track 50% Scroll
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends an Action to the Formbricks API called &apos;50% Scroll&apos;. You can also
scroll down to trigger the 50% scroll.
</p>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ interface ActionDetailModalProps {
open: boolean;
setOpen: (v: boolean) => void;
actionClass: TActionClass;
actionClasses: TActionClass[];
membershipRole?: TMembershipRole;
isUserTargetingEnabled: boolean;
}
@@ -21,6 +22,7 @@ export default function ActionDetailModal({
open,
setOpen,
actionClass,
actionClasses,
membershipRole,
isUserTargetingEnabled,
}: ActionDetailModalProps) {
@@ -41,6 +43,7 @@ export default function ActionDetailModal({
<ActionSettingsTab
environmentId={environmentId}
actionClass={actionClass}
actionClasses={actionClasses}
setOpen={setOpen}
membershipRole={membershipRole}
/>

View File

@@ -4,14 +4,21 @@ import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/actions/actions";
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TActionClassInput, TActionClassNoCodeConfig, TNoCodeConfig } from "@formbricks/types/actionClasses";
import { testURLmatch } from "@formbricks/lib/utils/testUrlMatch";
import {
TActionClass,
TActionClassInput,
TActionClassNoCodeConfig,
TNoCodeConfig,
} from "@formbricks/types/actionClasses";
import { TMembershipRole } from "@formbricks/types/memberships";
import { CssSelector, InnerHtmlSelector, PageUrlSelector } from "@formbricks/ui/Actions";
import { Button } from "@formbricks/ui/Button";
@@ -19,11 +26,10 @@ import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { testURLmatch } from "../lib/testURLmatch";
interface ActionSettingsTabProps {
environmentId: string;
actionClass: any;
actionClass: TActionClass;
actionClasses: TActionClass[];
setOpen: (v: boolean) => void;
membershipRole?: TMembershipRole;
}
@@ -31,6 +37,7 @@ interface ActionSettingsTabProps {
export default function ActionSettingsTab({
environmentId,
actionClass,
actionClasses,
setOpen,
membershipRole,
}: ActionSettingsTabProps) {
@@ -44,11 +51,17 @@ export default function ActionSettingsTab({
const [isUpdatingAction, setIsUpdatingAction] = useState(false);
const [isDeletingAction, setIsDeletingAction] = useState(false);
const { isViewer } = getAccessFlags(membershipRole);
const actionClassNames = useMemo(
() =>
actionClasses.filter((action) => action.id !== actionClass.id).map((actionClass) => actionClass.name),
[actionClass.id, actionClasses]
);
const { register, handleSubmit, control, watch } = useForm({
const { register, handleSubmit, control, watch } = useForm<TActionClass>({
defaultValues: {
name: actionClass.name,
description: actionClass.description,
key: actionClass.key,
noCodeConfig: actionClass.noCodeConfig,
},
});
@@ -73,8 +86,8 @@ export default function ActionSettingsTab({
const handleMatchClick = () => {
const match = testURLmatch(
testUrl,
watch("noCodeConfig.[pageUrl].value"),
watch("noCodeConfig.[pageUrl].rule")
watch("noCodeConfig.pageUrl.value"),
watch("noCodeConfig.pageUrl.rule")
);
setIsMatch(match);
if (match === "yes") toast.success("Your survey would be shown on this URL.");
@@ -83,21 +96,38 @@ export default function ActionSettingsTab({
const onSubmit = async (data) => {
try {
const isCodeAction = actionClass.type === "code";
if (isViewer) {
throw new Error("You are not authorised to perform this action.");
}
setIsUpdatingAction(true);
if (data.name === "") throw new Error("Please give your action a name");
if (!isPageUrl && !isCssSelector && !isInnerHtml && !isCodeAction)
throw new Error("Please select at least one selector");
if (!data.name || data.name?.trim() === "") {
throw new Error("Please give your action a name");
}
if (data.name && actionClassNames.includes(data.name)) {
throw new Error(`Action with name ${data.name} already exist`);
}
if (actionClass.type === "noCode") {
if (!isPageUrl && !isCssSelector && !isInnerHtml)
throw new Error("Please select at least one selector");
if (isCssSelector && !isValidCssSelector(actionClass.noCodeConfig?.cssSelector?.value))
throw new Error("Please enter a valid CSS Selector");
if (isPageUrl && actionClass.noCodeConfig?.pageUrl?.rule === undefined)
throw new Error("Please select a rule for page URL");
}
let filteredNoCodeConfig = data.noCodeConfig;
const isCodeAction = actionClass.type === "code";
if (!isCodeAction) {
filteredNoCodeConfig = filterNoCodeConfig(data.noCodeConfig as TNoCodeConfig);
}
const updatedData: TActionClassInput = {
...data,
environmentId,
noCodeConfig: filteredNoCodeConfig,
type: isCodeAction ? "code" : "noCode",
} as TActionClassInput;
...(isCodeAction ? {} : { noCodeConfig: filteredNoCodeConfig }),
name: data.name.trim(),
};
await updateActionClassAction(environmentId, actionClass.id, updatedData);
setOpen(false);
router.refresh();
@@ -128,13 +158,14 @@ export default function ActionSettingsTab({
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label htmlFor="actionNameSettingsInput">What did your user do?</Label>
<Label htmlFor="actionNameSettingsInput">
{actionClass.type === "noCode" ? "What did your user do?" : "Display name"}
</Label>
<Input
id="actionNameSettingsInput"
placeholder="E.g. Clicked Download"
{...register("name", {
value: actionClass.name,
disabled: actionClass.type === "automatic" || actionClass.type === "code" ? true : false,
disabled: actionClass.type === "automatic" ? true : false,
})}
/>
</div>
@@ -145,12 +176,24 @@ export default function ActionSettingsTab({
id="actionDescriptionSettingsInput"
placeholder="User clicked Download Button "
{...register("description", {
value: actionClass.description,
disabled: actionClass.type === "automatic" ? true : false,
})}
/>
</div>
)}
{actionClass.type === "code" && (
<div className="col-span-1 mt-4">
<Label htmlFor="actionKeySettingsInput">Key</Label>
<Input
id="actionKeySettingsInput"
placeholder="E.g. download_button_clicked"
{...register("key")}
readOnly
disabled
/>
</div>
)}
</div>
{actionClass.type === "code" ? (
<p className="text-sm text-slate-600">

View File

@@ -1,22 +1,10 @@
"use client";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { CreateNewActionTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab";
import { MousePointerClickIcon } from "lucide-react";
import { Terminal } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { CssSelector, InnerHtmlSelector, PageUrlSelector } from "@formbricks/ui/Actions";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { TActionClass } from "@formbricks/types/actionClasses";
import { Modal } from "@formbricks/ui/Modal";
import { TabBar } from "@formbricks/ui/TabBar";
import { testURLmatch } from "../lib/testURLmatch";
interface AddNoCodeActionModalProps {
environmentId: string;
@@ -27,19 +15,6 @@ interface AddNoCodeActionModalProps {
isViewer: boolean;
}
function isValidCssSelector(selector?: string) {
if (!selector || selector.length === 0) {
return false;
}
const element = document.createElement("div");
try {
element.querySelector(selector);
} catch (err) {
return false;
}
return true;
}
export default function AddNoCodeActionModal({
environmentId,
open,
@@ -48,108 +23,8 @@ export default function AddNoCodeActionModal({
setActionClasses,
isViewer,
}: AddNoCodeActionModalProps) {
const { register, control, handleSubmit, watch, reset } = useForm();
const [isPageUrl, setIsPageUrl] = useState(false);
const [isCssSelector, setIsCssSelector] = useState(false);
const [isInnerHtml, setIsInnerText] = useState(false);
const [isCreatingAction, setIsCreatingAction] = useState(false);
const [testUrl, setTestUrl] = useState("");
const [isMatch, setIsMatch] = useState("");
const [type, setType] = useState("noCode");
const actionClassNames = actionClasses.map((actionClass) => actionClass.name);
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
const filteredNoCodeConfig: TActionClassNoCodeConfig = {};
if (isPageUrl && pageUrl?.rule && pageUrl?.value) {
filteredNoCodeConfig.pageUrl = { rule: pageUrl.rule, value: pageUrl.value };
}
if (isInnerHtml && innerHtml?.value) {
filteredNoCodeConfig.innerHtml = { value: innerHtml.value };
}
if (isCssSelector && cssSelector?.value) {
filteredNoCodeConfig.cssSelector = { value: cssSelector.value };
}
return filteredNoCodeConfig;
};
const handleMatchClick = () => {
const match = testURLmatch(
testUrl,
watch("noCodeConfig.[pageUrl].value"),
watch("noCodeConfig.[pageUrl].rule")
);
setIsMatch(match);
if (match === "yes") toast.success("Your survey would be shown on this URL.");
if (match === "no") toast.error("Your survey would not be shown.");
};
const submitEventClass = async (data: Partial<TActionClassInput>): Promise<void> => {
const { noCodeConfig } = data;
try {
if (isViewer) {
throw new Error("You are not authorised to perform this action.");
}
setIsCreatingAction(true);
if (!data.name || data.name?.trim() === "") {
throw new Error("Please give your action a name");
}
if (data.name && actionClassNames.includes(data.name)) {
throw new Error(`Action with name ${data.name} already exist`);
}
if (type === "noCode") {
if (!isPageUrl && !isCssSelector && !isInnerHtml)
throw new Error("Please select at least one selector");
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value)) {
throw new Error("Please enter a valid CSS Selector");
}
if (isPageUrl && noCodeConfig?.pageUrl?.rule === undefined) {
throw new Error("Please select a rule for page URL");
}
}
const updatedAction: TActionClassInput = {
name: data.name,
description: data.description,
environmentId,
type,
} as TActionClassInput;
if (type === "noCode") {
const filteredNoCodeConfig = filterNoCodeConfig(noCodeConfig as TActionClassNoCodeConfig);
updatedAction.noCodeConfig = filteredNoCodeConfig;
}
const newActionClass: TActionClass = await createActionClassAction(updatedAction);
if (setActionClasses) {
setActionClasses((prevActionClasses: TActionClass[]) => [...prevActionClasses, newActionClass]);
}
reset();
resetAllStates(false);
toast.success("Action added successfully.");
} catch (e) {
toast.error(e.message);
} finally {
setIsCreatingAction(false);
}
};
const resetAllStates = (open: boolean) => {
setIsCssSelector(false);
setIsPageUrl(false);
setIsInnerText(false);
setTestUrl("");
setIsMatch("");
reset();
setOpen(open);
};
return (
<Modal open={open} setOpen={() => resetAllStates(false)} noPadding closeOnOutsideClick={false}>
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
@@ -166,121 +41,15 @@ export default function AddNoCodeActionModal({
</div>
</div>
</div>
<TabBar
tabs={[
{ id: "noCode", label: "No Code" },
{ id: "code", label: "Code" },
]}
activeId={type}
setActiveId={setType}
</div>
<div className="px-6 py-4">
<CreateNewActionTab
actionClasses={actionClasses}
environmentId={environmentId}
isViewer={isViewer}
setActionClasses={setActionClasses}
setOpen={setOpen}
/>
{type === "noCode" ? (
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label htmlFor="actionNameInput">What did your user do?</Label>
<Input id="actionNameInput" placeholder="E.g. Clicked Download" {...register("name")} />
</div>
<div className="col-span-1">
<Label htmlFor="actionDescriptionInput">Description</Label>
<Input
id="actionDescriptionInput"
placeholder="User clicked Download Button "
{...register("description")}
/>
</div>
</div>
<div>
<Label>Select By</Label>
</div>
<CssSelector
isCssSelector={isCssSelector}
setIsCssSelector={setIsCssSelector}
register={register}
/>
<PageUrlSelector
isPageUrl={isPageUrl}
setIsPageUrl={setIsPageUrl}
register={register}
control={control}
testUrl={testUrl}
setTestUrl={setTestUrl}
isMatch={isMatch}
setIsMatch={setIsMatch}
handleMatchClick={handleMatchClick}
/>
<InnerHtmlSelector
isInnerHtml={isInnerHtml}
setIsInnerHtml={setIsInnerText}
register={register}
/>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Create Action
</Button>
</div>
</div>
</form>
) : (
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label htmlFor="codeActionNameInput">Identifier</Label>
<Input
id="codeActionNameInput"
placeholder="E.g. clicked-download"
{...register("name", { required: true })}
/>
</div>
<div className="col-span-1">
<Label htmlFor="codeActionDescriptionInput">Description</Label>
<Input
id="codeActionDescriptionInput"
placeholder="User clicked Download Button"
{...register("description")}
/>
</div>
</div>
<hr />
<Alert>
<Terminal className="h-4 w-4" />
<AlertTitle>How do Code Actions work?</AlertTitle>
<AlertDescription>
You can track code action anywhere in your app using{" "}
<span className="rounded bg-slate-100 px-2 py-1 text-xs">
formbricks.track(&quot;{watch("name")}&quot;)
</span>{" "}
in your code. Read more in our{" "}
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
docs
</a>
.
</AlertDescription>
</Alert>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Create Action
</Button>
</div>
</div>
</form>
)}
</div>
</Modal>
);

View File

@@ -1,20 +0,0 @@
export type MatchType = "exactMatch" | "contains" | "startsWith" | "endsWith" | "notMatch" | "notContains";
export function testURLmatch(testUrl: string, pageUrlValue: string, pageUrlRule: MatchType): string {
switch (pageUrlRule) {
case "exactMatch":
return testUrl === pageUrlValue ? "yes" : "no";
case "contains":
return testUrl.includes(pageUrlValue) ? "yes" : "no";
case "startsWith":
return testUrl.startsWith(pageUrlValue) ? "yes" : "no";
case "endsWith":
return testUrl.endsWith(pageUrlValue) ? "yes" : "no";
case "notMatch":
return testUrl !== pageUrlValue ? "yes" : "no";
case "notContains":
return !testUrl.includes(pageUrlValue) ? "yes" : "no";
default:
throw new Error("Invalid match type");
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { CreateNewActionTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab";
import { SavedActionsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SavedActionsTab";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { ModalWithTabs } from "@formbricks/ui/ModalWithTabs";
interface AddActionModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
environmentId: string;
actionClasses: TActionClass[];
setActionClasses: React.Dispatch<React.SetStateAction<TActionClass[]>>;
isViewer: boolean;
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}
export const AddActionModal = ({
open,
setOpen,
actionClasses,
setActionClasses,
localSurvey,
setLocalSurvey,
isViewer,
environmentId,
}: AddActionModalProps) => {
const tabs = [
{
title: "Select saved action",
children: (
<SavedActionsTab
actionClasses={actionClasses}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setOpen={setOpen}
/>
),
},
{
title: "Capture new action",
children: (
<CreateNewActionTab
actionClasses={actionClasses}
setActionClasses={setActionClasses}
setOpen={setOpen}
isViewer={isViewer}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
),
},
];
return (
<ModalWithTabs
label="Add action"
open={open}
setOpen={setOpen}
tabs={tabs}
size="md"
closeOnOutsideClick={false}
/>
);
};

View File

@@ -0,0 +1,297 @@
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
import { Terminal } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { MatchType, testURLmatch } from "@formbricks/lib/utils/testUrlMatch";
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { CssSelector, InnerHtmlSelector, PageUrlSelector } from "@formbricks/ui/Actions";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { TabBar } from "@formbricks/ui/TabBar";
import { createActionClassAction } from "../actions";
interface CreateNewActionTabProps {
actionClasses: TActionClass[];
setActionClasses: React.Dispatch<React.SetStateAction<TActionClass[]>>;
isViewer: boolean;
setLocalSurvey?: React.Dispatch<React.SetStateAction<TSurvey>>;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
environmentId: string;
}
export const CreateNewActionTab = ({
actionClasses,
setActionClasses,
setOpen,
isViewer,
setLocalSurvey,
environmentId,
}: CreateNewActionTabProps) => {
const { register, control, handleSubmit, watch, reset } = useForm<TActionClass>({
defaultValues: {
name: "",
description: "",
type: "noCode",
key: "",
noCodeConfig: {
pageUrl: {
rule: "contains",
value: "",
},
cssSelector: {
value: "",
},
innerHtml: {
value: "",
},
},
},
});
const [type, setType] = useState("noCode");
const [isPageUrl, setIsPageUrl] = useState(false);
const [isCssSelector, setIsCssSelector] = useState(false);
const [isInnerHtml, setIsInnerText] = useState(false);
const [isCreatingAction, setIsCreatingAction] = useState(false);
const [testUrl, setTestUrl] = useState("");
const [isMatch, setIsMatch] = useState("");
const actionClassNames = useMemo(
() => actionClasses.map((actionClass) => actionClass.name),
[actionClasses]
);
const actionClassKeys = useMemo(
() =>
actionClasses
.filter((actionClass) => actionClass.type === "code")
.map((actionClass) => actionClass.key),
[actionClasses]
);
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
const filteredNoCodeConfig: TActionClassNoCodeConfig = {};
if (isPageUrl && pageUrl?.rule && pageUrl?.value) {
filteredNoCodeConfig.pageUrl = { rule: pageUrl.rule, value: pageUrl.value };
}
if (isInnerHtml && innerHtml?.value) {
filteredNoCodeConfig.innerHtml = { value: innerHtml.value };
}
if (isCssSelector && cssSelector?.value) {
filteredNoCodeConfig.cssSelector = { value: cssSelector.value };
}
return filteredNoCodeConfig;
};
const handleMatchClick = () => {
const match = testURLmatch(
testUrl,
watch("noCodeConfig.pageUrl.value"),
watch("noCodeConfig.pageUrl.rule") as MatchType
);
setIsMatch(match);
if (match === "yes") toast.success("Your survey would be shown on this URL.");
if (match === "no") toast.error("Your survey would not be shown.");
};
const submitHandler = async (data: Partial<TActionClass>) => {
const { noCodeConfig } = data;
try {
if (isViewer) {
throw new Error("You are not authorised to perform this action.");
}
setIsCreatingAction(true);
if (!data.name || data.name?.trim() === "") {
throw new Error("Please give your action a name");
}
if (data.name && actionClassNames.includes(data.name)) {
throw new Error(`Action with name ${data.name} already exist`);
}
if (type === "noCode") {
if (!isPageUrl && !isCssSelector && !isInnerHtml)
throw new Error("Please select at least one selector");
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value))
throw new Error("Please enter a valid CSS Selector");
if (isPageUrl && noCodeConfig?.pageUrl?.rule === undefined)
throw new Error("Please select a rule for page URL");
}
if (type === "code" && !data.key) {
throw new Error("Please enter a code key");
}
if (data.key && actionClassKeys.includes(data.key)) {
throw new Error(`Action with key ${data.key} already exist`);
}
const updatedAction: TActionClassInput = {
name: data.name.trim(),
description: data.description,
environmentId,
type: type as TActionClass["type"],
};
if (type === "noCode") {
const filteredNoCodeConfig = filterNoCodeConfig(noCodeConfig as TActionClassNoCodeConfig);
updatedAction.noCodeConfig = filteredNoCodeConfig;
} else {
updatedAction.key = data.key;
}
const newActionClass: TActionClass = await createActionClassAction(updatedAction);
if (setActionClasses) {
setActionClasses((prevActionClasses: TActionClass[]) => [...prevActionClasses, newActionClass]);
}
if (setLocalSurvey) {
setLocalSurvey((prev) => ({
...prev,
triggers: prev.triggers.concat({ actionClass: newActionClass }),
}));
}
reset();
resetAllStates();
} catch (e: any) {
toast.error(e.message);
} finally {
setIsCreatingAction(false);
}
};
const resetAllStates = () => {
setType("noCode");
setIsCssSelector(false);
setIsPageUrl(false);
setIsInnerText(false);
setTestUrl("");
setIsMatch("");
reset();
setOpen(false);
};
return (
<div>
<form onSubmit={handleSubmit(submitHandler)}>
<div className="w-full space-y-4">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label htmlFor="actionNameInput">What did your user do?</Label>
<Input id="actionNameInput" placeholder="E.g. Clicked Download" {...register("name")} />
</div>
<div className="col-span-1">
<Label htmlFor="actionDescriptionInput">Description</Label>
<Input
id="actionDescriptionInput"
placeholder="User clicked Download Button "
{...register("description")}
/>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Label>Type</Label>
<div className="w-3/5">
<TabBar
tabs={[
{
id: "noCode",
label: "No code",
},
{
id: "code",
label: "Code",
},
]}
activeId={type}
setActiveId={setType}
tabStyle="button"
className="rounded-md bg-white"
activeTabClassName="bg-slate-100"
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{type === "code" ? (
<>
<div className="col-span-1">
<Label htmlFor="codeActionKeyInput">Key</Label>
<Input
id="codeActionKeyInput"
placeholder="e.g. download_cta_click_on_home"
{...register("key")}
className="mb-2 w-1/2"
/>
</div>
<Alert className="bg-slate-100">
<Terminal className="h-4 w-4" />
<AlertTitle>How do Code Actions work?</AlertTitle>
<AlertDescription>
You can track code action anywhere in your app using{" "}
<span className="rounded bg-white px-2 py-1 text-xs">
formbricks.track(&quot;{watch("key")}&quot;)
</span>{" "}
in your code. Read more in our{" "}
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
docs
</a>
.
</AlertDescription>
</Alert>
</>
) : (
<>
<div>
<Label>Select By</Label>
</div>
<CssSelector
isCssSelector={isCssSelector}
setIsCssSelector={setIsCssSelector}
register={register}
/>
<PageUrlSelector
isPageUrl={isPageUrl}
setIsPageUrl={setIsPageUrl}
register={register}
control={control}
testUrl={testUrl}
setTestUrl={setTestUrl}
isMatch={isMatch}
setIsMatch={setIsMatch}
handleMatchClick={handleMatchClick}
/>
<InnerHtmlSelector
isInnerHtml={isInnerHtml}
setIsInnerHtml={setIsInnerText}
register={register}
/>
</>
)}
</div>
</div>
<div className="flex justify-end pt-6">
<div className="flex space-x-2">
<Button type="button" variant="minimal" onClick={resetAllStates}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Create action
</Button>
</div>
</div>
</form>
</div>
);
};

View File

@@ -1,400 +0,0 @@
import { MatchType } from "@/app/(app)/environments/[environmentId]/actions/lib/testURLmatch";
import { HelpCircleIcon } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { TSurvey, TSurveyInlineTriggers } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Input } from "@formbricks/ui/Input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
const updateInlineTriggers = (
localSurvey: TSurvey,
update: (inlineTriggers: TSurveyInlineTriggers | null) => Partial<TSurveyInlineTriggers>
): TSurvey => {
return {
...localSurvey,
inlineTriggers: {
...localSurvey.inlineTriggers,
...update(localSurvey.inlineTriggers),
},
};
};
const CodeActionSelector = ({
localSurvey,
setLocalSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isCodeAction, setIsCodeAction] = useState(!!localSurvey.inlineTriggers?.codeConfig?.identifier);
const codeActionIdentifier = localSurvey.inlineTriggers?.codeConfig?.identifier || "";
const onChange = (val: string) => {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => ({
...triggers,
codeConfig: {
identifier: val,
},
}));
setLocalSurvey(updatedSurvey);
};
const onCodeActionToggle = (checked: boolean) => {
setIsCodeAction(!isCodeAction);
// reset the code action state if the user toggles off
if (!checked) {
setLocalSurvey((prevSurvey) => {
const { codeConfig, ...withoutCodeAction } = prevSurvey.inlineTriggers ?? {};
return {
...prevSurvey,
inlineTriggers: {
...withoutCodeAction,
},
};
});
}
};
return (
<div>
<AdvancedOptionToggle
title="Code Action"
description="Trigger this survey on a Code Action"
isChecked={isCodeAction}
onToggle={onCodeActionToggle}
htmlId="codeAction">
<div className="w-full rounded-lg border border-slate-100 p-4">
<Input
type="text"
value={codeActionIdentifier || ""}
onChange={(e) => onChange(e.target.value)}
className="bg-white"
placeholder="Identifier e.g. clicked-download"
id="codeActionIdentifierInput"
/>
</div>
</AdvancedOptionToggle>
</div>
);
};
const CssSelector = ({
setLocalSurvey,
localSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isCssSelector, setIsCssSelector] = useState(
!!localSurvey.inlineTriggers?.noCodeConfig?.cssSelector?.value
);
const cssSelectorValue = localSurvey.inlineTriggers?.noCodeConfig?.cssSelector?.value || "";
const onChange = (val: string) => {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => ({
...triggers,
noCodeConfig: {
...triggers?.noCodeConfig,
cssSelector: {
value: val,
},
},
}));
setLocalSurvey(updatedSurvey);
};
const onCssSelectorToggle = (checked: boolean) => {
setIsCssSelector(!isCssSelector);
// reset the css selector state if the user toggles off
if (!checked) {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => {
const { noCodeConfig } = triggers ?? {};
const { cssSelector, ...withoutCssSelector } = noCodeConfig ?? {};
return {
...triggers,
noCodeConfig: {
...withoutCssSelector,
},
};
});
setLocalSurvey(updatedSurvey);
}
};
return (
<div>
<AdvancedOptionToggle
htmlId="cssSelectorToggle"
isChecked={isCssSelector}
onToggle={onCssSelectorToggle}
customContainerClass="p-0"
title="CSS Selector"
description="If a user clicks a button with a specific CSS class or id"
childBorder={true}>
<div className="w-full rounded-lg">
<Input
type="text"
value={cssSelectorValue}
onChange={(e) => onChange(e.target.value)}
className="bg-white"
placeholder="Add .css-class or #css-id"
id="cssSelectorInput"
/>
</div>
</AdvancedOptionToggle>
</div>
);
};
const PageUrlSelector = ({
localSurvey,
setLocalSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isPageUrl, setIsPageUrl] = useState(!!localSurvey.inlineTriggers?.noCodeConfig?.pageUrl?.value);
const matchValue = localSurvey.inlineTriggers?.noCodeConfig?.pageUrl?.rule || "exactMatch";
const pageUrlValue = localSurvey.inlineTriggers?.noCodeConfig?.pageUrl?.value || "";
const updatePageUrlState = (match: MatchType, pageUrl: string): TSurvey =>
updateInlineTriggers(localSurvey, (triggers) => ({
...triggers,
noCodeConfig: {
...triggers?.noCodeConfig,
pageUrl: {
rule: match,
value: pageUrl,
},
},
}));
const onMatchChange = (match: MatchType) => {
const updatedSurvey = updatePageUrlState(match, pageUrlValue);
setLocalSurvey(updatedSurvey);
};
const onPageUrlChange = (pageUrl: string) => {
const updatedSurvey = updatePageUrlState(matchValue, pageUrl);
setLocalSurvey(updatedSurvey);
};
const onPageUrlToggle = (checked: boolean) => {
setIsPageUrl(!isPageUrl);
// reset the page url state if the user toggles off
if (!checked) {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => {
const { noCodeConfig } = triggers ?? {};
const { pageUrl, ...withoutPageUrl } = noCodeConfig ?? {};
return {
...triggers,
noCodeConfig: {
...withoutPageUrl,
},
};
});
setLocalSurvey(updatedSurvey);
}
};
return (
<div>
<AdvancedOptionToggle
htmlId="pageURLToggle"
isChecked={isPageUrl}
onToggle={onPageUrlToggle}
title="Page URL"
customContainerClass="p-0"
description="If a user visits a specific URL"
childBorder={false}>
<div className="flex w-full gap-2">
<div>
<Select
onValueChange={onMatchChange}
defaultValue={localSurvey.inlineTriggers?.noCodeConfig?.pageUrl?.rule || "exactMatch"}>
<SelectTrigger className="w-[160px] bg-white">
<SelectValue placeholder="Select match type" />
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem value="exactMatch">Exactly matches</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="startsWith">Starts with</SelectItem>
<SelectItem value="endsWith">Ends with</SelectItem>
<SelectItem value="notMatch">Does not exactly match</SelectItem>
<SelectItem value="notContains">Does not contain</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full">
<Input
type="text"
value={pageUrlValue}
onChange={(e) => onPageUrlChange(e.target.value)}
className="bg-white"
placeholder="e.g. https://app.com/dashboard"
id="pageURLInput"
/>
</div>
</div>
</AdvancedOptionToggle>
</div>
);
};
const InnerHtmlSelector = ({
localSurvey,
setLocalSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isInnerHtml, setIsInnerHtml] = useState(
!!localSurvey.inlineTriggers?.noCodeConfig?.innerHtml?.value
);
const innerHtmlValue = localSurvey.inlineTriggers?.noCodeConfig?.innerHtml?.value || "";
const onChange = (val: string) => {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => ({
...triggers,
noCodeConfig: {
...triggers?.noCodeConfig,
innerHtml: {
value: val,
},
},
}));
setLocalSurvey(updatedSurvey);
};
const onInnerHtmlToggle = (checked: boolean) => {
setIsInnerHtml(!isInnerHtml);
// reset the inner html state if the user toggles off
if (!checked) {
const updatedSurvey = updateInlineTriggers(localSurvey, (triggers) => {
const { noCodeConfig } = triggers ?? {};
const { innerHtml, ...withoutInnerHtml } = noCodeConfig ?? {};
return {
...triggers,
noCodeConfig: {
...withoutInnerHtml,
},
};
});
setLocalSurvey(updatedSurvey);
}
};
return (
<div>
<AdvancedOptionToggle
htmlId="innerHTMLToggle"
isChecked={isInnerHtml}
onToggle={onInnerHtmlToggle}
customContainerClass="p-0"
title="Inner Text"
description="If a user clicks a button with a specific text"
childBorder={true}>
<div className="w-full">
<div className="grid grid-cols-3 gap-x-8">
<div className="col-span-3 flex items-end">
<Input
type="text"
value={innerHtmlValue}
onChange={(e) => onChange(e.target.value)}
className="bg-white"
placeholder="e.g. 'Install App'"
id="innerHTMLInput"
/>
</div>
</div>
</div>
</AdvancedOptionToggle>
</div>
);
};
const InlineTriggers = ({
localSurvey,
setLocalSurvey,
}: {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}) => {
const [isNoCodeAction, setIsNoCodeAction] = useState(!!localSurvey.inlineTriggers?.noCodeConfig);
const onNoCodeActionToggle = useCallback(
(checked: boolean) => {
setIsNoCodeAction(checked);
if (!checked) {
setLocalSurvey((prevSurvey) => {
const { noCodeConfig, ...withoutNoCodeConfig } = prevSurvey.inlineTriggers ?? {};
return {
...prevSurvey,
inlineTriggers: {
...withoutNoCodeConfig,
},
};
});
}
},
[setLocalSurvey]
);
// inside the no code config, if no selector is present, then the no code action is not present
useEffect(() => {
const noCodeConfig = localSurvey.inlineTriggers?.noCodeConfig ?? {};
if (Object.keys(noCodeConfig).length === 0) {
setLocalSurvey((prevSurvey) => {
const { noCodeConfig, ...withoutNoCodeConfig } = prevSurvey.inlineTriggers ?? {};
return {
...prevSurvey,
inlineTriggers: {
...withoutNoCodeConfig,
},
};
});
}
}, [localSurvey.inlineTriggers?.noCodeConfig, setLocalSurvey]);
return (
<div className="flex flex-col gap-4">
<div className="mx-4 mt-2 flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-slate-700">
<HelpCircleIcon className="h-3 w-3" />
<span className="text-xs">Custom Actions can only be used in this survey. They are not saved.</span>
</div>
<CodeActionSelector localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
<AdvancedOptionToggle
title="No Code Action"
description="Trigger this survey on a No Code Action"
htmlId="noCodeAction"
isChecked={isNoCodeAction}
onToggle={onNoCodeActionToggle}
childBorder={false}>
<div className="flex w-full flex-col gap-8 rounded-lg border border-slate-200 bg-slate-50 p-6">
<CssSelector localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
<PageUrlSelector localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
<InnerHtmlSelector localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
</div>
</AdvancedOptionToggle>
</div>
);
};
export default InlineTriggers;

View File

@@ -0,0 +1,90 @@
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
import { useState } from "react";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { Input } from "@formbricks/ui/Input";
interface SavedActionsTabProps {
actionClasses: TActionClass[];
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const SavedActionsTab = ({
actionClasses,
localSurvey,
setLocalSurvey,
setOpen,
}: SavedActionsTabProps) => {
const availableActions = actionClasses.filter(
(actionClass) => !localSurvey.triggers.some((trigger) => trigger.actionClass.id === actionClass.id)
);
const [filteredActionClasses, setFilteredActionClasses] = useState<TActionClass[]>(availableActions);
const codeActions = filteredActionClasses.filter((actionClass) => actionClass.type === "code");
const noCodeActions = filteredActionClasses.filter((actionClass) => actionClass.type === "noCode");
const automaticActions = filteredActionClasses.filter((actionClass) => actionClass.type === "automatic");
const handleActionClick = (action: TActionClass) => {
setLocalSurvey((prev) => ({
...prev,
triggers: prev.triggers.concat({ actionClass: action }),
}));
setOpen(false);
};
return (
<div>
<Input
type="text"
onChange={(e) => {
setFilteredActionClasses(
availableActions.filter((actionClass) =>
actionClass.name.toLowerCase().includes(e.target.value.toLowerCase())
)
);
}}
className="mb-2 bg-white"
placeholder="Search actions"
id="search-actions"
/>
<div className="max-h-96 overflow-y-auto">
{[automaticActions, noCodeActions, codeActions].map(
(actions, i) =>
actions.length > 0 && (
<div key={i} className="me-4">
<h2 className="mb-2 mt-4 font-semibold">
{i === 0 ? "Automatic" : i === 1 ? "No code" : "Code"}
</h2>
<div className="flex flex-col gap-2">
{actions.map((action) => (
<div
key={action.id}
className="cursor-pointer rounded-md border border-slate-300 bg-white px-4 py-2 hover:bg-slate-100"
onClick={() => handleActionClick(action)}>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">
{action.type === "code" ? (
<Code2Icon className="h-4 w-4" />
) : action.type === "noCode" ? (
<MousePointerClickIcon className="h-4 w-4" />
) : action.type === "automatic" ? (
<SparklesIcon className="h-4 w-4" />
) : null}
</div>
<h4 className="text-sm font-semibold text-slate-600">{action.name}</h4>
</div>
<p className="mt-1 text-xs text-gray-500">{action.description}</p>
</div>
))}
</div>
</div>
)
)}
</div>
</div>
);
};

View File

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

View File

@@ -1,10 +1,15 @@
"use client";
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
import InlineTriggers from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/InlineTriggers";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CheckIcon,
Code2Icon,
MousePointerClickIcon,
PlusIcon,
SparklesIcon,
Trash2Icon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TActionClass } from "@formbricks/types/actionClasses";
@@ -13,15 +18,8 @@ import { TSurvey } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@formbricks/ui/Select";
import { TabBar } from "@formbricks/ui/TabBar";
import { AddActionModal } from "./AddActionModal";
interface WhenToSendCardProps {
localSurvey: TSurvey;
@@ -41,52 +39,16 @@ export default function WhenToSendCard({
const [open, setOpen] = useState(
localSurvey.type === "app" || localSurvey.type === "website" ? true : false
);
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [isAddActionModalOpen, setAddActionModalOpen] = useState(false);
const [actionClasses, setActionClasses] = useState<TActionClass[]>(propActionClasses);
const [randomizerToggle, setRandomizerToggle] = useState(localSurvey.displayPercentage ? true : false);
const [activeTriggerTab, setActiveTriggerTab] = useState(
!!localSurvey?.inlineTriggers ? "inline" : "relation"
);
const tabs = [
{
id: "relation",
label: "Saved Actions",
},
{
id: "inline",
label: "Custom Actions",
},
];
const { isViewer } = getAccessFlags(membershipRole);
const autoClose = localSurvey.autoClose !== null;
const delay = localSurvey.delay !== 0;
const addTriggerEvent = useCallback(() => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers = [...localSurvey.triggers, ""];
setLocalSurvey(updatedSurvey);
}, [localSurvey, setLocalSurvey]);
const setTriggerEvent = useCallback(
(idx: number, actionClassName: string) => {
const updatedSurvey = { ...localSurvey };
const newActionClass = actionClasses!.find((actionClass) => {
return actionClass.name === actionClassName;
});
if (!newActionClass) {
throw new Error("Action class not found");
}
updatedSurvey.triggers[idx] = newActionClass.name;
setLocalSurvey(updatedSurvey);
},
[actionClasses, localSurvey, setLocalSurvey]
);
const removeTriggerEvent = (idx: number) => {
const handleRemoveTriggerEvent = (idx: number) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers = [...localSurvey.triggers.slice(0, idx), ...localSurvey.triggers.slice(idx + 1)];
setLocalSurvey(updatedSurvey);
@@ -143,60 +105,16 @@ export default function WhenToSendCard({
setLocalSurvey(updatedSurvey);
};
useEffect(() => {
if (isAddEventModalOpen) return;
if (activeIndex !== null) {
const newActionClass = actionClasses[actionClasses.length - 1].name;
const currentActionClass = localSurvey.triggers[activeIndex];
if (newActionClass !== currentActionClass) {
setTriggerEvent(activeIndex, newActionClass);
}
setActiveIndex(null);
}
}, [actionClasses, activeIndex, setTriggerEvent, isAddEventModalOpen, localSurvey.triggers]);
useEffect(() => {
if (localSurvey.type === "link") {
setOpen(false);
}
}, [localSurvey.type]);
//create new empty trigger on page load, remove one click for user
useEffect(() => {
if (localSurvey.triggers.length === 0) {
addTriggerEvent();
}
}, [addTriggerEvent, localSurvey.triggers.length]);
const containsEmptyTriggers = useMemo(() => {
const noTriggers = !localSurvey.triggers || !localSurvey.triggers.length || !localSurvey.triggers[0];
const noInlineTriggers =
!localSurvey.inlineTriggers ||
(!localSurvey.inlineTriggers?.codeConfig && !localSurvey.inlineTriggers?.noCodeConfig);
if (noTriggers && noInlineTriggers) {
return true;
}
return false;
return !localSurvey.triggers || !localSurvey.triggers.length || !localSurvey.triggers[0];
}, [localSurvey]);
// for inline triggers, if both the codeConfig and noCodeConfig are empty, we consider it as empty
useEffect(() => {
const inlineTriggers = localSurvey?.inlineTriggers ?? {};
if (Object.keys(inlineTriggers).length === 0) {
setLocalSurvey((prevSurvey) => {
return {
...prevSurvey,
inlineTriggers: null,
};
});
}
}, [localSurvey?.inlineTriggers, setLocalSurvey]);
if (localSurvey.type === "link") {
return null; // Hide card completely
}
@@ -237,77 +155,85 @@ export default function WhenToSendCard({
<hr className="py-1 text-slate-600" />
<div className="px-3 pb-3 pt-1">
<div className="flex flex-col overflow-hidden rounded-lg border-2 border-slate-100">
<TabBar
tabs={tabs}
activeId={activeTriggerTab}
setActiveId={setActiveTriggerTab}
tabStyle="button"
className="bg-slate-100"
/>
<div className="p-3">
{activeTriggerTab === "inline" ? (
<div className="flex flex-col">
<InlineTriggers localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
</div>
) : (
<>
{!isAddEventModalOpen &&
localSurvey.triggers?.map((triggerEventClass, idx) => (
<div className="mt-2" key={idx}>
<div className="inline-flex items-center">
<p className="mr-2 w-14 text-right text-sm">{idx === 0 ? "When" : "or"}</p>
<Select
value={triggerEventClass}
onValueChange={(actionClassName) => setTriggerEvent(idx, actionClassName)}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<button
type="button"
className="flex w-full items-center space-x-2 rounded-md p-1 text-sm font-semibold text-slate-800 hover:bg-slate-100 hover:text-slate-500"
value="none"
onClick={() => {
setAddEventModalOpen(true);
setActiveIndex(idx);
}}>
<PlusIcon className="mr-1 h-5 w-5" />
Add Action
</button>
<SelectSeparator />
{actionClasses.map((actionClass) => (
<SelectItem
value={actionClass.name}
key={actionClass.name}
title={actionClass.description ? actionClass.description : ""}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mx-2 text-sm">action is performed</p>
<button type="button" onClick={() => removeTriggerEvent(idx)}>
<TrashIcon className="ml-3 h-4 w-4 text-slate-400" />
</button>
<div className="filter-scrollbar flex flex-col gap-4 overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-4">
<p className="text-sm font-semibold text-slate-800">
Trigger survey when one of the actions is fired...
</p>
{localSurvey.triggers.filter(Boolean).map((trigger, idx) => {
return (
<div className="flex items-center gap-2" key={trigger.actionClass.id}>
{idx !== 0 && <p className="ml-1 text-sm font-bold text-slate-700">or</p>}
<div
key={trigger.actionClass.id}
className="flex grow items-center justify-between rounded-md border border-slate-300 bg-white p-2 px-3">
<div>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">
{trigger.actionClass.type === "code" ? (
<Code2Icon className="h-4 w-4" />
) : trigger.actionClass.type === "noCode" ? (
<MousePointerClickIcon className="h-4 w-4" />
) : trigger.actionClass.type === "automatic" ? (
<SparklesIcon className="h-4 w-4" />
) : null}
</div>
<h4 className="text-sm font-semibold text-slate-600">{trigger.actionClass.name}</h4>
</div>
))}
<div className="px-6 py-4">
<Button
variant="secondary"
onClick={() => {
addTriggerEvent();
}}>
<PlusIcon className="mr-2 h-4 w-4" />
Add condition
</Button>
<div className="mt-1 text-xs text-gray-500">
{trigger.actionClass.description && (
<span className="mr-1">{trigger.actionClass.description}</span>
)}
{trigger.actionClass.type === "code" && (
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
Key: <b>{trigger.actionClass.key}</b>
</span>
)}
{trigger.actionClass.type === "noCode" &&
trigger.actionClass.noCodeConfig?.cssSelector && (
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
CSS Selector: <b>{trigger.actionClass.noCodeConfig.cssSelector.value}</b>
</span>
)}
{trigger.actionClass.type === "noCode" &&
trigger.actionClass.noCodeConfig?.innerHtml && (
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
Inner Text: <b>{trigger.actionClass.noCodeConfig.innerHtml.value}</b>
</span>
)}
{trigger.actionClass.type === "noCode" &&
trigger.actionClass.noCodeConfig?.pageUrl && (
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
URL {trigger.actionClass.noCodeConfig.pageUrl.rule}:{" "}
<b>{trigger.actionClass.noCodeConfig.pageUrl.value}</b>
</span>
)}
</div>
</div>
</div>
</>
)}
<Trash2Icon
className="h-4 w-4 cursor-pointer text-slate-600"
onClick={() => handleRemoveTriggerEvent(idx)}
/>
</div>
);
})}
<div>
<Button
variant="secondary"
size="sm"
onClick={() => {
setAddActionModalOpen(true);
}}>
<PlusIcon className="mr-2 h-4 w-4" />
Add action
</Button>
</div>
</div>
{/* Survey Display Settings */}
<div className="mb-4 mt-8 space-y-1 px-4">
<h3 className="font-semibold text-slate-800">Survey Display Settings</h3>
<p className="text-sm text-slate-500">Add a delay or auto-close the survey</p>
@@ -387,13 +313,15 @@ export default function WhenToSendCard({
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
<AddNoCodeActionModal
<AddActionModal
environmentId={environmentId}
open={isAddEventModalOpen}
setOpen={setAddEventModalOpen}
open={isAddActionModalOpen}
setOpen={setAddActionModalOpen}
actionClasses={actionClasses}
setActionClasses={setActionClasses}
isViewer={isViewer}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
/>
</>
);

View File

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

View File

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

View File

@@ -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: {} };
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
export const isValidCssSelector = (selector?: string) => {
if (!selector || selector.length === 0) {
return false;
}
const element = document.createElement("div");
try {
element.querySelector(selector);
} catch (err) {
return false;
}
return true;
};

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "1.7.0",
"version": "2.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",

View File

@@ -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);
});

View File

@@ -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 =

View File

@@ -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]+$/);

View File

@@ -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: {