mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-17 11:31:09 -05:00
feat: Recall functionality (#1789)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
committed by
GitHub
parent
e30f16cec2
commit
f2800632e3
@@ -9,6 +9,7 @@ import { getFilterResponses } from "@/app/lib/surveys/surveys";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
@@ -44,9 +45,10 @@ const ResponsePage = ({
|
||||
membershipRole,
|
||||
}: ResponsePageProps) => {
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
survey = useMemo(() => {
|
||||
return checkForRecallInHeadline(survey);
|
||||
}, [survey]);
|
||||
useEffect(() => {
|
||||
if (!searchParams?.get("referer")) {
|
||||
resetState();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getFilterResponses } from "@/app/lib/surveys/surveys";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
@@ -50,7 +51,9 @@ const SummaryPage = ({
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
survey = useMemo(() => {
|
||||
return checkForRecallInHeadline(survey);
|
||||
}, [survey]);
|
||||
useEffect(() => {
|
||||
if (!searchParams?.get("referer")) {
|
||||
resetState();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BackButtonInput } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard";
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { useState } from "react";
|
||||
|
||||
import { md } from "@formbricks/lib/markdownIt";
|
||||
@@ -9,6 +8,7 @@ import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys";
|
||||
import { Editor } from "@formbricks/ui/Editor";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
|
||||
interface CTAQuestionFormProps {
|
||||
@@ -17,7 +17,7 @@ interface CTAQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function CTAQuestionForm({
|
||||
@@ -25,7 +25,7 @@ export default function CTAQuestionForm({
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
}: CTAQuestionFormProps): JSX.Element {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
@@ -34,11 +34,13 @@ export default function CTAQuestionForm({
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -6,6 +5,7 @@ import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
|
||||
interface CalQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -13,7 +13,7 @@ interface CalQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function CalQuestionForm({
|
||||
@@ -21,33 +21,36 @@ export default function CalQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
}: CalQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
const environmentId = localSurvey.environmentId;
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
localSurvey={localSurvey}
|
||||
environmentId={localSurvey.environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
<div className="flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={localSurvey.environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="subheader"
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
@@ -57,7 +60,12 @@ export default function CalQuestionForm({
|
||||
</>
|
||||
)}
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
variant="minimal"
|
||||
type="button"
|
||||
onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { useState } from "react";
|
||||
|
||||
import { md } from "@formbricks/lib/markdownIt";
|
||||
@@ -8,20 +7,21 @@ import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys";
|
||||
import { Editor } from "@formbricks/ui/Editor";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
|
||||
interface ConsentQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyConsentQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function ConsentQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
}: ConsentQuestionFormProps): JSX.Element {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
@@ -30,11 +30,13 @@ export default function ConsentQuestionForm({
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
@@ -66,7 +68,7 @@ export default function ConsentQuestionForm({
|
||||
value={question.label}
|
||||
placeholder="I agree to the terms and conditions"
|
||||
onChange={(e) => updateQuestion(questionIdx, { label: e.target.value })}
|
||||
isInvalid={isInValid && question.label.trim() === ""}
|
||||
isInvalid={isInvalid && question.label.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="mt-3">
|
||||
|
||||
@@ -3,19 +3,17 @@ import { useState } from "react";
|
||||
|
||||
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
|
||||
import QuestionFormInput from "./QuestionFormInput";
|
||||
|
||||
interface IDateQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyDateQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
const dateOptions = [
|
||||
@@ -37,7 +35,7 @@ export default function DateQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
}: IDateQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
@@ -46,24 +44,28 @@ export default function DateQuestionForm({
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
environmentId={localSurvey.environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
localSurvey={localSurvey}
|
||||
type="headline"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
<div className="flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={localSurvey.environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="subheader"
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
@@ -74,7 +76,12 @@ export default function DateQuestionForm({
|
||||
)}
|
||||
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
variant="minimal"
|
||||
type="button"
|
||||
onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
|
||||
@@ -4,8 +4,8 @@ import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
interface EditThankYouCardProps {
|
||||
@@ -45,7 +45,7 @@ export default function EditThankYouCard({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg " : "scale-97 shadow-md",
|
||||
"flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
"z-20 flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -91,30 +91,26 @@ export default function EditThankYouCard({
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-6">
|
||||
<form>
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="headline">Headline</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
id="headline"
|
||||
name="headline"
|
||||
defaultValue={localSurvey?.thankYouCard?.headline}
|
||||
onChange={(e) => {
|
||||
updateSurvey({ headline: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={localSurvey.environmentId}
|
||||
isInvalid={false}
|
||||
questionId="end"
|
||||
questionIdx={localSurvey.questions.length}
|
||||
updateSurvey={updateSurvey}
|
||||
type="headline"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
defaultValue={localSurvey?.thankYouCard?.subheader}
|
||||
onChange={(e) => {
|
||||
updateSurvey({ subheader: e.target.value });
|
||||
}}
|
||||
<div>
|
||||
<div className="flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={localSurvey.environmentId}
|
||||
isInvalid={false}
|
||||
questionId="end"
|
||||
questionIdx={localSurvey.questions.length}
|
||||
updateSurvey={updateSurvey}
|
||||
type="subheader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { PlusIcon, TrashIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -12,7 +11,7 @@ import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
|
||||
interface FileUploadFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -21,7 +20,7 @@ interface FileUploadFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function FileUploadQuestionForm({
|
||||
@@ -29,7 +28,7 @@ export default function FileUploadQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
product,
|
||||
}: FileUploadFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
@@ -109,25 +108,29 @@ export default function FileUploadQuestionForm({
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
<div className="flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="subheader"
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
@@ -137,7 +140,12 @@ export default function FileUploadQuestionForm({
|
||||
</>
|
||||
)}
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
variant="minimal"
|
||||
type="button"
|
||||
onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
|
||||
@@ -49,7 +49,7 @@ const HiddenFieldsCard: FC<HiddenFieldsCardProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg " : "scale-97 shadow-md",
|
||||
"flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
"z-10 flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -10,6 +9,7 @@ import { TSurvey, TSurveyMultipleChoiceMultiQuestion } from "@formbricks/types/s
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
|
||||
interface OpenQuestionFormProps {
|
||||
@@ -18,21 +18,21 @@ interface OpenQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function MultipleChoiceMultiForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
}: OpenQuestionFormProps): JSX.Element {
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
const questionRef = useRef<HTMLInputElement>(null);
|
||||
const [isInvalidValue, setIsInvalidValue] = useState<string | null>(null);
|
||||
const [isInvalidValue, setisInvalidValue] = useState<string | null>(null);
|
||||
|
||||
const shuffleOptionsTypes = {
|
||||
none: {
|
||||
@@ -131,7 +131,7 @@ export default function MultipleChoiceMultiForm({
|
||||
|
||||
const choiceValue = question.choices[choiceIdx].label;
|
||||
if (isInvalidValue === choiceValue) {
|
||||
setIsInvalidValue(null);
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
@@ -165,27 +165,31 @@ export default function MultipleChoiceMultiForm({
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
ref={questionRef}
|
||||
question={question}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
<div className="flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="subheader"
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
@@ -195,7 +199,12 @@ export default function MultipleChoiceMultiForm({
|
||||
</>
|
||||
)}
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
className="mt-3"
|
||||
type="button"
|
||||
onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
@@ -219,11 +228,11 @@ export default function MultipleChoiceMultiForm({
|
||||
onBlur={() => {
|
||||
const duplicateLabel = findDuplicateLabel();
|
||||
if (duplicateLabel) {
|
||||
setIsInvalidValue(duplicateLabel);
|
||||
setisInvalidValue(duplicateLabel);
|
||||
} else if (findEmptyLabel()) {
|
||||
setIsInvalidValue("");
|
||||
setisInvalidValue("");
|
||||
} else {
|
||||
setIsInvalidValue(null);
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
}}
|
||||
isInvalid={
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -10,6 +9,7 @@ import { TSurvey, TSurveyMultipleChoiceSingleQuestion } from "@formbricks/types/
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
|
||||
interface OpenQuestionFormProps {
|
||||
@@ -18,20 +18,20 @@ interface OpenQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function MultipleChoiceSingleForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
}: OpenQuestionFormProps): JSX.Element {
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
const [isInvalidValue, setIsInvalidValue] = useState<string | null>(null);
|
||||
const [isInvalidValue, setisInvalidValue] = useState<string | null>(null);
|
||||
const questionRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const shuffleOptionsTypes = {
|
||||
@@ -131,7 +131,7 @@ export default function MultipleChoiceSingleForm({
|
||||
|
||||
const choiceValue = question.choices[choiceIdx].label;
|
||||
if (isInvalidValue === choiceValue) {
|
||||
setIsInvalidValue(null);
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
@@ -165,27 +165,31 @@ export default function MultipleChoiceSingleForm({
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
ref={questionRef}
|
||||
question={question}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
<div className="flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="subheader"
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
@@ -195,7 +199,12 @@ export default function MultipleChoiceSingleForm({
|
||||
</>
|
||||
)}
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
className="mt-3"
|
||||
type="button"
|
||||
onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
@@ -218,11 +227,11 @@ export default function MultipleChoiceSingleForm({
|
||||
onBlur={() => {
|
||||
const duplicateLabel = findDuplicateLabel();
|
||||
if (duplicateLabel) {
|
||||
setIsInvalidValue(duplicateLabel);
|
||||
setisInvalidValue(duplicateLabel);
|
||||
} else if (findEmptyLabel()) {
|
||||
setIsInvalidValue("");
|
||||
setisInvalidValue("");
|
||||
} else {
|
||||
setIsInvalidValue(null);
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -8,6 +7,7 @@ import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
|
||||
interface NPSQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -15,7 +15,7 @@ interface NPSQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function NPSQuestionForm({
|
||||
@@ -23,7 +23,7 @@ export default function NPSQuestionForm({
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
}: NPSQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
@@ -32,26 +32,30 @@ export default function NPSQuestionForm({
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
<div className=" flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="subheader"
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
@@ -61,7 +65,12 @@ export default function NPSQuestionForm({
|
||||
</>
|
||||
)}
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
className="mt-3"
|
||||
type="button"
|
||||
onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import {
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
|
||||
const questionTypes = [
|
||||
@@ -35,14 +35,14 @@ interface OpenQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function OpenQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
}: OpenQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
@@ -62,26 +62,30 @@ export default function OpenQuestionForm({
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
<div className="flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="subheader"
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
@@ -91,7 +95,12 @@ export default function OpenQuestionForm({
|
||||
</>
|
||||
)}
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
className="mt-3"
|
||||
type="button"
|
||||
onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useState } from "react";
|
||||
@@ -7,8 +6,8 @@ import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import FileInput from "@formbricks/ui/FileInput";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
interface PictureSelectionFormProps {
|
||||
@@ -17,7 +16,7 @@ interface PictureSelectionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function PictureSelectionForm({
|
||||
@@ -25,7 +24,7 @@ export default function PictureSelectionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
}: PictureSelectionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
const environmentId = localSurvey.environmentId;
|
||||
@@ -33,25 +32,29 @@ export default function PictureSelectionForm({
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
<div className="flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="subheader"
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
@@ -61,7 +64,12 @@ export default function PictureSelectionForm({
|
||||
</>
|
||||
)}
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
className="mt-3"
|
||||
type="button"
|
||||
onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
@@ -72,7 +80,7 @@ export default function PictureSelectionForm({
|
||||
Images{" "}
|
||||
<span
|
||||
className={cn("text-slate-400", {
|
||||
"text-red-600": isInValid && question.choices?.length < 2,
|
||||
"text-red-600": isInvalid && question.choices?.length < 2,
|
||||
})}>
|
||||
(Upload at least 2 images)
|
||||
</span>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useState } from "react";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
@@ -53,7 +54,7 @@ interface QuestionCardProps {
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export function BackButtonInput({
|
||||
@@ -93,12 +94,31 @@ export default function QuestionCard({
|
||||
activeQuestionId,
|
||||
setActiveQuestionId,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
}: QuestionCardProps) {
|
||||
const question = localSurvey.questions[questionIdx];
|
||||
const open = activeQuestionId === question.id;
|
||||
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
|
||||
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
const formatTextWithSlashes = (text) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateEmptyNextButtonLabels = (labelValue: string) => {
|
||||
localSurvey.questions.forEach((q, index) => {
|
||||
if (index === localSurvey.questions.length - 1) return;
|
||||
@@ -123,7 +143,7 @@ export default function QuestionCard({
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:bg-slate-600",
|
||||
isInValid && "bg-red-400 hover:bg-red-600"
|
||||
isInvalid && "bg-red-400 hover:bg-red-600"
|
||||
)}>
|
||||
{questionIdx + 1}
|
||||
</div>
|
||||
@@ -142,7 +162,7 @@ export default function QuestionCard({
|
||||
className={cn(open ? "" : " ", "flex cursor-pointer justify-between p-4 hover:bg-slate-50")}>
|
||||
<div>
|
||||
<div className="inline-flex">
|
||||
<div className="-ml-0.5 mr-3 h-6 w-6 text-slate-400">
|
||||
<div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
|
||||
{question.type === TSurveyQuestionType.FileUpload ? (
|
||||
<ArrowUpTrayIcon />
|
||||
) : question.type === TSurveyQuestionType.OpenText ? (
|
||||
@@ -169,7 +189,9 @@ export default function QuestionCard({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{question.headline || getTSurveyQuestionTypeName(question.type)}
|
||||
{recallToHeadline(question.headline, localSurvey, true)
|
||||
? formatTextWithSlashes(recallToHeadline(question.headline, localSurvey, true))
|
||||
: getTSurveyQuestionTypeName(question.type)}
|
||||
</p>
|
||||
{!open && question?.required && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
@@ -198,7 +220,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceSingleForm
|
||||
@@ -207,7 +229,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceMultiForm
|
||||
@@ -216,7 +238,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.NPS ? (
|
||||
<NPSQuestionForm
|
||||
@@ -225,7 +247,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.CTA ? (
|
||||
<CTAQuestionForm
|
||||
@@ -234,7 +256,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Rating ? (
|
||||
<RatingQuestionForm
|
||||
@@ -243,7 +265,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Consent ? (
|
||||
<ConsentQuestionForm
|
||||
@@ -251,7 +273,7 @@ export default function QuestionCard({
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<DateQuestionForm
|
||||
@@ -260,7 +282,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
@@ -269,7 +291,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.FileUpload ? (
|
||||
<FileUploadQuestionForm
|
||||
@@ -279,7 +301,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Cal ? (
|
||||
<CalQuestionForm
|
||||
@@ -288,7 +310,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ImagePlusIcon } from "lucide-react";
|
||||
import { RefObject, useState } from "react";
|
||||
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
import FileInput from "@formbricks/ui/FileInput";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
interface QuestionFormInputProps {
|
||||
question: TSurveyQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
isInValid: boolean;
|
||||
environmentId: string;
|
||||
ref?: RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const QuestionFormInput = ({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
environmentId,
|
||||
ref,
|
||||
}: QuestionFormInputProps) => {
|
||||
const [showImageUploader, setShowImageUploader] = useState<boolean>(!!question.imageUrl);
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="headline">Question</Label>
|
||||
<div className="mt-2 flex flex-col gap-6">
|
||||
{showImageUploader && (
|
||||
<FileInput
|
||||
id="question-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[]) => {
|
||||
updateQuestion(questionIdx, { imageUrl: url[0] });
|
||||
}}
|
||||
fileUrl={question.imageUrl}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
autoFocus
|
||||
ref={ref}
|
||||
id="headline"
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
<ImagePlusIcon
|
||||
aria-label="Toggle image uploader"
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => setShowImageUploader((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionFormInput;
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import HiddenFieldsCard from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DragDropContext } from "react-beautiful-dnd";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
@@ -46,6 +47,9 @@ export default function QuestionsView({
|
||||
|
||||
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
|
||||
survey.questions.forEach((question) => {
|
||||
if (question.headline.includes(`recall:${compareId}`)) {
|
||||
question.headline = question.headline.replaceAll(`recall:${compareId}`, `recall:${updatedId}`);
|
||||
}
|
||||
if (!question.logic) return;
|
||||
question.logic.forEach((rule) => {
|
||||
if (rule.destination === compareId) {
|
||||
@@ -74,7 +78,6 @@ export default function QuestionsView({
|
||||
|
||||
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
|
||||
if ("id" in updatedAttributes) {
|
||||
// if the survey whose id is to be changed is linked to logic of any other survey then changing it
|
||||
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
|
||||
@@ -111,6 +114,16 @@ export default function QuestionsView({
|
||||
const questionId = localSurvey.questions[questionIdx].id;
|
||||
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
|
||||
let updatedSurvey: TSurvey = { ...localSurvey };
|
||||
|
||||
// check if we are recalling from this question
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
if (question.headline.includes(`recall:${questionId}`)) {
|
||||
const recallInfo = extractRecallInfo(question.headline);
|
||||
if (recallInfo) {
|
||||
question.headline = question.headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
});
|
||||
updatedSurvey.questions.splice(questionIdx, 1);
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");
|
||||
|
||||
@@ -181,6 +194,17 @@ export default function QuestionsView({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey);
|
||||
if (questionWithEmptyFallback) {
|
||||
setActiveQuestionId(questionWithEmptyFallback.id);
|
||||
if (activeQuestionId === questionWithEmptyFallback.id) {
|
||||
toast.error("Fallback missing");
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeQuestionId, setActiveQuestionId]);
|
||||
|
||||
return (
|
||||
<div className="mt-12 px-5 py-4">
|
||||
<div className="mb-5 flex flex-col gap-5">
|
||||
@@ -195,7 +219,7 @@ export default function QuestionsView({
|
||||
<div className="mb-5 grid grid-cols-1 gap-5 ">
|
||||
<StrictModeDroppable droppableId="questionsList">
|
||||
{(provided) => (
|
||||
<div className="grid gap-5" ref={provided.innerRef} {...provided.droppableProps}>
|
||||
<div className="grid w-full gap-5" ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{localSurvey.questions.map((question, questionIdx) => (
|
||||
// display a question form
|
||||
<QuestionCard
|
||||
@@ -210,7 +234,7 @@ export default function QuestionsView({
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
lastQuestion={questionIdx === localSurvey.questions.length - 1}
|
||||
isInValid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { FaceSmileIcon, HashtagIcon, StarIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
@@ -7,6 +6,7 @@ import { TSurvey, TSurveyRatingQuestion } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import QuestionFormInput from "@formbricks/ui/QuestionFormInput";
|
||||
|
||||
import Dropdown from "./RatingTypeDropdown";
|
||||
|
||||
@@ -16,7 +16,7 @@ interface RatingQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
isInvalid: boolean;
|
||||
}
|
||||
|
||||
export default function RatingQuestionForm({
|
||||
@@ -24,7 +24,7 @@ export default function RatingQuestionForm({
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
}: RatingQuestionFormProps) {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
@@ -33,26 +33,30 @@ export default function RatingQuestionForm({
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="headline"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
<div className="flex w-full items-center">
|
||||
<QuestionFormInput
|
||||
localSurvey={localSurvey}
|
||||
environmentId={environmentId}
|
||||
isInvalid={isInvalid}
|
||||
questionId={question.id}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
type="subheader"
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
@@ -62,7 +66,12 @@ export default function RatingQuestionForm({
|
||||
</>
|
||||
)}
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
className="mt-3"
|
||||
type="button"
|
||||
onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, 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 { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
@@ -107,7 +108,7 @@ export default function SurveyMenuBar({
|
||||
}
|
||||
};
|
||||
|
||||
const validateSurvey = (survey) => {
|
||||
const validateSurvey = (survey: TSurvey) => {
|
||||
const existingQuestionIds = new Set();
|
||||
|
||||
if (survey.questions.length === 0) {
|
||||
@@ -116,7 +117,7 @@ export default function SurveyMenuBar({
|
||||
}
|
||||
|
||||
let pin = survey?.pin;
|
||||
if (pin !== null && pin.toString().length !== 4) {
|
||||
if (pin !== null && pin!.toString().length !== 4) {
|
||||
toast.error("PIN must be a four digit number.");
|
||||
return;
|
||||
}
|
||||
@@ -130,6 +131,7 @@ export default function SurveyMenuBar({
|
||||
faultyQuestions.push(question.id);
|
||||
}
|
||||
}
|
||||
|
||||
// if there are any faulty questions, the user won't be allowed to save the survey
|
||||
if (faultyQuestions.length > 0) {
|
||||
setInvalidQuestions(faultyQuestions);
|
||||
@@ -205,10 +207,7 @@ export default function SurveyMenuBar({
|
||||
Check whether the count for autocomplete responses is not less
|
||||
than the current count of accepted response and also it is not set to 0
|
||||
*/
|
||||
if (
|
||||
(survey.autoComplete && survey._count?.responses && survey._count.responses >= survey.autoComplete) ||
|
||||
survey?.autoComplete === 0
|
||||
) {
|
||||
if ((survey.autoComplete && responseCount >= survey.autoComplete) || survey?.autoComplete === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -220,6 +219,12 @@ export default function SurveyMenuBar({
|
||||
toast.error("Please add at least one question.");
|
||||
return;
|
||||
}
|
||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey);
|
||||
if (questionWithEmptyFallback) {
|
||||
toast.error("Fallback missing");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSurveySaving(true);
|
||||
// Create a copy of localSurvey with isDraft removed from every question
|
||||
const strippedSurvey: TSurvey = {
|
||||
|
||||
@@ -69,7 +69,7 @@ test.describe("Survey Create & Submit Response", async () => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.ratingQuestion.question);
|
||||
await page.getByLabel("Scale").fill(surveys.createAndSubmit.ratingQuestion.description);
|
||||
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);
|
||||
|
||||
@@ -135,7 +135,7 @@ test.describe("Survey Create & Submit Response", async () => {
|
||||
.filter({ hasText: /^Thank You CardShown$/ })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByLabel("Headline").fill(surveys.createAndSubmit.thankYouCard.headline);
|
||||
await page.getByLabel("Question").fill(surveys.createAndSubmit.thankYouCard.headline);
|
||||
await page.getByLabel("Description").fill(surveys.createAndSubmit.thankYouCard.description);
|
||||
|
||||
// Save & Publish Survey
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/js-library.json",
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"downlevelIteration": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,3 +72,14 @@ export const formatDateWithOrdinal = (date: Date): string => {
|
||||
|
||||
return `${dayOfWeek}, ${monthNames[monthIndex]} ${day}${getOrdinalSuffix(day)}, ${year}`;
|
||||
};
|
||||
|
||||
export function isValidDateString(value: string) {
|
||||
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
|
||||
|
||||
if (!regex.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return date;
|
||||
}
|
||||
|
||||
179
packages/lib/utils/recall.ts
Normal file
179
packages/lib/utils/recall.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { RefObject, useEffect } from "react";
|
||||
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
export interface fallbacks {
|
||||
[id: string]: string;
|
||||
}
|
||||
|
||||
// Extracts the ID of recall question from a string containing the "recall" pattern.
|
||||
export const extractId = (text: string): string | null => {
|
||||
const pattern = /#recall:([A-Za-z0-9]+)/;
|
||||
const match = text.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// If there are multiple recall infos in a string extracts all recall question IDs from that string and construct an array out of it.
|
||||
export const extractIds = (text: string): string[] => {
|
||||
const pattern = /#recall:([A-Za-z0-9]+)/g;
|
||||
const matches = Array.from(text.matchAll(pattern));
|
||||
return matches.map((match) => match[1]).filter((id) => id !== null);
|
||||
};
|
||||
|
||||
// Extracts the fallback value from a string containing the "fallback" pattern.
|
||||
export const extractFallbackValue = (text: string): string => {
|
||||
const pattern = /fallback:(\S*)#/;
|
||||
const match = text.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Extracts the complete recall information (ID and fallback) from a headline string.
|
||||
export const extractRecallInfo = (headline: string): string | null => {
|
||||
const pattern = /#recall:([A-Za-z0-9]+)\/fallback:(\S*)#/;
|
||||
const match = headline.match(pattern);
|
||||
return match ? match[0] : null;
|
||||
};
|
||||
|
||||
// Finds the recall information by a specific recall question ID within a text.
|
||||
export const findRecallInfoById = (text: string, id: string): string | null => {
|
||||
const pattern = new RegExp(`#recall:${id}\\/fallback:(\\S*)#`, "g");
|
||||
const match = text.match(pattern);
|
||||
return match ? match[0] : null;
|
||||
};
|
||||
|
||||
// Converts recall information in a headline to a corresponding recall question headline, with or without a slash.
|
||||
export const recallToHeadline = (headline: string, survey: TSurvey, withSlash: boolean): string => {
|
||||
let newHeadline = headline;
|
||||
if (!headline.includes("#recall:")) return headline;
|
||||
|
||||
while (newHeadline.includes("#recall:")) {
|
||||
const recallInfo = extractRecallInfo(newHeadline);
|
||||
if (recallInfo) {
|
||||
const questionId = extractId(recallInfo);
|
||||
let questionHeadline = survey.questions.find((question) => question.id === questionId)?.headline;
|
||||
while (questionHeadline?.includes("#recall:")) {
|
||||
const recallInfo = extractRecallInfo(questionHeadline);
|
||||
if (recallInfo) {
|
||||
questionHeadline = questionHeadline.replaceAll(recallInfo, "___");
|
||||
}
|
||||
}
|
||||
if (withSlash) {
|
||||
newHeadline = newHeadline.replace(recallInfo, `/${questionHeadline}\\`);
|
||||
} else {
|
||||
newHeadline = newHeadline.replace(recallInfo, `@${questionHeadline}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newHeadline;
|
||||
};
|
||||
|
||||
// Replaces recall information in a survey question's headline with an ___.
|
||||
export const replaceRecallInfoWithUnderline = (recallQuestion: TSurveyQuestion): TSurveyQuestion => {
|
||||
while (recallQuestion.headline.includes("#recall:")) {
|
||||
const recallInfo = extractRecallInfo(recallQuestion.headline);
|
||||
if (recallInfo) {
|
||||
recallQuestion.headline = recallQuestion.headline.replace(recallInfo, "___");
|
||||
}
|
||||
}
|
||||
return recallQuestion;
|
||||
};
|
||||
|
||||
// Checks for survey questions with a "recall" pattern but no fallback value.
|
||||
export const checkForEmptyFallBackValue = (survey: TSurvey): TSurveyQuestion | null => {
|
||||
const questions = survey.questions;
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const question = questions[i];
|
||||
if (question.headline.includes("#recall:") && !extractFallbackValue(question.headline)) {
|
||||
return question;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Processes each question in a survey to ensure headlines are formatted correctly for recall and return the modified survey.
|
||||
export const checkForRecallInHeadline = (survey: TSurvey): TSurvey => {
|
||||
const modifiedSurvey = { ...survey };
|
||||
modifiedSurvey.questions.forEach((question) => {
|
||||
question.headline = recallToHeadline(question.headline, modifiedSurvey, false);
|
||||
});
|
||||
return modifiedSurvey;
|
||||
};
|
||||
|
||||
// Retrieves an array of survey questions referenced in a text containing recall information.
|
||||
export const getRecallQuestions = (text: string, survey: TSurvey): TSurveyQuestion[] => {
|
||||
if (!text.includes("#recall:")) return [];
|
||||
|
||||
const ids = extractIds(text);
|
||||
let recallQuestionArray: TSurveyQuestion[] = [];
|
||||
ids.forEach((questionId) => {
|
||||
let recallQuestion = survey.questions.find((question) => question.id === questionId);
|
||||
if (recallQuestion) {
|
||||
let recallQuestionTemp = { ...recallQuestion };
|
||||
recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp);
|
||||
recallQuestionArray.push(recallQuestionTemp);
|
||||
}
|
||||
});
|
||||
return recallQuestionArray;
|
||||
};
|
||||
|
||||
// Constructs a fallbacks object from a text containing multiple recall and fallback patterns.
|
||||
export const getFallbackValues = (text: string): fallbacks => {
|
||||
if (!text.includes("#recall:")) return {};
|
||||
const pattern = /#recall:([A-Za-z0-9]+)\/fallback:([\S*]+)#/g;
|
||||
let match;
|
||||
const fallbacks: fallbacks = {};
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
const id = match[1];
|
||||
const fallbackValue = match[2];
|
||||
fallbacks[id] = fallbackValue;
|
||||
}
|
||||
return fallbacks;
|
||||
};
|
||||
|
||||
// Transforms headlines in a text to their corresponding recall information.
|
||||
export const headlineToRecall = (
|
||||
text: string,
|
||||
recallQuestions: TSurveyQuestion[],
|
||||
fallbacks: fallbacks
|
||||
): string => {
|
||||
recallQuestions.forEach((recallQuestion) => {
|
||||
const recallInfo = `#recall:${recallQuestion.id}/fallback:${fallbacks[recallQuestion.id]}#`;
|
||||
text = text.replace(`@${recallQuestion.headline}`, recallInfo);
|
||||
});
|
||||
return text;
|
||||
};
|
||||
|
||||
// Custom hook to synchronize the horizontal scroll position of two elements.
|
||||
export const useSyncScroll = (
|
||||
highlightContainerRef: RefObject<HTMLElement>,
|
||||
inputRef: RefObject<HTMLElement>,
|
||||
text: string
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const syncScrollPosition = () => {
|
||||
if (highlightContainerRef.current && inputRef.current) {
|
||||
highlightContainerRef.current.scrollLeft = inputRef.current.scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
const sourceElement = inputRef.current;
|
||||
if (sourceElement) {
|
||||
sourceElement.addEventListener("scroll", syncScrollPosition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (sourceElement) {
|
||||
sourceElement.removeEventListener("scroll", syncScrollPosition);
|
||||
}
|
||||
};
|
||||
}, [inputRef, highlightContainerRef, text]);
|
||||
};
|
||||
@@ -6,7 +6,10 @@ import { cn } from "@/lib/utils";
|
||||
import { SurveyBaseProps } from "@/types/props";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { formatDateWithOrdinal, isValidDateString } from "@formbricks/lib/utils/datetime";
|
||||
import { extractFallbackValue, extractId, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import type { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
import QuestionConditional from "./QuestionConditional";
|
||||
import ThankYouCard from "./ThankYouCard";
|
||||
@@ -110,6 +113,37 @@ export function Survey({
|
||||
onActiveQuestionChange(nextQuestionId);
|
||||
};
|
||||
|
||||
const replaceRecallInfo = (text: string) => {
|
||||
while (text.includes("recall:")) {
|
||||
const recallInfo = extractRecallInfo(text);
|
||||
if (recallInfo) {
|
||||
const questionId = extractId(recallInfo);
|
||||
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
|
||||
let value = questionId && responseData[questionId] ? (responseData[questionId] as string) : fallback;
|
||||
|
||||
if (isValidDateString(value)) {
|
||||
value = formatDateWithOrdinal(new Date(value));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value = value.join(", ");
|
||||
}
|
||||
text = text.replace(recallInfo, value);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const parseRecallInformation = (question: TSurveyQuestion) => {
|
||||
const modifiedQuestion = { ...question };
|
||||
if (question.headline.includes("recall:")) {
|
||||
modifiedQuestion.headline = replaceRecallInfo(modifiedQuestion.headline);
|
||||
}
|
||||
if (question.subheader && question.subheader.includes("recall:")) {
|
||||
modifiedQuestion.subheader = replaceRecallInfo(modifiedQuestion.subheader as string);
|
||||
}
|
||||
return modifiedQuestion;
|
||||
};
|
||||
|
||||
const onBack = (): void => {
|
||||
let prevQuestionId;
|
||||
// use history if available
|
||||
@@ -142,8 +176,16 @@ export function Survey({
|
||||
} else if (questionId === "end" && survey.thankYouCard.enabled) {
|
||||
return (
|
||||
<ThankYouCard
|
||||
headline={survey.thankYouCard.headline}
|
||||
subheader={survey.thankYouCard.subheader}
|
||||
headline={
|
||||
typeof survey.thankYouCard.headline === "string"
|
||||
? replaceRecallInfo(survey.thankYouCard.headline)
|
||||
: ""
|
||||
}
|
||||
subheader={
|
||||
typeof survey.thankYouCard.subheader === "string"
|
||||
? replaceRecallInfo(survey.thankYouCard.subheader)
|
||||
: ""
|
||||
}
|
||||
redirectUrl={survey.redirectUrl}
|
||||
isRedirectDisabled={isRedirectDisabled}
|
||||
/>
|
||||
@@ -154,7 +196,7 @@ export function Survey({
|
||||
currQues && (
|
||||
<QuestionConditional
|
||||
surveyId={survey.id}
|
||||
question={currQues}
|
||||
question={parseRecallInformation(currQues)}
|
||||
value={responseData[currQues.id]}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -25,6 +25,7 @@ interface CTAQuestionProps {
|
||||
export default function CTAQuestion({
|
||||
question,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onBack,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
@@ -62,6 +63,7 @@ export default function CTAQuestion({
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: "dismissed" }, updatedTtcObj);
|
||||
onChange({ [question.id]: "dismissed" });
|
||||
}}
|
||||
className="text-heading focus:ring-focus mr-4 flex items-center rounded-md px-3 py-3 text-base font-medium leading-4 hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2">
|
||||
{question.dismissButtonLabel || "Skip"}
|
||||
@@ -78,6 +80,7 @@ export default function CTAQuestion({
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: "clicked" }, updatedTtcObj);
|
||||
onChange({ [question.id]: "clicked" });
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...pr
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"focus:border-brand flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
|
||||
"focus:border-brand-dark flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
|
||||
className,
|
||||
props.isInvalid && "border border-red-600 focus:border-red-600"
|
||||
)}
|
||||
|
||||
70
packages/ui/QuestionFormInput/components/FallbackInput.tsx
Normal file
70
packages/ui/QuestionFormInput/components/FallbackInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { RefObject } from "react";
|
||||
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
import { Button } from "../../Button";
|
||||
import { Input } from "../../Input";
|
||||
|
||||
interface FallbackInputProps {
|
||||
filteredRecallQuestions: (TSurveyQuestion | undefined)[];
|
||||
fallbacks: { [type: string]: string };
|
||||
setFallbacks: (fallbacks: { [type: string]: string }) => void;
|
||||
fallbackInputRef: RefObject<HTMLInputElement>;
|
||||
addFallback: () => void;
|
||||
}
|
||||
|
||||
export function FallbackInput({
|
||||
filteredRecallQuestions,
|
||||
fallbacks,
|
||||
setFallbacks,
|
||||
fallbackInputRef,
|
||||
addFallback,
|
||||
}: FallbackInputProps) {
|
||||
return (
|
||||
<div className="fixed z-30 mt-1 rounded-md border border-slate-300 bg-slate-50 p-3 text-xs">
|
||||
<p className="font-medium">Add a placeholder to show if the question gets skipped:</p>
|
||||
{filteredRecallQuestions.map((recallQuestion) => {
|
||||
if (!recallQuestion) return;
|
||||
return (
|
||||
<div className="mt-2 flex flex-col" key={recallQuestion.id}>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="placeholder:text-md h-full bg-white"
|
||||
ref={fallbackInputRef}
|
||||
id="fallback"
|
||||
value={fallbacks[recallQuestion.id]?.replaceAll("nbsp", " ")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
e.preventDefault();
|
||||
addFallback();
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const newFallbacks = { ...fallbacks };
|
||||
newFallbacks[recallQuestion.id] = e.target.value;
|
||||
setFallbacks(newFallbacks);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
className="mt-2 h-full py-2"
|
||||
disabled={
|
||||
Object.values(fallbacks)
|
||||
.map((value) => value.trim())
|
||||
.includes("") || Object.entries(fallbacks).length === 0
|
||||
}
|
||||
variant="darkCTA"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
addFallback();
|
||||
}}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
ListBulletIcon,
|
||||
PhoneIcon,
|
||||
PresentationChartBarIcon,
|
||||
QueueListIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { RefObject, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { replaceRecallInfoWithUnderline } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
const questionIconMapping = {
|
||||
openText: ChatBubbleBottomCenterTextIcon,
|
||||
multipleChoiceSingle: QueueListIcon,
|
||||
multipleChoiceMulti: ListBulletIcon,
|
||||
rating: StarIcon,
|
||||
nps: PresentationChartBarIcon,
|
||||
date: CalendarDaysIcon,
|
||||
cal: PhoneIcon,
|
||||
};
|
||||
|
||||
interface RecallQuestionSelectProps {
|
||||
localSurvey: TSurvey;
|
||||
questionId: string;
|
||||
addRecallQuestion: (question: TSurveyQuestion) => void;
|
||||
setShowQuestionSelect: (show: boolean) => void;
|
||||
showQuestionSelect: boolean;
|
||||
inputRef: RefObject<HTMLInputElement>;
|
||||
recallQuestions: TSurveyQuestion[];
|
||||
}
|
||||
|
||||
export default function RecallQuestionSelect({
|
||||
localSurvey,
|
||||
questionId,
|
||||
addRecallQuestion,
|
||||
setShowQuestionSelect,
|
||||
showQuestionSelect,
|
||||
inputRef,
|
||||
recallQuestions,
|
||||
}: RecallQuestionSelectProps) {
|
||||
const [focusedQuestionIdx, setFocusedQuestionIdx] = useState(0); // New state for managing focus
|
||||
const isNotAllowedQuestionType = (question: TSurveyQuestion) => {
|
||||
return (
|
||||
question.type === "fileUpload" ||
|
||||
question.type === "cta" ||
|
||||
question.type === "consent" ||
|
||||
question.type === "pictureSelection" ||
|
||||
question.type === "cal"
|
||||
);
|
||||
};
|
||||
|
||||
const recallQuestionIds = useMemo(() => {
|
||||
return recallQuestions.map((recallQuestion) => recallQuestion.id);
|
||||
}, [recallQuestions]);
|
||||
|
||||
// function to remove some specific type of questions (fileUpload, imageSelect etc) from the list of questions to recall from and few other checks
|
||||
const filteredRecallQuestions = useMemo(() => {
|
||||
const idx =
|
||||
questionId === "end"
|
||||
? localSurvey.questions.length
|
||||
: localSurvey.questions.findIndex((recallQuestion) => recallQuestion.id === questionId);
|
||||
const filteredQuestions = localSurvey.questions.filter((question, index) => {
|
||||
const notAllowed = isNotAllowedQuestionType(question);
|
||||
return (
|
||||
!recallQuestionIds.includes(question.id) && !notAllowed && question.id !== questionId && idx > index
|
||||
);
|
||||
});
|
||||
return filteredQuestions;
|
||||
}, [localSurvey.questions, questionId, recallQuestionIds]);
|
||||
|
||||
// function to modify headline (recallInfo to corresponding headline)
|
||||
const getRecallHeadline = (question: TSurveyQuestion): TSurveyQuestion => {
|
||||
let questionTemp = { ...question };
|
||||
questionTemp = replaceRecallInfoWithUnderline(questionTemp);
|
||||
return questionTemp;
|
||||
};
|
||||
|
||||
// function to handle key press
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (showQuestionSelect) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setFocusedQuestionIdx((prevIdx) => (prevIdx + 1) % filteredRecallQuestions.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setFocusedQuestionIdx((prevIdx) =>
|
||||
prevIdx === 0 ? filteredRecallQuestions.length - 1 : prevIdx - 1
|
||||
);
|
||||
} else if (event.key === "Enter") {
|
||||
const selectedQuestion = filteredRecallQuestions[focusedQuestionIdx];
|
||||
setShowQuestionSelect(false);
|
||||
if (!selectedQuestion) return;
|
||||
addRecallQuestion(selectedQuestion);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const inputElement = inputRef.current;
|
||||
inputElement?.addEventListener("keydown", handleKeyPress);
|
||||
|
||||
return () => {
|
||||
inputElement?.removeEventListener("keydown", handleKeyPress);
|
||||
};
|
||||
}, [showQuestionSelect, localSurvey.questions, focusedQuestionIdx]);
|
||||
|
||||
return (
|
||||
<div className="absolute z-30 mt-1 flex max-h-[50%] max-w-[85%] flex-col overflow-y-auto rounded-md border border-slate-300 bg-slate-50 p-3 text-xs ">
|
||||
{filteredRecallQuestions.length === 0 ? (
|
||||
<p className="font-medium text-slate-900">There is no information to recall yet 🤷</p>
|
||||
) : (
|
||||
<p className="mb-2 font-medium">Recall Information from...</p>
|
||||
)}
|
||||
<div>
|
||||
{filteredRecallQuestions.map((q, idx) => {
|
||||
const isFocused = idx === focusedQuestionIdx;
|
||||
const IconComponent = questionIconMapping[q.type as keyof typeof questionIconMapping];
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className={`flex max-w-full cursor-pointer items-center rounded-md px-3 py-2 ${
|
||||
isFocused ? "bg-slate-200" : "hover:bg-slate-200 "
|
||||
}`}
|
||||
onClick={() => {
|
||||
addRecallQuestion(q);
|
||||
setShowQuestionSelect(false);
|
||||
}}>
|
||||
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
|
||||
<div className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{getRecallHeadline(q).headline}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
333
packages/ui/QuestionFormInput/index.tsx
Normal file
333
packages/ui/QuestionFormInput/index.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import { PencilIcon } from "@heroicons/react/24/solid";
|
||||
import { ImagePlusIcon } from "lucide-react";
|
||||
import { RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
extractId,
|
||||
extractRecallInfo,
|
||||
findRecallInfoById,
|
||||
getFallbackValues,
|
||||
getRecallQuestions,
|
||||
headlineToRecall,
|
||||
recallToHeadline,
|
||||
replaceRecallInfoWithUnderline,
|
||||
useSyncScroll,
|
||||
} from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
import FileInput from "../FileInput";
|
||||
import { Input } from "../Input";
|
||||
import { Label } from "../Label";
|
||||
import { FallbackInput } from "./components/FallbackInput";
|
||||
import RecallQuestionSelect from "./components/RecallQuestionSelect";
|
||||
|
||||
interface QuestionFormInputProps {
|
||||
localSurvey: TSurvey;
|
||||
questionId: string;
|
||||
questionIdx: number;
|
||||
updateQuestion?: (questionIdx: number, data: Partial<TSurveyQuestion>) => void;
|
||||
updateSurvey?: (data: Partial<TSurveyQuestion>) => void;
|
||||
environmentId: string;
|
||||
type: string;
|
||||
isInvalid?: boolean;
|
||||
ref?: RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const QuestionFormInput = ({
|
||||
localSurvey,
|
||||
questionId,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
updateSurvey,
|
||||
isInvalid,
|
||||
environmentId,
|
||||
type,
|
||||
}: QuestionFormInputProps) => {
|
||||
const isThankYouCard = questionId === "end";
|
||||
const question = useMemo(() => {
|
||||
return isThankYouCard
|
||||
? localSurvey.thankYouCard
|
||||
: localSurvey.questions.find((question) => question.id === questionId)!;
|
||||
}, [isThankYouCard, localSurvey, questionId]);
|
||||
|
||||
const getQuestionTextBasedOnType = (): string => {
|
||||
return question[type as keyof typeof question] || "";
|
||||
};
|
||||
|
||||
const [text, setText] = useState(getQuestionTextBasedOnType() ?? "");
|
||||
const [renderedText, setRenderedText] = useState<JSX.Element[]>();
|
||||
|
||||
const highlightContainerRef = useRef<HTMLInputElement>(null);
|
||||
const fallbackInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [showImageUploader, setShowImageUploader] = useState<boolean>(
|
||||
questionId === "end" ? false : !!(question as TSurveyQuestion).imageUrl
|
||||
);
|
||||
const [showQuestionSelect, setShowQuestionSelect] = useState(false);
|
||||
const [showFallbackInput, setShowFallbackInput] = useState(false);
|
||||
const [recallQuestions, setRecallQuestions] = useState<TSurveyQuestion[]>(
|
||||
text.includes("#recall:") ? getRecallQuestions(text, localSurvey) : []
|
||||
);
|
||||
const filteredRecallQuestions = Array.from(new Set(recallQuestions.map((q) => q.id))).map((id) => {
|
||||
return recallQuestions.find((q) => q.id === id);
|
||||
});
|
||||
const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>(
|
||||
text.includes("/fallback:") ? getFallbackValues(text) : {}
|
||||
);
|
||||
|
||||
// Hook to synchronize the horizontal scroll position of highlightContainerRef and inputRef.
|
||||
useSyncScroll(highlightContainerRef, inputRef, text);
|
||||
|
||||
useEffect(() => {
|
||||
// Generates an array of headlines from recallQuestions, replacing nested recall questions with '___' .
|
||||
const recallQuestionHeadlines = recallQuestions.flatMap((recallQuestion) => {
|
||||
if (!recallQuestion.headline.includes("#recall:")) {
|
||||
return [recallQuestion.headline];
|
||||
}
|
||||
const recallQuestionText = (recallQuestion[type as keyof typeof recallQuestion] as string) || "";
|
||||
const recallInfo = extractRecallInfo(recallQuestionText);
|
||||
|
||||
if (recallInfo) {
|
||||
const recallQuestionId = extractId(recallInfo);
|
||||
const recallQuestion = localSurvey.questions.find((question) => question.id === recallQuestionId);
|
||||
|
||||
if (recallQuestion) {
|
||||
return [recallQuestionText.replace(recallInfo, `___`)];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Constructs an array of JSX elements representing segmented parts of text, interspersed with special formatted spans for recall headlines.
|
||||
const processInput = (): JSX.Element[] => {
|
||||
const parts: JSX.Element[] = [];
|
||||
let remainingText: string = text ?? "";
|
||||
remainingText = recallToHeadline(remainingText, localSurvey, false);
|
||||
filterRecallQuestions(remainingText);
|
||||
recallQuestionHeadlines.forEach((headline) => {
|
||||
const index = remainingText.indexOf("@" + headline);
|
||||
if (index !== -1) {
|
||||
if (index > 0) {
|
||||
parts.push(
|
||||
<span key={parts.length} className="whitespace-pre">
|
||||
{remainingText.substring(0, index)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
<span
|
||||
className="z-30 flex cursor-pointer items-center justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
|
||||
key={parts.length}>
|
||||
{"@" + headline}
|
||||
</span>
|
||||
);
|
||||
remainingText = remainingText.substring(index + headline.length + 1);
|
||||
}
|
||||
});
|
||||
if (remainingText.length) {
|
||||
parts.push(
|
||||
<span className="whitespace-pre" key={parts.length}>
|
||||
{remainingText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
setRenderedText(processInput());
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fallbackInputRef.current) {
|
||||
fallbackInputRef.current.focus();
|
||||
}
|
||||
}, [showFallbackInput]);
|
||||
|
||||
const checkForRecallSymbol = () => {
|
||||
const pattern = /(^|\s)@(\s|$)/;
|
||||
if (pattern.test(text)) {
|
||||
setShowQuestionSelect(true);
|
||||
} else {
|
||||
setShowQuestionSelect(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Adds a new recall question to the recallQuestions array, updates fallbacks, modifies the text with recall details.
|
||||
const addRecallQuestion = (recallQuestion: TSurveyQuestion) => {
|
||||
let recallQuestionTemp = { ...recallQuestion };
|
||||
recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp);
|
||||
setRecallQuestions((prevQuestions) => {
|
||||
const updatedQuestions = [...prevQuestions, recallQuestionTemp];
|
||||
return updatedQuestions;
|
||||
});
|
||||
if (!Object.keys(fallbacks).includes(recallQuestion.id)) {
|
||||
setFallbacks((prevFallbacks) => ({
|
||||
...prevFallbacks,
|
||||
[recallQuestion.id]: "",
|
||||
}));
|
||||
}
|
||||
setShowQuestionSelect(false);
|
||||
const modifiedHeadlineWithId = getQuestionTextBasedOnType().replace(
|
||||
"@",
|
||||
`#recall:${recallQuestion.id}/fallback:# `
|
||||
);
|
||||
updateQuestionDetails(modifiedHeadlineWithId);
|
||||
|
||||
const modifiedHeadlineWithName = recallToHeadline(modifiedHeadlineWithId, localSurvey, false);
|
||||
setText(modifiedHeadlineWithName);
|
||||
setShowFallbackInput(true);
|
||||
};
|
||||
|
||||
// Filters and updates the list of recall questions based on their presence in the given text, also managing related text and fallback states.
|
||||
const filterRecallQuestions = (text: string) => {
|
||||
let includedQuestions: TSurveyQuestion[] = [];
|
||||
recallQuestions.forEach((recallQuestion) => {
|
||||
if (text.includes(`@${recallQuestion.headline}`)) {
|
||||
includedQuestions.push(recallQuestion);
|
||||
} else {
|
||||
const questionToRemove = recallQuestion.headline.slice(0, -1);
|
||||
const newText = text.replace(`@${questionToRemove}`, "");
|
||||
setText(newText);
|
||||
updateQuestionDetails(newText);
|
||||
let updatedFallback = { ...fallbacks };
|
||||
delete updatedFallback[recallQuestion.id];
|
||||
setFallbacks(updatedFallback);
|
||||
}
|
||||
});
|
||||
setRecallQuestions(includedQuestions);
|
||||
};
|
||||
|
||||
const addFallback = () => {
|
||||
let headlineWithFallback = getQuestionTextBasedOnType();
|
||||
filteredRecallQuestions.forEach((recallQuestion) => {
|
||||
if (recallQuestion) {
|
||||
const recallInfo = findRecallInfoById(getQuestionTextBasedOnType(), recallQuestion!.id);
|
||||
if (recallInfo) {
|
||||
let fallBackValue = fallbacks[recallQuestion.id].trim();
|
||||
fallBackValue = fallBackValue.replace(/ /g, "nbsp");
|
||||
let updatedFallback = { ...fallbacks };
|
||||
updatedFallback[recallQuestion.id] = fallBackValue;
|
||||
setFallbacks(updatedFallback);
|
||||
headlineWithFallback = headlineWithFallback.replace(
|
||||
recallInfo,
|
||||
`#recall:${recallQuestion?.id}/fallback:${fallBackValue}#`
|
||||
);
|
||||
updateQuestionDetails(headlineWithFallback);
|
||||
}
|
||||
}
|
||||
});
|
||||
setShowFallbackInput(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkForRecallSymbol();
|
||||
}, [text]);
|
||||
|
||||
// updation of questions and Thank You Card is done in a different manner, so for question we use updateQuestion and for ThankYouCard we use updateSurvey
|
||||
const updateQuestionDetails = (updatedText: string) => {
|
||||
if (isThankYouCard) {
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ [type]: updatedText });
|
||||
}
|
||||
} else {
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, {
|
||||
[type]: updatedText,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 w-full">
|
||||
<Label htmlFor="headline">{type === "headline" ? "Question" : "Description"}</Label>
|
||||
<div className="mt-2 flex flex-col gap-6 overflow-hidden">
|
||||
{showImageUploader && (
|
||||
<FileInput
|
||||
id="question-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[] | undefined) => {
|
||||
if (updateQuestion && url) {
|
||||
updateQuestion(questionIdx, { imageUrl: url[0] });
|
||||
}
|
||||
}}
|
||||
fileUrl={isThankYouCard ? "" : (question as TSurveyQuestion).imageUrl}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="group relative w-full">
|
||||
<div className="h-10 w-full"></div>
|
||||
<div
|
||||
id="wrapper"
|
||||
ref={highlightContainerRef}
|
||||
className="no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ">
|
||||
{renderedText}
|
||||
</div>
|
||||
{getQuestionTextBasedOnType().includes("recall:") && (
|
||||
<button
|
||||
className="fixed right-14 hidden items-center rounded-b-lg bg-slate-100 px-2.5 py-1 text-xs hover:bg-slate-200 group-hover:flex"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowFallbackInput(true);
|
||||
}}>
|
||||
Edit Recall
|
||||
<PencilIcon className="ml-2 h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<Input
|
||||
className="absolute top-0 text-black caret-black"
|
||||
placeholder={
|
||||
type === "headline"
|
||||
? "Your question here. Recall information with @"
|
||||
: "Your description here. Recall information with @"
|
||||
}
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
id={type}
|
||||
name={type}
|
||||
aria-label={type === "headline" ? "Question" : "Description"}
|
||||
autoComplete={showQuestionSelect ? "off" : "on"}
|
||||
value={recallToHeadline(text ?? "", localSurvey, false)}
|
||||
onChange={(e) => {
|
||||
setText(recallToHeadline(e.target.value ?? "", localSurvey, false));
|
||||
updateQuestionDetails(headlineToRecall(e.target.value, recallQuestions, fallbacks));
|
||||
}}
|
||||
isInvalid={isInvalid && text.trim() === ""}
|
||||
/>
|
||||
{!showQuestionSelect && showFallbackInput && recallQuestions.length > 0 && (
|
||||
<FallbackInput
|
||||
filteredRecallQuestions={filteredRecallQuestions}
|
||||
fallbacks={fallbacks}
|
||||
setFallbacks={setFallbacks}
|
||||
fallbackInputRef={fallbackInputRef}
|
||||
addFallback={addFallback}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{type === "headline" && (
|
||||
<ImagePlusIcon
|
||||
aria-label="Toggle image uploader"
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => setShowImageUploader((prev) => !prev)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showQuestionSelect && (
|
||||
<RecallQuestionSelect
|
||||
localSurvey={localSurvey}
|
||||
questionId={questionId}
|
||||
addRecallQuestion={addRecallQuestion}
|
||||
setShowQuestionSelect={setShowQuestionSelect}
|
||||
showQuestionSelect={showQuestionSelect}
|
||||
inputRef={inputRef}
|
||||
recallQuestions={recallQuestions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default QuestionFormInput;
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/react-library.json",
|
||||
"include": ["."],
|
||||
"exclude": ["build", "node_modules"]
|
||||
"exclude": ["build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2021.String"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user