mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-02 11:30:31 -05:00
feat: on the fly triggers (#2110)
Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -51,3 +51,6 @@ Zone.Identifier
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# uploads
|
||||
packages/lib/uploads
|
||||
|
||||
+1
-3
@@ -4,9 +4,6 @@ import {
|
||||
deleteActionClassAction,
|
||||
updateActionClassAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
|
||||
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
|
||||
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
|
||||
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -16,6 +13,7 @@ import { toast } from "react-hot-toast";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { 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";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
+1
-3
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
|
||||
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
|
||||
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
|
||||
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
|
||||
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -11,6 +8,7 @@ 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";
|
||||
|
||||
+6
-2
@@ -82,7 +82,10 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
|
||||
open ? "" : "hover:bg-slate-50",
|
||||
"w-full space-y-2 rounded-lg border border-slate-300 bg-white "
|
||||
)}>
|
||||
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="h-full w-full cursor-pointer"
|
||||
id="howToSendCardTrigger">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckCircleIcon className="h-8 w-8 text-green-400" />
|
||||
@@ -112,7 +115,8 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
|
||||
: option.id === localSurvey.type
|
||||
? "border-brand-dark cursor-pointer bg-slate-50"
|
||||
: "cursor-pointer bg-slate-50"
|
||||
)}>
|
||||
)}
|
||||
id={`howToSendCardOption-${option.id}`}>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={option.id}
|
||||
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
import { MatchType } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/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;
|
||||
+40
-11
@@ -4,14 +4,19 @@ import SurveyStatusDropdown from "@/app/(app)/environments/[environmentId]/surve
|
||||
import { ArrowLeftIcon, Cog8ToothIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { isEqual } from "lodash";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyQuestionType,
|
||||
ZSurveyInlineTriggers,
|
||||
surveyHasBothTriggers,
|
||||
} from "@formbricks/types/surveys";
|
||||
import AlertDialog from "@formbricks/ui/AlertDialog";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
@@ -73,18 +78,25 @@ export default function SurveyMenuBar({
|
||||
};
|
||||
}, [localSurvey, survey]);
|
||||
|
||||
const containsEmptyTriggers = useCallback(() => {
|
||||
return (
|
||||
localSurvey.type === "web" &&
|
||||
localSurvey.triggers &&
|
||||
(localSurvey.triggers[0] === "" || localSurvey.triggers.length === 0)
|
||||
);
|
||||
}, [localSurvey.triggers, localSurvey.type]);
|
||||
const containsEmptyTriggers = useMemo(() => {
|
||||
if (localSurvey.type !== "web") 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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [localSurvey]);
|
||||
|
||||
const disableSave = useMemo(() => {
|
||||
if (isSurveySaving) return true;
|
||||
|
||||
if (localSurvey.status !== "draft" && containsEmptyTriggers()) return true;
|
||||
if (localSurvey.status !== "draft" && containsEmptyTriggers) return true;
|
||||
}, [containsEmptyTriggers, isSurveySaving, localSurvey.status]);
|
||||
|
||||
// write a function which updates the local survey status
|
||||
@@ -240,6 +252,7 @@ export default function SurveyMenuBar({
|
||||
return rest;
|
||||
}),
|
||||
};
|
||||
|
||||
if (!validateSurvey(localSurvey)) {
|
||||
setIsSurveySaving(false);
|
||||
return;
|
||||
@@ -279,6 +292,22 @@ export default function SurveyMenuBar({
|
||||
}
|
||||
}
|
||||
|
||||
// if inlineTriggers are present validate with zod
|
||||
if (!!strippedSurvey.inlineTriggers) {
|
||||
const parsedInlineTriggers = ZSurveyInlineTriggers.safeParse(strippedSurvey.inlineTriggers);
|
||||
if (!parsedInlineTriggers.success) {
|
||||
toast.error("Invalid Custom Actions: Please check your custom actions");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// validate that both triggers and inlineTriggers are not present
|
||||
if (surveyHasBothTriggers(strippedSurvey)) {
|
||||
setIsSurveySaving(false);
|
||||
toast.error("Survey cannot have both custom and saved actions, please remove one.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSurveyAction({ ...strippedSurvey });
|
||||
setIsSurveySaving(false);
|
||||
@@ -391,7 +420,7 @@ export default function SurveyMenuBar({
|
||||
)}
|
||||
{localSurvey.status === "draft" && !audiencePrompt && (
|
||||
<Button
|
||||
disabled={isSurveySaving || containsEmptyTriggers()}
|
||||
disabled={isSurveySaving || containsEmptyTriggers}
|
||||
variant="darkCTA"
|
||||
loading={isSurveyPublishing}
|
||||
onClick={handleSurveyPublish}>
|
||||
|
||||
+120
-54
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddActionModal";
|
||||
import InlineTriggers from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/InlineTriggers";
|
||||
import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
@@ -20,10 +21,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@formbricks/ui/Select";
|
||||
import { TabBar } from "@formbricks/ui/TabBar";
|
||||
|
||||
interface WhenToSendCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
environmentId: string;
|
||||
propActionClasses: TActionClass[];
|
||||
membershipRole?: TMembershipRole;
|
||||
@@ -41,6 +43,21 @@ export default function WhenToSendCard({
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
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;
|
||||
@@ -152,6 +169,32 @@ export default function WhenToSendCard({
|
||||
}
|
||||
}, [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;
|
||||
}, [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
|
||||
}
|
||||
@@ -171,7 +214,7 @@ export default function WhenToSendCard({
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
{!localSurvey.triggers || localSurvey.triggers.length === 0 || !localSurvey.triggers[0] ? (
|
||||
{containsEmptyTriggers ? (
|
||||
<div className="h-8 w-8 rounded-full border border-amber-500 bg-amber-50" />
|
||||
) : (
|
||||
<CheckCircleIcon className="h-8 w-8 text-green-400" />
|
||||
@@ -187,60 +230,83 @@ export default function WhenToSendCard({
|
||||
|
||||
<Collapsible.CollapsibleContent>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
<div className="p-3">
|
||||
{!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="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>
|
||||
</div>
|
||||
))}
|
||||
<div className="px-6 py-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
addTriggerEvent();
|
||||
}}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add condition
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{!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>
|
||||
</div>
|
||||
))}
|
||||
<div className="px-6 py-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
addTriggerEvent();
|
||||
}}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add condition
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex items-center space-x-1 px-4 pb-4"></div>
|
||||
<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>
|
||||
</div>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="delay"
|
||||
isChecked={delay}
|
||||
|
||||
@@ -2534,6 +2534,7 @@ export const minimalSurvey: TSurvey = {
|
||||
recontactDays: null,
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [],
|
||||
inlineTriggers: null,
|
||||
thankYouCard: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function Testimonial() {
|
||||
<div className="3xl:w-2/3 mb-10 space-y-8 px-12 xl:px-20 ">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-slate-800">
|
||||
Versatile in-app surveys. Valuable user insights.
|
||||
Turn customer insights into irresistible experiences.
|
||||
</h2>
|
||||
</div>
|
||||
{/* <p className="text-slate-600">
|
||||
|
||||
@@ -1,142 +1,15 @@
|
||||
import { surveys, users } from "@/playwright/utils/mock";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { signUpAndLogin, skipOnboarding } from "./utils/helper";
|
||||
import { createSurvey } from "./utils/helper";
|
||||
|
||||
test.describe("Survey Create & Submit Response", async () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
let url: string | null;
|
||||
const { name, email, password } = users.survey[0];
|
||||
let addQuestion = "Add QuestionAdd a new question to your survey";
|
||||
|
||||
test("Create Survey", async ({ page }) => {
|
||||
await signUpAndLogin(page, name, email, password);
|
||||
await skipOnboarding(page);
|
||||
|
||||
await page.getByRole("heading", { name: "Start from Scratch" }).click();
|
||||
|
||||
// Welcome Card
|
||||
await expect(page.locator("#welcome-toggle")).toBeVisible();
|
||||
await page.getByText("Welcome Card").click();
|
||||
await page.locator("#welcome-toggle").check();
|
||||
await page.getByLabel("Headline").fill(surveys.createAndSubmit.welcomeCard.headline);
|
||||
await page
|
||||
.locator("form")
|
||||
.getByText("Thanks for providing your")
|
||||
.fill(surveys.createAndSubmit.welcomeCard.description);
|
||||
await page.getByText("Welcome CardEnabled").click();
|
||||
|
||||
// Open Text Question
|
||||
await page.getByRole("button", { name: "1 What would you like to know" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.openTextQuestion.question);
|
||||
await page.getByLabel("Description").fill(surveys.createAndSubmit.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(surveys.createAndSubmit.openTextQuestion.placeholder);
|
||||
await page.getByRole("button", { name: surveys.createAndSubmit.openTextQuestion.question }).click();
|
||||
|
||||
// Single Select Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Single-Select" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.singleSelectQuestion.question);
|
||||
await page.getByLabel("Description").fill(surveys.createAndSubmit.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.singleSelectQuestion.options[1]);
|
||||
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
||||
|
||||
// Multi Select Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Multi-Select" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.multiSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(surveys.createAndSubmit.multiSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
|
||||
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.multiSelectQuestion.options[2]);
|
||||
|
||||
// Rating Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.ratingQuestion.question);
|
||||
await page.getByLabel("Description").fill(surveys.createAndSubmit.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").fill(surveys.createAndSubmit.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").fill(surveys.createAndSubmit.ratingQuestion.highLabel);
|
||||
|
||||
// NPS Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.npsQuestion.question);
|
||||
await page.getByLabel("Lower label").fill(surveys.createAndSubmit.npsQuestion.lowLabel);
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^Upper label$/ })
|
||||
.locator("#subheader")
|
||||
.fill(surveys.createAndSubmit.npsQuestion.highLabel);
|
||||
|
||||
// CTA Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Call-to-Action" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.ctaQuestion.question);
|
||||
await page.getByPlaceholder("Finish").fill(surveys.createAndSubmit.ctaQuestion.buttonLabel);
|
||||
|
||||
// Consent Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Consent" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.consentQuestion.question);
|
||||
await page
|
||||
.getByPlaceholder("I agree to the terms and")
|
||||
.fill(surveys.createAndSubmit.consentQuestion.checkboxLabel);
|
||||
|
||||
// Picture Select Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.pictureSelectQuestion.question);
|
||||
await page.getByLabel("Description").fill(surveys.createAndSubmit.pictureSelectQuestion.description);
|
||||
|
||||
// File Upload Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "File Upload" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.fileUploadQuestion.question);
|
||||
|
||||
// Thank You Card
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^Thank You CardShown$/ })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.thankYouCard.headline);
|
||||
await page.getByLabel("Description").fill(surveys.createAndSubmit.thankYouCard.description);
|
||||
|
||||
await createSurvey(page, name, email, password, surveys.createAndSubmit);
|
||||
// Save & Publish Survey
|
||||
await page.getByRole("button", { name: "Continue to Settings" }).click();
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
@@ -149,6 +22,52 @@ test.describe("Survey Create & Submit Response", async () => {
|
||||
.innerText();
|
||||
});
|
||||
|
||||
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 expect(page.locator("#howToSendCardTrigger")).toBeVisible();
|
||||
await page.locator("#howToSendCardTrigger").click();
|
||||
|
||||
await expect(page.locator("#howToSendCardOption-web")).toBeVisible();
|
||||
await page.locator("#howToSendCardOption-web").click();
|
||||
|
||||
await expect(page.getByText("Survey Trigger")).toBeVisible();
|
||||
await page.getByText("Survey Trigger").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]+$/);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CreateSurveyParams } from "@/playwright/utils/mock";
|
||||
import { expect } from "@playwright/test";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { Page } from "playwright";
|
||||
@@ -75,3 +76,135 @@ export const signupUsingInviteToken = async (page: Page, name: string, email: st
|
||||
await page.getByPlaceholder("*******").fill(password);
|
||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
||||
};
|
||||
|
||||
export const createSurvey = async (
|
||||
page: Page,
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
params: CreateSurveyParams
|
||||
) => {
|
||||
const addQuestion = "Add QuestionAdd a new question to your survey";
|
||||
|
||||
await signUpAndLogin(page, name, email, password);
|
||||
await skipOnboarding(page);
|
||||
|
||||
await page.getByRole("heading", { name: "Start from Scratch" }).click();
|
||||
|
||||
// Welcome Card
|
||||
await expect(page.locator("#welcome-toggle")).toBeVisible();
|
||||
await page.getByText("Welcome Card").click();
|
||||
await page.locator("#welcome-toggle").check();
|
||||
await page.getByLabel("Headline").fill(params.welcomeCard.headline);
|
||||
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
|
||||
await page.getByText("Welcome CardEnabled").click();
|
||||
|
||||
// Open Text Question
|
||||
await page.getByRole("button", { name: "1 What would you like to know" }).click();
|
||||
await page.getByLabel("Question").fill(params.openTextQuestion.question);
|
||||
await page.getByLabel("Description").fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
await page.getByRole("button", { name: params.openTextQuestion.question }).click();
|
||||
|
||||
// Single Select Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Single-Select" }).click();
|
||||
await page.getByLabel("Question").fill(params.singleSelectQuestion.question);
|
||||
await page.getByLabel("Description").fill(params.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
||||
|
||||
// Multi Select Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Multi-Select" }).click();
|
||||
await page.getByLabel("Question").fill(params.multiSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add Description", exact: true }).click();
|
||||
await page.getByLabel("Description").fill(params.multiSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.multiSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
|
||||
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
|
||||
|
||||
// Rating Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await page.getByLabel("Question").fill(params.ratingQuestion.question);
|
||||
await page.getByLabel("Description").fill(params.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
||||
|
||||
// NPS Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
|
||||
await page.getByLabel("Question").fill(params.npsQuestion.question);
|
||||
await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel);
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^Upper label$/ })
|
||||
.locator("#subheader")
|
||||
.fill(params.npsQuestion.highLabel);
|
||||
|
||||
// CTA Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Call-to-Action" }).click();
|
||||
await page.getByLabel("Question").fill(params.ctaQuestion.question);
|
||||
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
|
||||
|
||||
// Consent Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Consent" }).click();
|
||||
await page.getByLabel("Question").fill(params.consentQuestion.question);
|
||||
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
|
||||
|
||||
// Picture Select Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||
await page.getByLabel("Question").fill(params.pictureSelectQuestion.question);
|
||||
await page.getByLabel("Description").fill(params.pictureSelectQuestion.description);
|
||||
|
||||
// File Upload Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "File Upload" }).click();
|
||||
await page.getByLabel("Question").fill(params.fileUploadQuestion.question);
|
||||
|
||||
// Thank You Card
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^Thank You CardShown$/ })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByLabel("Question").fill(params.thankYouCard.headline);
|
||||
await page.getByLabel("Description").fill(params.thankYouCard.description);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,11 @@ export const users = {
|
||||
email: "survey1@formbricks.com",
|
||||
password: "Y1I*EpURUSb32j5XijP",
|
||||
},
|
||||
{
|
||||
name: "Survey User 2",
|
||||
email: "survey2@formbricks.com",
|
||||
password: "Y1I*EpURUSb32j5XijP",
|
||||
},
|
||||
],
|
||||
js: [
|
||||
{
|
||||
@@ -137,6 +142,8 @@ export const surveys = {
|
||||
},
|
||||
};
|
||||
|
||||
export type CreateSurveyParams = typeof surveys.createAndSubmit;
|
||||
|
||||
export const actions = {
|
||||
create: {
|
||||
noCode: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TBaseFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
TSurveyClosedMessage,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyInlineTriggers,
|
||||
TSurveyProductOverwrites,
|
||||
TSurveyQuestions,
|
||||
TSurveySingleUse,
|
||||
@@ -36,5 +37,6 @@ declare global {
|
||||
export type TeamBilling = TTeamBilling;
|
||||
export type UserNotificationSettings = TUserNotificationSettings;
|
||||
export type SegmentFilter = TBaseFilters;
|
||||
export type SurveyInlineTriggers = TSurveyInlineTriggers;
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "inlineTriggers" JSONB;
|
||||
@@ -276,6 +276,9 @@ model Survey {
|
||||
displayOption displayOptions @default(displayOnce)
|
||||
recontactDays Int?
|
||||
triggers SurveyTrigger[]
|
||||
/// @zod.custom(imports.ZSurveyInlineTriggers)
|
||||
/// [SurveyInlineTriggers]
|
||||
inlineTriggers Json?
|
||||
attributeFilters SurveyAttributeFilter[]
|
||||
displays Display[]
|
||||
autoClose Int?
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
ZSurveyStyling,
|
||||
ZSurveyVerifyEmail,
|
||||
ZSurveySingleUse,
|
||||
ZSurveyInlineTriggers,
|
||||
} from "@formbricks/types/surveys";
|
||||
|
||||
export { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
|
||||
@@ -20,7 +20,22 @@ const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
|
||||
};
|
||||
|
||||
export const trackAction = async (name: string): Promise<Result<void, NetworkError>> => {
|
||||
const { userId } = config.get();
|
||||
const {
|
||||
userId,
|
||||
state: { surveys = [] },
|
||||
} = config.get();
|
||||
|
||||
// if surveys have a inline triggers, we need to check the name of the action in the code action config
|
||||
surveys.forEach(async (survey) => {
|
||||
const { inlineTriggers } = survey;
|
||||
const { codeConfig } = inlineTriggers ?? {};
|
||||
|
||||
if (name === codeConfig?.identifier) {
|
||||
await renderWidget(survey);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const input: TJsActionInput = {
|
||||
environmentId: config.get().environmentId,
|
||||
userId,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import type { TActionClassPageUrlRule } from "@formbricks/types/actionClasses";
|
||||
import { TSurveyInlineTriggers } from "@formbricks/types/surveys";
|
||||
|
||||
import { trackAction } from "./actions";
|
||||
import { Config } from "./config";
|
||||
import { ErrorHandler, InvalidMatchTypeError, NetworkError, Result, err, match, ok, okVoid } from "./errors";
|
||||
import { Logger } from "./logger";
|
||||
import { renderWidget } from "./widget";
|
||||
|
||||
const config = Config.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
@@ -13,37 +15,53 @@ const errorHandler = ErrorHandler.getInstance();
|
||||
export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError | NetworkError>> => {
|
||||
logger.debug(`Checking page url: ${window.location.href}`);
|
||||
const { state } = config.get();
|
||||
if (state?.noCodeActionClasses === undefined) {
|
||||
return okVoid();
|
||||
}
|
||||
const { noCodeActionClasses = [], surveys = [] } = state ?? {};
|
||||
|
||||
const pageUrlEvents: TActionClass[] = (state?.noCodeActionClasses || []).filter((action) => {
|
||||
const actionsWithPageUrl: TActionClass[] = noCodeActionClasses.filter((action) => {
|
||||
const { innerHtml, cssSelector, pageUrl } = action.noCodeConfig || {};
|
||||
return pageUrl && !innerHtml && !cssSelector;
|
||||
});
|
||||
|
||||
if (pageUrlEvents.length === 0) {
|
||||
return okVoid();
|
||||
const surveysWithInlineTriggers = surveys.filter((survey) => {
|
||||
const { pageUrl, cssSelector, innerHtml } = survey.inlineTriggers?.noCodeConfig || {};
|
||||
return pageUrl && !cssSelector && !innerHtml;
|
||||
});
|
||||
|
||||
if (actionsWithPageUrl.length > 0) {
|
||||
for (const event of actionsWithPageUrl) {
|
||||
if (!event.noCodeConfig?.pageUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const {
|
||||
noCodeConfig: { pageUrl },
|
||||
} = event;
|
||||
|
||||
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
|
||||
|
||||
if (match.ok !== true) return err(match.error);
|
||||
|
||||
if (match.value === false) continue;
|
||||
|
||||
const trackResult = await trackAction(event.name);
|
||||
|
||||
if (trackResult.ok !== true) return err(trackResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of pageUrlEvents) {
|
||||
if (!event.noCodeConfig?.pageUrl) {
|
||||
continue;
|
||||
}
|
||||
if (surveysWithInlineTriggers.length > 0) {
|
||||
surveysWithInlineTriggers.forEach((survey) => {
|
||||
const { noCodeConfig } = survey.inlineTriggers ?? {};
|
||||
const { pageUrl } = noCodeConfig ?? {};
|
||||
|
||||
const {
|
||||
noCodeConfig: { pageUrl },
|
||||
} = event;
|
||||
if (pageUrl) {
|
||||
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
|
||||
|
||||
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
|
||||
if (match.ok !== true) return err(match.error);
|
||||
|
||||
if (match.ok !== true) return err(match.error);
|
||||
|
||||
if (match.value === false) continue;
|
||||
|
||||
const trackResult = await trackAction(event.name);
|
||||
|
||||
if (trackResult.ok !== true) return err(trackResult.error);
|
||||
renderWidget(survey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
@@ -102,51 +120,85 @@ export function checkUrlMatch(
|
||||
}
|
||||
}
|
||||
|
||||
const evaluateNoCodeConfig = (
|
||||
targetElement: HTMLElement,
|
||||
action: TActionClass | TSurveyInlineTriggers
|
||||
): boolean => {
|
||||
const innerHtml = action.noCodeConfig?.innerHtml?.value;
|
||||
const cssSelectors = action.noCodeConfig?.cssSelector?.value;
|
||||
const pageUrl = action.noCodeConfig?.pageUrl?.value;
|
||||
const pageUrlRule = action.noCodeConfig?.pageUrl?.rule;
|
||||
|
||||
if (!innerHtml && !cssSelectors && !pageUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (innerHtml && targetElement.innerHTML !== innerHtml) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pageUrl && pageUrlRule) {
|
||||
const urlMatch = checkUrlMatch(window.location.href, pageUrl, pageUrlRule);
|
||||
if (!urlMatch.ok || !urlMatch.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const checkClickMatch = (event: MouseEvent) => {
|
||||
const { state } = config.get();
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { noCodeActionClasses } = state;
|
||||
if (!noCodeActionClasses) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
const pageUrlRule = action.noCodeConfig?.pageUrl?.rule;
|
||||
|
||||
if (!innerHtml && !cssSelectors && !pageUrl) {
|
||||
return;
|
||||
noCodeActionClasses.forEach((action: TActionClass) => {
|
||||
const shouldTrack = evaluateNoCodeConfig(targetElement, action);
|
||||
if (shouldTrack) {
|
||||
trackAction(action.name).then((res) => {
|
||||
match(
|
||||
res,
|
||||
(_value) => {},
|
||||
(err) => {
|
||||
errorHandler.handle(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (innerHtml && targetElement.innerHTML !== innerHtml) {
|
||||
return;
|
||||
}
|
||||
// check for the inline triggers as well
|
||||
const activeSurveys = state.surveys;
|
||||
if (!activeSurveys || activeSurveys.length === 0) {
|
||||
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;
|
||||
}
|
||||
activeSurveys.forEach((survey) => {
|
||||
const { inlineTriggers } = survey;
|
||||
if (inlineTriggers) {
|
||||
const shouldTrack = evaluateNoCodeConfig(targetElement, inlineTriggers);
|
||||
if (shouldTrack) {
|
||||
renderWidget(survey);
|
||||
}
|
||||
}
|
||||
if (pageUrl && pageUrlRule) {
|
||||
const urlMatch = checkUrlMatch(window.location.href, pageUrl, pageUrlRule);
|
||||
if (!urlMatch.ok || !urlMatch.value) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
trackAction(action.name).then((res) => {
|
||||
match(
|
||||
res,
|
||||
(_value) => {},
|
||||
(err) => {
|
||||
errorHandler.handle(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ZId } from "@formbricks/types/environment";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { TSegment, ZSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyInput, ZSurvey } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyInput, ZSurvey, ZSurveyWithRefinements } from "@formbricks/types/surveys";
|
||||
|
||||
import { getActionsByPersonId } from "../action/service";
|
||||
import { getActionClasses } from "../actionClass/service";
|
||||
@@ -75,6 +75,7 @@ export const selectSurvey = {
|
||||
},
|
||||
},
|
||||
},
|
||||
inlineTriggers: true,
|
||||
segment: {
|
||||
include: {
|
||||
surveys: {
|
||||
@@ -265,7 +266,7 @@ export const getSurveys = async (environmentId: string, page?: number): Promise<
|
||||
};
|
||||
|
||||
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
|
||||
validateInputs([updatedSurvey, ZSurvey]);
|
||||
validateInputs([updatedSurvey, ZSurveyWithRefinements]);
|
||||
|
||||
const surveyId = updatedSurvey.id;
|
||||
let data: any = {};
|
||||
@@ -425,6 +426,11 @@ export async function deleteSurvey(surveyId: string) {
|
||||
export const createSurvey = async (environmentId: string, surveyBody: TSurveyInput): Promise<TSurvey> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
// if the survey body has both triggers and inlineTriggers, we throw an error
|
||||
if (surveyBody.triggers && surveyBody.inlineTriggers) {
|
||||
throw new InvalidInputError("Survey body cannot have both triggers and inlineTriggers");
|
||||
}
|
||||
|
||||
if (surveyBody.triggers) {
|
||||
const actionClasses = await getActionClasses(environmentId);
|
||||
revalidateSurveyByActionClassId(actionClasses, surveyBody.triggers);
|
||||
@@ -507,6 +513,7 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
|
||||
actionClassId: getActionClassIdFromName(actionClasses, trigger),
|
||||
})),
|
||||
},
|
||||
inlineTriggers: existingSurvey.inlineTriggers ?? undefined,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
|
||||
@@ -189,6 +189,7 @@ export const mockSurveyOutput: SurveyMock = {
|
||||
segment: null,
|
||||
segmentId: null,
|
||||
resultShareKey: null,
|
||||
inlineTriggers: null,
|
||||
...baseSurveyProperties,
|
||||
};
|
||||
|
||||
@@ -213,6 +214,7 @@ export const updateSurveyInput: TSurvey = {
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
inlineTriggers: null,
|
||||
...commonMockProperties,
|
||||
...baseSurveyProperties,
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ export function formatDateFields<T extends z.ZodRawShape>(
|
||||
|
||||
return formattedObject as z.infer<typeof zodSchema>;
|
||||
}
|
||||
|
||||
export const formatDateWithOrdinal = (date: Date): string => {
|
||||
const getOrdinalSuffix = (day: number) => {
|
||||
const suffixes = ["th", "st", "nd", "rd"];
|
||||
|
||||
+70
-19
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ZNoCodeConfig } from "./actionClasses";
|
||||
import { ZAllowedFileExtension, ZColor, ZPlacement } from "./common";
|
||||
import { TPerson } from "./people";
|
||||
import { ZSegment } from "./segment";
|
||||
@@ -402,6 +403,30 @@ const ZSurveyStatus = z.enum(["draft", "inProgress", "paused", "completed"]);
|
||||
|
||||
export type TSurveyStatus = z.infer<typeof ZSurveyStatus>;
|
||||
|
||||
export const ZSurveyInlineTriggers = z.object({
|
||||
codeConfig: z.object({ identifier: z.string() }).optional(),
|
||||
noCodeConfig: ZNoCodeConfig.omit({ type: true }).optional(),
|
||||
});
|
||||
|
||||
export type TSurveyInlineTriggers = z.infer<typeof ZSurveyInlineTriggers>;
|
||||
|
||||
export const surveyHasBothTriggers = (survey: TSurvey) => {
|
||||
// if the triggers array has a single empty string, it means the survey has no triggers
|
||||
if (survey.triggers?.[0] === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasTriggers = survey.triggers?.length > 0;
|
||||
const hasInlineTriggers = !!survey.inlineTriggers?.codeConfig || !!survey.inlineTriggers?.noCodeConfig;
|
||||
|
||||
// Survey cannot have both triggers and inlineTriggers
|
||||
if (hasTriggers && hasInlineTriggers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const ZSurvey = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
@@ -414,6 +439,7 @@ export const ZSurvey = z.object({
|
||||
displayOption: ZSurveyDisplayOption,
|
||||
autoClose: z.number().nullable(),
|
||||
triggers: z.array(z.string()),
|
||||
inlineTriggers: ZSurveyInlineTriggers.nullable(),
|
||||
redirectUrl: z.string().url().nullable(),
|
||||
recontactDays: z.number().nullable(),
|
||||
welcomeCard: ZSurveyWelcomeCard,
|
||||
@@ -434,27 +460,52 @@ export const ZSurvey = z.object({
|
||||
displayPercentage: z.number().min(1).max(100).nullable(),
|
||||
});
|
||||
|
||||
export const ZSurveyInput = z.object({
|
||||
name: z.string(),
|
||||
type: ZSurveyType.optional(),
|
||||
createdBy: z.string().cuid().optional(),
|
||||
status: ZSurveyStatus.optional(),
|
||||
displayOption: ZSurveyDisplayOption.optional(),
|
||||
autoClose: z.number().optional(),
|
||||
redirectUrl: z.string().url().optional(),
|
||||
recontactDays: z.number().optional(),
|
||||
welcomeCard: ZSurveyWelcomeCard.optional(),
|
||||
questions: ZSurveyQuestions.optional(),
|
||||
thankYouCard: ZSurveyThankYouCard.optional(),
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
delay: z.number().optional(),
|
||||
autoComplete: z.number().optional(),
|
||||
closeOnDate: z.date().optional(),
|
||||
surveyClosedMessage: ZSurveyClosedMessage.optional(),
|
||||
verifyEmail: ZSurveyVerifyEmail.optional(),
|
||||
triggers: z.array(z.string()).optional(),
|
||||
export const ZSurveyWithRefinements = ZSurvey.refine((survey) => !surveyHasBothTriggers(survey), {
|
||||
message: "Survey cannot have both triggers and inlineTriggers",
|
||||
});
|
||||
|
||||
export const ZSurveyInput = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: ZSurveyType.optional(),
|
||||
createdBy: z.string().cuid().optional(),
|
||||
status: ZSurveyStatus.optional(),
|
||||
displayOption: ZSurveyDisplayOption.optional(),
|
||||
autoClose: z.number().optional(),
|
||||
redirectUrl: z.string().url().optional(),
|
||||
recontactDays: z.number().optional(),
|
||||
welcomeCard: ZSurveyWelcomeCard.optional(),
|
||||
questions: ZSurveyQuestions.optional(),
|
||||
thankYouCard: ZSurveyThankYouCard.optional(),
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
delay: z.number().optional(),
|
||||
autoComplete: z.number().optional(),
|
||||
closeOnDate: z.date().optional(),
|
||||
surveyClosedMessage: ZSurveyClosedMessage.optional(),
|
||||
verifyEmail: ZSurveyVerifyEmail.optional(),
|
||||
triggers: z.array(z.string()).optional(),
|
||||
inlineTriggers: ZSurveyInlineTriggers.optional(),
|
||||
})
|
||||
.refine(
|
||||
(survey) => {
|
||||
// if the triggers array has a single empty string, it means the survey has no triggers
|
||||
if (survey.triggers?.[0] === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasTriggers = !!survey.triggers?.length;
|
||||
const hasInlineTriggers = !!survey.inlineTriggers?.codeConfig || !!survey.inlineTriggers?.noCodeConfig;
|
||||
|
||||
// Survey cannot have both triggers and inlineTriggers
|
||||
if (hasTriggers && hasInlineTriggers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{ message: "Survey cannot have both triggers and inlineTriggers" }
|
||||
);
|
||||
|
||||
export type TSurvey = z.infer<typeof ZSurvey>;
|
||||
|
||||
export type TSurveyDates = {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { UseFormRegister } from "react-hook-form";
|
||||
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
|
||||
import { Input } from "../../Input";
|
||||
|
||||
interface CssSelectorProps {
|
||||
isCssSelector: boolean;
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { UseFormRegister } from "react-hook-form";
|
||||
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
|
||||
import { Input } from "../../Input";
|
||||
|
||||
interface InnerHtmlSelectorProps {
|
||||
isInnerHtml: boolean;
|
||||
+4
-4
@@ -2,10 +2,10 @@ import { Label } from "@radix-ui/react-dropdown-menu";
|
||||
import clsx from "clsx";
|
||||
import { Control, Controller, UseFormRegister } from "react-hook-form";
|
||||
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
import { AdvancedOptionToggle } from "../../AdvancedOptionToggle";
|
||||
import { Button } from "../../Button";
|
||||
import { Input } from "../../Input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../Select";
|
||||
|
||||
interface PageUrlSelectorProps {
|
||||
isPageUrl: boolean;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CssSelector } from "./components/CssSelector";
|
||||
import { InnerHtmlSelector } from "./components/InnerHtmlSelector";
|
||||
import { PageUrlSelector } from "./components/PageUrlSelector";
|
||||
|
||||
export { CssSelector, InnerHtmlSelector, PageUrlSelector };
|
||||
@@ -148,6 +148,7 @@ export async function copyToOtherEnvironmentAction(
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
inlineTriggers: JSON.parse(JSON.stringify(existingSurvey.inlineTriggers)),
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((actionClassId) => ({
|
||||
actionClassId: actionClassId,
|
||||
|
||||
@@ -7,28 +7,66 @@ interface TabBarProps {
|
||||
activeId: string;
|
||||
setActiveId: (id: string) => void;
|
||||
className?: string;
|
||||
tabStyle?: "bar" | "button";
|
||||
}
|
||||
|
||||
export const TabBar: React.FC<TabBarProps> = ({ tabs, activeId, setActiveId, className = "" }) => {
|
||||
export const TabBar: React.FC<TabBarProps> = ({
|
||||
tabs,
|
||||
activeId,
|
||||
setActiveId,
|
||||
className = "",
|
||||
tabStyle = "bar",
|
||||
}) => {
|
||||
const Nav = () => {
|
||||
if (tabStyle === "bar") {
|
||||
return (
|
||||
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
tab.id === activeId
|
||||
? " border-brand-dark border-b-2 font-semibold text-slate-900"
|
||||
: "text-slate-500 hover:text-slate-700",
|
||||
"flex h-full items-center px-3 text-sm font-medium"
|
||||
)}
|
||||
aria-current={tab.id === activeId ? "page" : undefined}>
|
||||
{tab.icon && <div className="flex h-5 w-5 items-center">{tab.icon}</div>}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabStyle === "button") {
|
||||
return (
|
||||
<nav className="flex h-full w-full flex-1 items-center space-x-4" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<div className="flex h-full flex-1 justify-center px-3 py-2">
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
tab.id === activeId
|
||||
? "bg-white font-semibold text-slate-900"
|
||||
: "text-slate-500 hover:text-slate-700",
|
||||
"h-full w-full items-center rounded-lg text-center text-sm font-medium"
|
||||
)}
|
||||
aria-current={tab.id === activeId ? "page" : undefined}>
|
||||
{tab.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-14 w-full items-center justify-center border-b bg-slate-50", className)}>
|
||||
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
tab.id === activeId
|
||||
? " border-brand-dark border-b-2 font-semibold text-slate-900"
|
||||
: "text-slate-500 hover:text-slate-700",
|
||||
"flex h-full items-center px-3 text-sm font-medium"
|
||||
)}
|
||||
aria-current={tab.id === activeId ? "page" : undefined}>
|
||||
{tab.icon && <div className="flex h-5 w-5 items-center">{tab.icon}</div>}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<Nav />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user