feat: recall from hidden fields and attributes (#2601)

Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2024-05-27 20:58:43 +05:30
committed by GitHub
parent f917d2171e
commit 5b78487b94
77 changed files with 1562 additions and 836 deletions

View File

@@ -4,6 +4,7 @@ import { PlusIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
@@ -18,6 +19,7 @@ interface AddressQuestionFormProps {
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
attributeClasses: TAttributeClass[];
}
export const AddressQuestionForm = ({
@@ -28,6 +30,7 @@ export const AddressQuestionForm = ({
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: AddressQuestionFormProps): JSX.Element => {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
@@ -43,6 +46,7 @@ export const AddressQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
@@ -58,6 +62,7 @@ export const AddressQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>

View File

@@ -1,3 +1,4 @@
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
import { LogicEditor } from "./LogicEditor";
@@ -8,6 +9,7 @@ interface AdvancedSettingsProps {
questionIdx: number;
localSurvey: TSurvey;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
attributeClasses: TAttributeClass[];
}
export const AdvancedSettings = ({
@@ -15,6 +17,7 @@ export const AdvancedSettings = ({
questionIdx,
localSurvey,
updateQuestion,
attributeClasses,
}: AdvancedSettingsProps) => {
return (
<div>
@@ -24,6 +27,7 @@ export const AdvancedSettings = ({
updateQuestion={updateQuestion}
localSurvey={localSurvey}
questionIdx={questionIdx}
attributeClasses={attributeClasses}
/>
</div>

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multiLanguage/components/LocalizedEditor";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
@@ -18,6 +19,7 @@ interface CTAQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const CTAQuestionForm = ({
@@ -29,6 +31,7 @@ export const CTAQuestionForm = ({
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: CTAQuestionFormProps): JSX.Element => {
const [firstRender, setFirstRender] = useState(true);
@@ -43,6 +46,7 @@ export const CTAQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div className="mt-3">
@@ -95,6 +99,7 @@ export const CTAQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
{questionIdx !== 0 && (
@@ -109,6 +114,7 @@ export const CTAQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
)}
</div>
@@ -143,6 +149,7 @@ export const CTAQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { PlusIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -17,6 +18,7 @@ interface CalQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const CalQuestionForm = ({
@@ -27,6 +29,7 @@ export const CalQuestionForm = ({
selectedLanguageCode,
setSelectedLanguageCode,
isInvalid,
attributeClasses,
}: CalQuestionFormProps): JSX.Element => {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -42,6 +45,7 @@ export const CalQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
{showSubheader && (
@@ -56,6 +60,7 @@ export const CalQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multiLanguage/components/LocalizedEditor";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
@@ -15,6 +16,7 @@ interface ConsentQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const ConsentQuestionForm = ({
@@ -25,6 +27,7 @@ export const ConsentQuestionForm = ({
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: ConsentQuestionFormProps): JSX.Element => {
const [firstRender, setFirstRender] = useState(true);
@@ -39,6 +42,7 @@ export const ConsentQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div className="mt-3">
@@ -70,6 +74,7 @@ export const ConsentQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</form>
);

View File

@@ -2,6 +2,7 @@ import { PlusIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
@@ -17,6 +18,7 @@ interface IDateQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
const dateOptions = [
@@ -42,6 +44,7 @@ export const DateQuestionForm = ({
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: IDateQuestionFormProps): JSX.Element => {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -57,6 +60,7 @@ export const DateQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
{showSubheader && (
@@ -71,6 +75,7 @@ export const DateQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>

View File

@@ -5,6 +5,7 @@ import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
@@ -19,6 +20,7 @@ interface EditThankYouCardProps {
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
attributeClasses: TAttributeClass[];
}
export const EditThankYouCard = ({
@@ -29,6 +31,7 @@ export const EditThankYouCard = ({
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: EditThankYouCardProps) => {
// const [open, setOpen] = useState(false);
let open = activeQuestionId == "end";
@@ -117,6 +120,7 @@ export const EditThankYouCard = ({
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<QuestionFormInput
@@ -128,6 +132,7 @@ export const EditThankYouCard = ({
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div className="mt-4">
<div className="flex items-center space-x-1">
@@ -170,6 +175,7 @@ export const EditThankYouCard = ({
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
<div className="space-y-2">

View File

@@ -6,6 +6,7 @@ import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multiLanguage/components/LocalizedEditor";
import { cn } from "@formbricks/lib/cn";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { FileInput } from "@formbricks/ui/FileInput";
import { Label } from "@formbricks/ui/Label";
@@ -20,6 +21,7 @@ interface EditWelcomeCardProps {
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
attributeClasses: TAttributeClass[];
}
export const EditWelcomeCard = ({
@@ -30,6 +32,7 @@ export const EditWelcomeCard = ({
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: EditWelcomeCardProps) => {
const [firstRender, setFirstRender] = useState(true);
const path = usePathname();
@@ -130,6 +133,7 @@ export const EditWelcomeCard = ({
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
<div className="mt-3">
@@ -164,6 +168,7 @@ export const EditWelcomeCard = ({
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { toast } from "react-hot-toast";
import { extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { createI18nString } from "@formbricks/lib/i18n/utils";
import { useGetBillingInfo } from "@formbricks/lib/organization/hooks/useGetBillingInfo";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
@@ -25,6 +26,7 @@ interface FileUploadFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const FileUploadQuestionForm = ({
@@ -36,6 +38,7 @@ export const FileUploadQuestionForm = ({
product,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: FileUploadFormProps): JSX.Element => {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const [extension, setExtension] = useState("");
@@ -121,6 +124,7 @@ export const FileUploadQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
{showSubheader && (
@@ -135,6 +139,7 @@ export const FileUploadQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>

View File

@@ -94,18 +94,18 @@ export const HiddenFieldsCard = ({
<Collapsible.CollapsibleContent className="px-4 pb-6">
<div className="flex gap-2">
{localSurvey.hiddenFields?.fieldIds && localSurvey.hiddenFields?.fieldIds?.length > 0 ? (
localSurvey.hiddenFields?.fieldIds?.map((question) => {
localSurvey.hiddenFields?.fieldIds?.map((fieldId) => {
return (
<Tag
key={question}
key={fieldId}
onDelete={() => {
updateSurvey({
enabled: true,
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== question),
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
});
}}
tagId={question}
tagName={question}
tagId={fieldId}
tagName={fieldId}
/>
);
})

View File

@@ -11,7 +11,8 @@ import { toast } from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TSurvey,
TSurveyLogic,
@@ -36,6 +37,7 @@ interface LogicEditorProps {
questionIdx: number;
question: TSurveyQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
attributeClasses: TAttributeClass[];
}
type LogicConditions = {
@@ -47,11 +49,17 @@ type LogicConditions = {
};
};
export const LogicEditor = ({ localSurvey, question, questionIdx, updateQuestion }: LogicEditorProps) => {
export const LogicEditor = ({
localSurvey,
question,
questionIdx,
updateQuestion,
attributeClasses,
}: LogicEditorProps) => {
const [searchValue, setSearchValue] = useState<string>("");
localSurvey = useMemo(() => {
return checkForRecallInHeadline(localSurvey, "default");
}, [localSurvey]);
return replaceHeadlineRecall(localSurvey, "default", attributeClasses);
}, [localSurvey, attributeClasses]);
const questionValues = useMemo(() => {
if ("choices" in question) {

View File

@@ -5,6 +5,7 @@ import { useState } from "react";
import { toast } from "react-hot-toast";
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
@@ -21,6 +22,7 @@ interface MatrixQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const MatrixQuestionForm = ({
@@ -31,6 +33,7 @@ export const MatrixQuestionForm = ({
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: MatrixQuestionFormProps): JSX.Element => {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const languageCodes = extractLanguageCodes(localSurvey.languages);
@@ -110,6 +113,7 @@ export const MatrixQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
{showSubheader && (
@@ -124,6 +128,7 @@ export const MatrixQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
@@ -168,6 +173,7 @@ export const MatrixQuestionForm = ({
isInvalid={
isInvalid && !isLabelValidForAllLanguages(question.rows[index], localSurvey.languages)
}
attributeClasses={attributeClasses}
/>
{question.rows.length > 2 && (
<TrashIcon
@@ -209,6 +215,7 @@ export const MatrixQuestionForm = ({
isInvalid={
isInvalid && !isLabelValidForAllLanguages(question.columns[index], localSurvey.languages)
}
attributeClasses={attributeClasses}
/>
{question.columns.length > 2 && (
<TrashIcon

View File

@@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TI18nString,
TShuffleOption,
@@ -31,6 +32,7 @@ interface OpenQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const MultipleChoiceQuestionForm = ({
@@ -41,6 +43,7 @@ export const MultipleChoiceQuestionForm = ({
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: OpenQuestionFormProps): JSX.Element => {
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
@@ -190,6 +193,7 @@ export const MultipleChoiceQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
@@ -205,6 +209,7 @@ export const MultipleChoiceQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
@@ -283,6 +288,7 @@ export const MultipleChoiceQuestionForm = ({
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
attributeClasses={attributeClasses}
/>
))}
</div>

View File

@@ -4,6 +4,7 @@ import { PlusIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
@@ -17,6 +18,7 @@ interface NPSQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const NPSQuestionForm = ({
@@ -28,6 +30,7 @@ export const NPSQuestionForm = ({
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: NPSQuestionFormProps): JSX.Element => {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -42,6 +45,7 @@ export const NPSQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
@@ -57,6 +61,7 @@ export const NPSQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
@@ -99,6 +104,7 @@ export const NPSQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
<div className="w-full">
@@ -111,6 +117,7 @@ export const NPSQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
</div>
@@ -128,6 +135,7 @@ export const NPSQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
)}

View File

@@ -12,6 +12,7 @@ import {
import { useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TSurvey,
TSurveyOpenTextQuestion,
@@ -39,6 +40,7 @@ interface OpenQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const OpenQuestionForm = ({
@@ -49,6 +51,7 @@ export const OpenQuestionForm = ({
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: OpenQuestionFormProps): JSX.Element => {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const defaultPlaceholder = getPlaceholderByInputType(question.inputType ?? "text");
@@ -73,6 +76,7 @@ export const OpenQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
@@ -88,6 +92,7 @@ export const OpenQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
@@ -131,6 +136,7 @@ export const OpenQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { FileInput } from "@formbricks/ui/FileInput";
@@ -20,6 +21,7 @@ interface PictureSelectionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const PictureSelectionForm = ({
@@ -30,6 +32,7 @@ export const PictureSelectionForm = ({
selectedLanguageCode,
setSelectedLanguageCode,
isInvalid,
attributeClasses,
}: PictureSelectionFormProps): JSX.Element => {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const environmentId = localSurvey.environmentId;
@@ -46,6 +49,7 @@ export const PictureSelectionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
{showSubheader && (
@@ -60,6 +64,7 @@ export const PictureSelectionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>

View File

@@ -26,6 +26,7 @@ import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProduct } from "@formbricks/types/product";
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
import { Label } from "@formbricks/ui/Label";
@@ -62,6 +63,7 @@ interface QuestionCardProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const QuestionCard = ({
@@ -79,6 +81,7 @@ export const QuestionCard = ({
selectedLanguageCode,
setSelectedLanguageCode,
isInvalid,
attributeClasses,
}: QuestionCardProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: question.id,
@@ -213,13 +216,21 @@ export const QuestionCard = ({
</div>
<div>
<p className="text-sm font-semibold">
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
]
{recallToHeadline(
question.headline,
localSurvey,
true,
selectedLanguageCode,
attributeClasses
)[selectedLanguageCode]
? formatTextWithSlashes(
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
] ?? ""
recallToHeadline(
question.headline,
localSurvey,
true,
selectedLanguageCode,
attributeClasses
)[selectedLanguageCode] ?? ""
)
: getTSurveyQuestionTypeName(question.type)}
</p>
@@ -251,6 +262,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? (
<MultipleChoiceQuestionForm
@@ -262,6 +274,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? (
<MultipleChoiceQuestionForm
@@ -273,6 +286,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.NPS ? (
<NPSQuestionForm
@@ -284,6 +298,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.CTA ? (
<CTAQuestionForm
@@ -295,6 +310,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.Rating ? (
<RatingQuestionForm
@@ -306,6 +322,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.Consent ? (
<ConsentQuestionForm
@@ -316,6 +333,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.Date ? (
<DateQuestionForm
@@ -327,6 +345,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.PictureSelection ? (
<PictureSelectionForm
@@ -338,6 +357,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.FileUpload ? (
<FileUploadQuestionForm
@@ -350,6 +370,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.Cal ? (
<CalQuestionForm
@@ -361,6 +382,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.Matrix ? (
<MatrixQuestionForm
@@ -372,6 +394,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionType.Address ? (
<AddressQuestionForm
@@ -383,6 +406,7 @@ export const QuestionCard = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : null}
<div className="mt-4">
@@ -423,6 +447,7 @@ export const QuestionCard = ({
if (questionIdx === localSurvey.questions.length - 1) return;
updateEmptyNextButtonLabels(translatedNextButtonLabel);
}}
attributeClasses={attributeClasses}
/>
</div>
{questionIdx !== 0 && (
@@ -437,6 +462,7 @@ export const QuestionCard = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
)}
</div>
@@ -456,6 +482,7 @@ export const QuestionCard = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
)}
@@ -465,6 +492,7 @@ export const QuestionCard = ({
questionIdx={questionIdx}
localSurvey={localSurvey}
updateQuestion={updateQuestion}
attributeClasses={attributeClasses}
/>
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -1,5 +1,6 @@
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
@@ -18,6 +19,7 @@ interface QuestionsDraggableProps {
setSelectedLanguageCode: (language: string) => void;
invalidQuestions: string[] | null;
internalQuestionIdMap: Record<string, string>;
attributeClasses: TAttributeClass[];
}
export const QuestionsDroppable = ({
@@ -33,6 +35,7 @@ export const QuestionsDroppable = ({
setSelectedLanguageCode,
updateQuestion,
internalQuestionIdMap,
attributeClasses,
}: QuestionsDraggableProps) => {
return (
<div className="group mb-5 grid w-full gap-5">
@@ -54,6 +57,7 @@ export const QuestionsDroppable = ({
setActiveQuestionId={setActiveQuestionId}
lastQuestion={questionIdx === localSurvey.questions.length - 1}
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
attributeClasses={attributeClasses}
/>
))}
</SortableContext>

View File

@@ -16,6 +16,7 @@ import { MultiLanguageCard } from "@formbricks/ee/multiLanguage/components/Multi
import { extractLanguageCodes, getLocalizedValue, translateQuestion } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
@@ -43,6 +44,7 @@ interface QuestionsViewProps {
setSelectedLanguageCode: (languageCode: string) => void;
isMultiLanguageAllowed?: boolean;
isFormbricksCloud: boolean;
attributeClasses: TAttributeClass[];
}
export const QuestionsView = ({
@@ -57,6 +59,7 @@ export const QuestionsView = ({
selectedLanguageCode,
isMultiLanguageAllowed,
isFormbricksCloud,
attributeClasses,
}: QuestionsViewProps) => {
const internalQuestionIdMap = useMemo(() => {
return localSurvey.questions.reduce((acc, question) => {
@@ -339,6 +342,7 @@ export const QuestionsView = ({
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
@@ -356,6 +360,7 @@ export const QuestionsView = ({
setActiveQuestionId={setActiveQuestionId}
invalidQuestions={invalidQuestions}
internalQuestionIdMap={internalQuestionIdMap}
attributeClasses={attributeClasses}
/>
</DndContext>
@@ -369,6 +374,7 @@ export const QuestionsView = ({
isInvalid={invalidQuestions ? invalidQuestions.includes("end") : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
attributeClasses={attributeClasses}
/>
{localSurvey.type === "link" ? (

View File

@@ -2,6 +2,7 @@ import { HashIcon, PlusIcon, SmileIcon, StarIcon, TrashIcon } from "lucide-react
import { useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyRatingQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
@@ -18,6 +19,7 @@ interface RatingQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const RatingQuestionForm = ({
@@ -28,6 +30,7 @@ export const RatingQuestionForm = ({
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: RatingQuestionFormProps) => {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -43,6 +46,7 @@ export const RatingQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
@@ -58,6 +62,7 @@ export const RatingQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
@@ -135,6 +140,7 @@ export const RatingQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
<div className="flex-1">
@@ -148,6 +154,7 @@ export const RatingQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
</div>
@@ -165,6 +172,7 @@ export const RatingQuestionForm = ({
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
)}

View File

@@ -5,6 +5,7 @@ import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { createI18nString } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TI18nString,
TSurvey,
@@ -35,6 +36,7 @@ interface ChoiceProps {
question: TSurveyMultipleChoiceQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceQuestion>) => void;
surveyLanguageCodes: string[];
attributeClasses: TAttributeClass[];
}
export const SelectQuestionChoice = ({
@@ -54,6 +56,7 @@ export const SelectQuestionChoice = ({
question,
surveyLanguageCodes,
updateQuestion,
attributeClasses,
}: ChoiceProps) => {
const isDragDisabled = choice.id === "other";
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
@@ -100,6 +103,7 @@ export const SelectQuestionChoice = ({
isInvalid && !isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages)
}
className={`${choice.id === "other" ? "border border-dashed" : ""} mt-0`}
attributeClasses={attributeClasses}
/>
{choice.id === "other" && (
<QuestionFormInput
@@ -119,6 +123,7 @@ export const SelectQuestionChoice = ({
isInvalid && !isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages)
}
className="border border-dashed"
attributeClasses={attributeClasses}
/>
)}
</div>

View File

@@ -163,6 +163,7 @@ export const SurveyEditor = ({
setSelectedLanguageCode={setSelectedLanguageCode}
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
attributeClasses={attributeClasses}
/>
)}

View File

@@ -4,6 +4,7 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
@@ -12,9 +13,15 @@ interface ResponseSectionProps {
environment: TEnvironment;
personId: string;
environmentTags: TTag[];
attributeClasses: TAttributeClass[];
}
export const ResponseSection = async ({ environment, personId, environmentTags }: ResponseSectionProps) => {
export const ResponseSection = async ({
environment,
personId,
environmentTags,
attributeClasses,
}: ResponseSectionProps) => {
const responses = await getResponsesByPersonId(personId);
const surveyIds = responses?.map((response) => response.surveyId) || [];
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : (await getSurveys(environment.id)) ?? [];
@@ -34,6 +41,7 @@ export const ResponseSection = async ({ environment, personId, environmentTags }
responses={responses}
environment={environment}
environmentTags={environmentTags}
attributeClasses={attributeClasses}
/>
);
};

View File

@@ -4,6 +4,7 @@ import { ResponseFeed } from "@/app/(app)/environments/[environmentId]/(people)/
import { ArrowDownUpIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
@@ -16,6 +17,7 @@ interface ResponseTimelineProps {
responses: TResponse[];
environment: TEnvironment;
environmentTags: TTag[];
attributeClasses: TAttributeClass[];
}
export const ResponseTimeline = ({
@@ -24,6 +26,7 @@ export const ResponseTimeline = ({
environment,
responses,
environmentTags,
attributeClasses,
}: ResponseTimelineProps) => {
const [sortedResponses, setSortedResponses] = useState(responses);
const toggleSortResponses = () => {
@@ -53,6 +56,7 @@ export const ResponseTimeline = ({
surveys={surveys}
user={user}
environmentTags={environmentTags}
attributeClasses={attributeClasses}
/>
</div>
);

View File

@@ -4,7 +4,8 @@ import { useEffect, useState } from "react";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
@@ -19,6 +20,7 @@ interface ResponseTimelineProps {
responses: TResponse[];
environment: TEnvironment;
environmentTags: TTag[];
attributeClasses: TAttributeClass[];
}
export const ResponseFeed = ({
@@ -27,6 +29,7 @@ export const ResponseFeed = ({
surveys,
user,
environmentTags,
attributeClasses,
}: ResponseTimelineProps) => {
const [fetchedResponses, setFetchedResponses] = useState(responses);
@@ -59,6 +62,7 @@ export const ResponseFeed = ({
environment={environment}
deleteResponse={deleteResponse}
updateResponse={updateResponse}
attributeClasses={attributeClasses}
/>
))
)}
@@ -74,6 +78,7 @@ const ResponseSurveyCard = ({
environment,
deleteResponse,
updateResponse,
attributeClasses,
}: {
response: TResponse;
surveys: TSurvey[];
@@ -82,6 +87,7 @@ const ResponseSurveyCard = ({
environment: TEnvironment;
deleteResponse: (responseId: string) => void;
updateResponse: (responseId: string, response: TResponse) => void;
attributeClasses: TAttributeClass[];
}) => {
const survey = surveys.find((survey) => {
return survey.id === response.surveyId;
@@ -95,7 +101,7 @@ const ResponseSurveyCard = ({
{survey && (
<SingleResponseCard
response={response}
survey={checkForRecallInHeadline(survey, "default")}
survey={replaceHeadlineRecall(survey, "default", attributeClasses)}
user={user}
pageType="people"
environmentTags={environmentTags}

View File

@@ -5,6 +5,7 @@ import { ResponseSection } from "@/app/(app)/environments/[environmentId]/(peopl
import { getServerSession } from "next-auth";
import { getAttributes } from "@formbricks/lib/attribute/service";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
@@ -18,7 +19,7 @@ import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
const Page = async ({ params }) => {
const [environment, environmentTags, product, session, organization, person, attributes] =
const [environment, environmentTags, product, session, organization, person, attributes, attributeClasses] =
await Promise.all([
getEnvironment(params.environmentId),
getTagsByEnvironmentId(params.environmentId),
@@ -27,6 +28,7 @@ const Page = async ({ params }) => {
getOrganizationByEnvironmentId(params.environmentId),
getPerson(params.personId),
getAttributes(params.personId),
getAttributeClasses(params.environmentId),
]);
if (!product) {
@@ -68,6 +70,7 @@ const Page = async ({ params }) => {
environment={environment}
personId={params.personId}
environmentTags={environmentTags}
attributeClasses={attributeClasses}
/>
<ActivitySection environmentId={params.environmentId} personId={params.personId} />
</div>

View File

@@ -11,7 +11,8 @@ import { Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
@@ -38,6 +39,7 @@ type AddIntegrationModalProps = {
airtableArray: TIntegrationItem[];
surveys: TSurvey[];
airtableIntegration: TIntegrationAirtable;
attributeClasses: TAttributeClass[];
} & EditModeProps;
export type IntegrationModalInputs = {
@@ -65,6 +67,7 @@ export const AddIntegrationModal = ({
airtableIntegration,
isEditMode,
defaultData,
attributeClasses,
}: AddIntegrationModalProps) => {
const router = useRouter();
const [tables, setTables] = useState<TIntegrationAirtableTables["tables"]>([]);
@@ -282,32 +285,36 @@ export const AddIntegrationModal = ({
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{checkForRecallInHeadline(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}
/>
))}
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
(question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
)
)}
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { authorize } from "@/app/(app)/environments/[environmentId]/integrations
import airtableLogo from "@/images/airtableLogo.svg";
import { useState } from "react";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
@@ -19,6 +20,7 @@ interface AirtableWrapperProps {
environment: TEnvironment;
isEnabled: boolean;
webAppUrl: string;
attributeClasses: TAttributeClass[];
}
export const AirtableWrapper = ({
@@ -29,6 +31,7 @@ export const AirtableWrapper = ({
environment,
isEnabled,
webAppUrl,
attributeClasses,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
@@ -50,6 +53,7 @@ export const AirtableWrapper = ({
airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected}
surveys={surveys}
attributeClasses={attributeClasses}
/>
) : (
<ConnectIntegration

View File

@@ -9,6 +9,7 @@ import { useState } from "react";
import { toast } from "react-hot-toast";
import { timeSince } from "@formbricks/lib/time";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
@@ -24,12 +25,21 @@ interface ManageIntegrationProps {
setIsConnected: (data: boolean) => void;
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
attributeClasses: TAttributeClass[];
}
const tableHeaders = ["Survey", "Table Name", "Questions", "Updated At"];
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const {
airtableIntegration,
environment,
environmentId,
setIsConnected,
surveys,
airtableArray,
attributeClasses,
} = props;
const [isDeleting, setisDeleting] = useState(false);
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
@@ -142,6 +152,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
environmentId={environmentId}
surveys={surveys}
airtableIntegration={airtableIntegration}
attributeClasses={attributeClasses}
{...data}
/>
)}

View File

@@ -1,6 +1,7 @@
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getAirtableTables } from "@formbricks/lib/airtable/service";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service";
@@ -14,10 +15,11 @@ import { PageHeader } from "@formbricks/ui/PageHeader";
const Page = async ({ params }) => {
const isEnabled = !!AIRTABLE_CLIENT_ID;
const [surveys, integrations, environment] = await Promise.all([
const [surveys, integrations, environment, attributeClasses] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getEnvironment(params.environmentId),
getAttributeClasses(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
@@ -49,6 +51,7 @@ const Page = async ({ params }) => {
surveys={surveys}
environment={environment}
webAppUrl={WEBAPP_URL}
attributeClasses={attributeClasses}
/>
</div>
</PageContentWrapper>

View File

@@ -12,7 +12,8 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
@@ -33,6 +34,7 @@ interface AddIntegrationModalProps {
setOpen: (v: boolean) => void;
googleSheetIntegration: TIntegrationGoogleSheets;
selectedIntegration?: (TIntegrationGoogleSheetsConfigData & { index: number }) | null;
attributeClasses: TAttributeClass[];
}
export const AddIntegrationModal = ({
@@ -42,6 +44,7 @@ export const AddIntegrationModal = ({
setOpen,
googleSheetIntegration,
selectedIntegration,
attributeClasses,
}: AddIntegrationModalProps) => {
const integrationData = {
spreadsheetId: "",
@@ -216,25 +219,27 @@ export const AddIntegrationModal = ({
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{checkForRecallInHeadline(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2 w-[30rem] truncate">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
))}
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
(question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2 w-[30rem] truncate">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)
)}
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { authorize } from "@/app/(app)/environments/[environmentId]/integrations
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { useState } from "react";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
@@ -21,6 +22,7 @@ interface GoogleSheetWrapperProps {
surveys: TSurvey[];
googleSheetIntegration?: TIntegrationGoogleSheets;
webAppUrl: string;
attributeClasses: TAttributeClass[];
}
export const GoogleSheetWrapper = ({
@@ -29,6 +31,7 @@ export const GoogleSheetWrapper = ({
surveys,
googleSheetIntegration,
webAppUrl,
attributeClasses,
}: GoogleSheetWrapperProps) => {
const [isConnected, setIsConnected] = useState(
googleSheetIntegration ? googleSheetIntegration.config?.key : false
@@ -57,6 +60,7 @@ export const GoogleSheetWrapper = ({
setOpen={setModalOpen}
googleSheetIntegration={googleSheetIntegration}
selectedIntegration={selectedIntegration}
attributeClasses={attributeClasses}
/>
<ManageIntegration
environment={environment}

View File

@@ -1,5 +1,6 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
@@ -17,10 +18,11 @@ import { PageHeader } from "@formbricks/ui/PageHeader";
const Page = async ({ params }) => {
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const [surveys, integrations, environment] = await Promise.all([
const [surveys, integrations, environment, attributeClasses] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getEnvironment(params.environmentId),
getAttributeClasses(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
@@ -45,6 +47,7 @@ const Page = async ({ params }) => {
surveys={surveys}
googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL}
attributeClasses={attributeClasses}
/>
</div>
</PageContentWrapper>

View File

@@ -14,7 +14,8 @@ import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationNotion,
@@ -35,6 +36,7 @@ interface AddIntegrationModalProps {
notionIntegration: TIntegrationNotion;
databases: TIntegrationNotionDatabase[];
selectedIntegration: (TIntegrationNotionConfigData & { index: number }) | null;
attributeClasses: TAttributeClass[];
}
export const AddIntegrationModal = ({
@@ -45,6 +47,7 @@ export const AddIntegrationModal = ({
notionIntegration,
databases,
selectedIntegration,
attributeClasses,
}: AddIntegrationModalProps) => {
const { handleSubmit } = useForm();
const [selectedDatabase, setSelectedDatabase] = useState<TIntegrationNotionDatabase | null>();
@@ -109,7 +112,7 @@ export const AddIntegrationModal = ({
const questionItems = useMemo(() => {
const questions = selectedSurvey
? checkForRecallInHeadline(selectedSurvey, "default")?.questions.map((q) => ({
? replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
type: q.type,

View File

@@ -5,6 +5,7 @@ import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/inte
import notionLogo from "@/images/notion.png";
import { useState } from "react";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationNotion,
@@ -23,6 +24,7 @@ interface NotionWrapperProps {
webAppUrl: string;
surveys: TSurvey[];
databasesArray: TIntegrationNotionDatabase[];
attributeClasses: TAttributeClass[];
}
export const NotionWrapper = ({
@@ -32,6 +34,7 @@ export const NotionWrapper = ({
webAppUrl,
surveys,
databasesArray,
attributeClasses,
}: NotionWrapperProps) => {
const [isModalOpen, setModalOpen] = useState(false);
const [isConnected, setIsConnected] = useState(
@@ -61,6 +64,7 @@ export const NotionWrapper = ({
notionIntegration={notionIntegration}
databases={databasesArray}
selectedIntegration={selectedIntegration}
attributeClasses={attributeClasses}
/>
<ManageIntegration
environment={environment}

View File

@@ -1,5 +1,6 @@
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import {
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
@@ -23,10 +24,11 @@ const Page = async ({ params }) => {
NOTION_AUTH_URL &&
NOTION_REDIRECT_URI
);
const [surveys, notionIntegration, environment] = await Promise.all([
const [surveys, notionIntegration, environment, attributeClasses] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getEnvironment(params.environmentId),
getAttributeClasses(params.environmentId),
]);
if (!environment) {
@@ -49,6 +51,7 @@ const Page = async ({ params }) => {
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
attributeClasses={attributeClasses}
/>
</PageContentWrapper>
);

View File

@@ -6,7 +6,8 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationSlack,
@@ -28,6 +29,7 @@ interface AddChannelMappingModalProps {
slackIntegration: TIntegrationSlack;
channels: TIntegrationItem[];
selectedIntegration?: (TIntegrationSlackConfigData & { index: number }) | null;
attributeClasses: TAttributeClass[];
}
export const AddChannelMappingModal = ({
@@ -38,6 +40,7 @@ export const AddChannelMappingModal = ({
channels,
slackIntegration,
selectedIntegration,
attributeClasses,
}: AddChannelMappingModalProps) => {
const { handleSubmit } = useForm();
@@ -223,23 +226,25 @@ export const AddChannelMappingModal = ({
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{checkForRecallInHeadline(selectedSurvey, "default")?.questions?.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
))}
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions?.map(
(question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)
)}
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { authorize } from "@/app/(app)/environments/[environmentId]/integrations
import slackLogo from "@/images/slacklogo.png";
import { useState } from "react";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
@@ -20,6 +21,7 @@ interface SlackWrapperProps {
channelsArray: TIntegrationItem[];
slackIntegration?: TIntegrationSlack;
webAppUrl: string;
attributeClasses: TAttributeClass[];
}
export const SlackWrapper = ({
@@ -29,6 +31,7 @@ export const SlackWrapper = ({
channelsArray,
slackIntegration,
webAppUrl,
attributeClasses,
}: SlackWrapperProps) => {
const [isConnected, setIsConnected] = useState(slackIntegration ? slackIntegration.config?.key : false);
const [slackChannels, setSlackChannels] = useState(channelsArray);
@@ -60,6 +63,7 @@ export const SlackWrapper = ({
channels={slackChannels}
slackIntegration={slackIntegration}
selectedIntegration={selectedIntegration}
attributeClasses={attributeClasses}
/>
<ManageIntegration
environment={environment}

View File

@@ -1,5 +1,6 @@
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrationByType } from "@formbricks/lib/integration/service";
@@ -14,10 +15,11 @@ import { PageHeader } from "@formbricks/ui/PageHeader";
const Page = async ({ params }) => {
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
const [surveys, slackIntegration, environment] = await Promise.all([
const [surveys, slackIntegration, environment, attributeClasses] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getEnvironment(params.environmentId),
getAttributeClasses(params.environmentId),
]);
if (!environment) {
@@ -41,6 +43,7 @@ const Page = async ({ params }) => {
surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL}
attributeClasses={attributeClasses}
/>
</div>
</PageContentWrapper>

View File

@@ -16,7 +16,6 @@ import {
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
@@ -65,10 +64,6 @@ export const ResponsePage = ({
const searchParams = useSearchParams();
survey = useMemo(() => {
return checkForRecallInHeadline(survey, "default");
}, [survey]);
const fetchNextPage = useCallback(async () => {
const newPage = page + 1;

View File

@@ -16,7 +16,8 @@ import {
import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys";
import { TUser } from "@formbricks/types/user";
@@ -46,6 +47,7 @@ interface SummaryPageProps {
webAppUrl: string;
user?: TUser;
totalResponseCount: number;
attributeClasses: TAttributeClass[];
}
export const SummaryPage = ({
@@ -55,6 +57,7 @@ export const SummaryPage = ({
webAppUrl,
user,
totalResponseCount,
attributeClasses,
}: SummaryPageProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
@@ -104,9 +107,9 @@ export const SummaryPage = ({
const searchParams = useSearchParams();
survey = useMemo(() => {
return checkForRecallInHeadline(survey, "default");
}, [survey]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default", attributeClasses);
}, [survey, attributeClasses]);
useEffect(() => {
if (!searchParams?.get("referer")) {
@@ -123,13 +126,13 @@ export const SummaryPage = ({
/>
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} />}
<div className="flex gap-1.5">
<CustomFilter survey={survey} />
{!isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />}
<CustomFilter survey={surveyMemoized} />
{!isSharingPage && <ResultsShareButton survey={surveyMemoized} webAppUrl={webAppUrl} user={user} />}
</div>
<SummaryList
summary={surveySummary.summary}
responseCount={responseCount}
survey={survey}
survey={surveyMemoized}
environment={environment}
fetchingSummary={isFetchingSummary}
totalResponseCount={totalResponseCount}

View File

@@ -4,6 +4,7 @@ import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surv
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
@@ -29,16 +30,17 @@ const Page = async ({ params }) => {
return notFound();
}
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
}
const environment = await getEnvironment(survey.environmentId);
const [survey, environment, attributeClasses] = await Promise.all([
getSurvey(params.surveyId),
getEnvironment(params.environmentId),
getAttributeClasses(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
if (!survey) {
throw new Error("Survey not found");
}
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
@@ -87,6 +89,7 @@ const Page = async ({ params }) => {
webAppUrl={WEBAPP_URL}
user={user}
totalResponseCount={totalResponseCount}
attributeClasses={attributeClasses}
/>
</PageContentWrapper>
);

View File

@@ -6,7 +6,7 @@ import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail }
import { CRON_SECRET } from "@formbricks/lib/constants";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { convertResponseValue } from "@formbricks/lib/responses";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import {
TWeeklySummaryEnvironmentData,
TWeeklySummaryNotificationDataSurvey,
@@ -150,6 +150,19 @@ const getProductsByOrganizationId = async (organizationId: string): Promise<TWee
id: true,
},
},
hiddenFields: true,
},
},
attributeClasses: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
archived: true,
},
},
},
@@ -187,7 +200,7 @@ const getNotificationResponse = (
const surveys: TWeeklySummaryNotificationDataSurvey[] = [];
// iterate through the surveys and calculate the overall insights
for (const survey of environment.surveys) {
const parsedSurvey = checkForRecallInHeadline(survey, "default");
const parsedSurvey = replaceHeadlineRecall(survey, "default", environment.attributeClasses);
const surveyData: TWeeklySummaryNotificationDataSurvey = {
id: parsedSurvey.id,
name: parsedSurvey.name,

View File

@@ -4,13 +4,14 @@ import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { sendResponseFinishedEmail } from "@formbricks/email";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { convertDatesInObject } from "@formbricks/lib/time";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { ZPipelineInput } from "@formbricks/types/pipelines";
import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -38,6 +39,7 @@ export const POST = async (request: Request) => {
const { environmentId, surveyId, event, response } = inputValidation.data;
const product = await getProductByEnvironmentId(environmentId);
const attributeClasses = await getAttributeClasses(environmentId);
if (!product) return;
// get all webhooks of this environment where event in triggers
@@ -106,7 +108,7 @@ export const POST = async (request: Request) => {
getIntegrations(environmentId),
getSurvey(surveyId),
]);
const survey = surveyData ? checkForRecallInHeadline(surveyData, "default") : undefined;
const survey = surveyData ? replaceHeadlineRecall(surveyData, "default", attributeClasses) : undefined;
if (integrations.length > 0 && survey) {
handleIntegrations(integrations, inputValidation.data, survey);

View File

@@ -1,10 +1,14 @@
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/app/sync/lib/posthog";
import {
replaceAttributeRecall,
replaceAttributeRecallInLegacySurveys,
} from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest, userAgent } from "next/server";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getAttribute } from "@formbricks/lib/attribute/service";
import { getAttributes } from "@formbricks/lib/attribute/service";
import {
IS_FORMBRICKS_CLOUD,
PRICING_APPSURVEYS_FREE_RESPONSES,
@@ -154,8 +158,8 @@ export const GET = async (
highlightBorderColor: product.styling.highlightBorderColor.light,
}),
};
const language = await getAttribute("language", person.id);
const attributes = await getAttributes(person.id);
const language = attributes["language"];
const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode");
// Scenario 1: Multi language and updated trigger action classes supported.
@@ -164,7 +168,9 @@ export const GET = async (
// creating state object
let state: TJsAppStateSync | TJsAppLegacyStateSync = {
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
surveys: !isInAppSurveyLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, attributes))
: [],
actionClasses,
language,
product: updatedProduct,
@@ -183,7 +189,9 @@ export const GET = async (
);
state = {
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
surveys: !isInAppSurveyLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecallInLegacySurveys(survey, attributes))
: [],
person,
noCodeActionClasses,
language,

View File

@@ -0,0 +1,81 @@
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TAttributes } from "@formbricks/types/attributes";
import { TSurvey } from "@formbricks/types/surveys";
export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes): TSurvey => {
const surveyTemp = structuredClone(survey);
const languages = Object.keys(survey.questions[0].headline);
surveyTemp.questions.forEach((question) => {
languages.forEach((language) => {
if (question.headline[language].includes("recall:")) {
question.headline[language] = parseRecallInfo(question.headline[language], attributes);
}
if (question.subheader && question.subheader[language].includes("recall:")) {
question.subheader[language] = parseRecallInfo(question.subheader[language], attributes);
}
});
});
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
languages.forEach((language) => {
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline[language].includes("recall:")) {
surveyTemp.welcomeCard.headline[language] = parseRecallInfo(
surveyTemp.welcomeCard.headline[language],
attributes
);
}
});
}
if (surveyTemp.thankYouCard.enabled) {
languages.forEach((language) => {
if (
surveyTemp.thankYouCard.headline &&
surveyTemp.thankYouCard.headline[language].includes("recall:")
) {
surveyTemp.thankYouCard.headline[language] = parseRecallInfo(
surveyTemp.thankYouCard.headline[language],
attributes
);
if (
surveyTemp.thankYouCard.subheader &&
surveyTemp.thankYouCard.subheader[language].includes("recall:")
) {
surveyTemp.thankYouCard.subheader[language] = parseRecallInfo(
surveyTemp.thankYouCard.subheader[language],
attributes
);
}
}
});
}
return surveyTemp;
};
export const replaceAttributeRecallInLegacySurveys = (
survey: TLegacySurvey,
attributes: TAttributes
): TLegacySurvey => {
const surveyTemp = structuredClone(survey);
surveyTemp.questions.forEach((question) => {
if (question.headline.includes("recall:")) {
question.headline = parseRecallInfo(question.headline, attributes);
}
if (question.subheader && question.subheader.includes("recall:")) {
question.subheader = parseRecallInfo(question.subheader, attributes);
}
});
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline.includes("recall:")) {
surveyTemp.welcomeCard.headline = parseRecallInfo(surveyTemp.welcomeCard.headline, attributes);
}
}
if (surveyTemp.thankYouCard.enabled && surveyTemp.thankYouCard.headline) {
if (surveyTemp.thankYouCard.headline && surveyTemp.thankYouCard.headline.includes("recall:")) {
surveyTemp.thankYouCard.headline = parseRecallInfo(surveyTemp.thankYouCard.headline, attributes);
if (surveyTemp.thankYouCard.subheader && surveyTemp.thankYouCard.subheader.includes("recall:")) {
surveyTemp.thankYouCard.subheader = parseRecallInfo(surveyTemp.thankYouCard.subheader, attributes);
}
}
}
return surveyTemp;
};

View File

@@ -9,8 +9,9 @@ import { useEffect, useMemo, useState } from "react";
import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import { SurveyState } from "@formbricks/lib/surveyState";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProduct } from "@formbricks/types/product";
import { TResponse, TResponseUpdate } from "@formbricks/types/responses";
import { TResponse, TResponseData, TResponseUpdate } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurvey } from "@formbricks/types/surveys";
import { ClientLogo } from "@formbricks/ui/ClientLogo";
@@ -32,6 +33,7 @@ interface LinkSurveyProps {
responseCount?: number;
verifiedEmail?: string;
languageCode: string;
attributeClasses: TAttributeClass[];
}
export const LinkSurvey = ({
@@ -45,6 +47,7 @@ export const LinkSurvey = ({
responseCount,
verifiedEmail,
languageCode,
attributeClasses,
}: LinkSurveyProps) => {
const responseId = singleUseResponse?.id;
const searchParams = useSearchParams();
@@ -120,8 +123,8 @@ export const LinkSurvey = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hiddenFieldsRecord = useMemo<Record<string, string | number | string[]> | null>(() => {
const fieldsRecord: Record<string, string | number | string[]> = {};
const hiddenFieldsRecord = useMemo<TResponseData | undefined>(() => {
const fieldsRecord: TResponseData = {};
let fieldsSet = false;
survey.hiddenFields?.fieldIds?.forEach((field) => {
@@ -133,7 +136,7 @@ export const LinkSurvey = ({
});
// Only return the record if at least one field was set.
return fieldsSet ? fieldsRecord : null;
return fieldsSet ? fieldsRecord : undefined;
}, [searchParams, survey.hiddenFields?.fieldIds]);
const getVerifiedEmail = useMemo<Record<string, string> | null>(() => {
@@ -160,6 +163,7 @@ export const LinkSurvey = ({
isErrorComponent={true}
languageCode={languageCode}
styling={product.styling}
attributeClasses={attributeClasses}
/>
);
}
@@ -170,6 +174,7 @@ export const LinkSurvey = ({
survey={survey}
languageCode={languageCode}
styling={product.styling}
attributeClasses={attributeClasses}
/>
);
}
@@ -280,6 +285,7 @@ export const LinkSurvey = ({
setQuestionId = f;
}}
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
hiddenFieldsRecord={hiddenFieldsRecord}
/>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types";
import { useCallback, useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProduct } from "@formbricks/types/product";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
@@ -26,6 +27,7 @@ interface PinScreenProps {
IS_FORMBRICKS_CLOUD: boolean;
verifiedEmail?: string;
languageCode: string;
attributeClasses: TAttributeClass[];
}
export const PinScreen = (props: PinScreenProps) => {
@@ -42,6 +44,7 @@ export const PinScreen = (props: PinScreenProps) => {
IS_FORMBRICKS_CLOUD,
verifiedEmail,
languageCode,
attributeClasses,
} = props;
const [localPinEntry, setLocalPinEntry] = useState<string>("");
@@ -122,6 +125,7 @@ export const PinScreen = (props: PinScreenProps) => {
webAppUrl={webAppUrl}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
attributeClasses={attributeClasses}
/>
</MediaBackground>
<LegalFooter

View File

@@ -7,7 +7,8 @@ import { useMemo, useState } from "react";
import { Toaster, toast } from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TProductStyling } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
@@ -20,6 +21,7 @@ interface VerifyEmailProps {
singleUseId?: string;
languageCode: string;
styling: TProductStyling;
attributeClasses: TAttributeClass[];
}
export const VerifyEmail = ({
@@ -28,10 +30,11 @@ export const VerifyEmail = ({
singleUseId,
languageCode,
styling,
attributeClasses,
}: VerifyEmailProps) => {
survey = useMemo(() => {
return checkForRecallInHeadline(survey, "default");
}, [survey]);
return replaceHeadlineRecall(survey, "default", attributeClasses);
}, [survey, attributeClasses]);
const [showPreviewQuestions, setShowPreviewQuestions] = useState(false);
const [email, setEmail] = useState<string | null>(null);

View File

@@ -8,6 +8,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getMultiLanguagePermission } from "@formbricks/ee/lib/service";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
@@ -125,6 +126,8 @@ const Page = async ({ params, searchParams }: LinkSurveyPageProps) => {
throw new Error("Product not found");
}
const attributeClasses = await getAttributeClasses(survey.environmentId);
const getLanguageCode = (): string => {
if (!langParam || !isMultiLanguageAllowed) return "default";
else {
@@ -170,6 +173,7 @@ const Page = async ({ params, searchParams }: LinkSurveyPageProps) => {
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
attributeClasses={attributeClasses}
/>
);
}
@@ -188,6 +192,7 @@ const Page = async ({ params, searchParams }: LinkSurveyPageProps) => {
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
attributeClasses={attributeClasses}
/>
<LegalFooter
IMPRINT_URL={IMPRINT_URL}

View File

@@ -18,23 +18,24 @@ const Page = async ({ params }) => {
return notFound();
}
const survey = await getSurvey(surveyId);
const [survey, environment, product, tags] = await Promise.all([
getSurvey(params.surveyId),
getEnvironment(params.environmentId),
getProductByEnvironmentId(params.environmentId),
getTagsByEnvironmentId(params.environmentId),
]);
if (!survey) {
throw new Error("Survey not found");
}
const environment = await getEnvironment(survey.environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
}
const tags = await getTagsByEnvironmentId(environment.id);
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
return (

View File

@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { notFound } from "next/navigation";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
@@ -17,18 +18,21 @@ const Page = async ({ params }) => {
return notFound();
}
const survey = await getSurvey(surveyId);
const [survey, environment, attributeClasses, product] = await Promise.all([
getSurvey(params.surveyId),
getEnvironment(params.environmentId),
getAttributeClasses(params.environmentId),
getProductByEnvironmentId(params.environmentId),
]);
if (!survey) {
throw new Error("Survey not found");
}
const environment = await getEnvironment(survey.environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
}
@@ -52,6 +56,7 @@ const Page = async ({ params }) => {
surveyId={survey.id}
webAppUrl={WEBAPP_URL}
totalResponseCount={totalResponseCount}
attributeClasses={attributeClasses}
/>
</PageContentWrapper>
</div>

View File

@@ -31,7 +31,7 @@ export const LanguageIndicator = ({
setShowLanguageDropdown(false);
};
const langaugeToBeDisplayed = surveyLanguages.find((language) => {
const languageToBeDisplayed = surveyLanguages.find((language) => {
return selectedLanguageCode === "default"
? language.default === true
: language.language.code === selectedLanguageCode;
@@ -48,7 +48,7 @@ export const LanguageIndicator = ({
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}>
{langaugeToBeDisplayed ? getLanguageLabel(langaugeToBeDisplayed?.language.code) : ""}
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed?.language.code) : ""}
<ChevronDown className="ml-1 h-4 w-4" />
</button>
{showLanguageDropdown && (
@@ -57,7 +57,7 @@ export const LanguageIndicator = ({
ref={languageDropdownRef}>
{surveyLanguages.map(
(language) =>
language.language.code !== langaugeToBeDisplayed?.language.code && (
language.language.code !== languageToBeDisplayed?.language.code && (
<button
key={language.language.id}
type="button"

View File

@@ -95,7 +95,7 @@ export const LocalizedEditor = ({
className="fb-htmlbody ml-1" // styles are in global.css
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
recallToHeadline(value, localSurvey, false, "default")["default"] ?? ""
recallToHeadline(value, localSurvey, false, "default", [])["default"] ?? ""
),
}}></label>
</div>

View File

@@ -25,6 +25,7 @@ import { TSurveySummary } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { getAttributes } from "../attribute/service";
import { getAttributeClasses } from "../attributeClass/service";
import { cache } from "../cache";
import { ITEMS_PER_PAGE, WEBAPP_URL } from "../constants";
import { displayCache } from "../display/cache";
@@ -36,7 +37,7 @@ import { putFile } from "../storage/service";
import { getSurvey } from "../survey/service";
import { captureTelemetry } from "../telemetry";
import { convertToCsv, convertToXlsxBuffer } from "../utils/fileConversion";
import { checkForRecallInHeadline } from "../utils/recall";
import { replaceHeadlineRecall } from "../utils/recall";
import { validateInputs } from "../utils/validate";
import { responseCache } from "./cache";
import {
@@ -559,10 +560,10 @@ export const getSurveySummary = (
try {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const attributeClasses = await getAttributeClasses(survey.environmentId);
const batchSize = 3000;
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
@@ -582,7 +583,7 @@ export const getSurveySummary = (
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const meta = getSurveySummaryMeta(responses, displayCount);
const questionWiseSummary = getQuestionWiseSummary(
checkForRecallInHeadline(survey, "default"),
replaceHeadlineRecall(survey, "default", attributeClasses),
responses,
dropOff
);

View File

@@ -34,7 +34,7 @@ import {
import { TTag } from "@formbricks/types/tags";
import { selectPerson } from "../../person/service";
import { mockSurveyOutput } from "../../survey/tests/__mock__/survey.mock";
import { mockAttributeClass, mockSurveyOutput } from "../../survey/tests/__mock__/survey.mock";
import {
createResponse,
createResponseLegacy,
@@ -472,6 +472,7 @@ describe("Tests for getSurveySummary service", () => {
it("Returns a summary of the survey responses", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.findMany.mockResolvedValue([mockResponse]);
prisma.attributeClass.findMany.mockResolvedValueOnce([mockAttributeClass]);
const summary = await getSurveySummary(mockSurveyId);
expect(summary).toEqual(mockSurveySummaryOutput);
@@ -490,6 +491,7 @@ describe("Tests for getSurveySummary service", () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.findMany.mockRejectedValue(errToThrow);
prisma.attributeClass.findMany.mockResolvedValueOnce([mockAttributeClass]);
await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(DatabaseError);
});
@@ -499,6 +501,7 @@ describe("Tests for getSurveySummary service", () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.findMany.mockRejectedValue(new Error(mockErrorMessage));
prisma.attributeClass.findMany.mockResolvedValueOnce([mockAttributeClass]);
await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(Error);
});

View File

@@ -374,7 +374,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
: [];
const updatedLanguageIds =
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLangaugeIds = languages.map((language) => {
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});
@@ -392,7 +392,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
where: { languageId: surveyLanguage.language.id },
data: {
default: surveyLanguage.language.id === defaultLanguageId,
enabled: enabledLangaugeIds.includes(surveyLanguage.language.id),
enabled: enabledLanguageIds.includes(surveyLanguage.language.id),
},
}));
@@ -401,7 +401,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
data.languages.create = languagesToAdd.map((languageId) => ({
languageId: languageId,
default: languageId === defaultLanguageId,
enabled: enabledLangaugeIds.includes(languageId),
enabled: enabledLanguageIds.includes(languageId),
}));
}
@@ -409,7 +409,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
if (languagesToRemove.length > 0) {
data.languages.deleteMany = languagesToRemove.map((languageId) => ({
languageId: languageId,
enabled: enabledLangaugeIds.includes(languageId),
enabled: enabledLanguageIds.includes(languageId),
}));
}
}

View File

@@ -1,7 +1,17 @@
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyQuestionsObject } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TAttributes } from "@formbricks/types/attributes";
import { TResponseData } from "@formbricks/types/responses";
import {
TI18nString,
TSurvey,
TSurveyQuestion,
TSurveyQuestionsObject,
TSurveyRecallItem,
} from "@formbricks/types/surveys";
import { getLocalizedValue } from "../i18n/utils";
import { structuredClone } from "../pollyfills/structuredClone";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
[id: string]: string;
@@ -37,8 +47,9 @@ export const extractFallbackValue = (text: string): string => {
};
// 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*)#/;
export const extractRecallInfo = (headline: string, id?: string): string | null => {
const idPattern = id ? id : "[A-Za-z0-9_-]+";
const pattern = new RegExp(`#recall:(${idPattern})\\/fallback:(\\S*)#`);
const match = headline.match(pattern);
return match ? match[0] : null;
};
@@ -50,67 +61,87 @@ export const findRecallInfoById = (text: string, id: string): string | null => {
return match ? match[0] : null;
};
const getRecallItemLabel = <T extends TSurveyQuestionsObject>(
recallItemId: string,
survey: T,
languageCode: string,
attributeClasses: TAttributeClass[]
): string | undefined => {
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
if (isHiddenField) return recallItemId;
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
if (surveyQuestion) return surveyQuestion.headline[languageCode];
const attributeClass = attributeClasses.find(
(attributeClass) => attributeClass.name.replaceAll(" ", "nbsp") === recallItemId
);
return attributeClass?.name;
};
// Converts recall information in a headline to a corresponding recall question headline, with or without a slash.
export const recallToHeadline = <T extends TSurveyQuestionsObject>(
headline: TI18nString,
survey: T,
withSlash: boolean,
language: string
languageCode: string,
attributeClasses: TAttributeClass[]
): TI18nString => {
let newHeadline = structuredClone(headline);
if (!newHeadline[language]?.includes("#recall:")) return headline;
const localizedHeadline = newHeadline[languageCode];
while (newHeadline[language].includes("#recall:")) {
const recallInfo = extractRecallInfo(getLocalizedValue(newHeadline, language));
if (recallInfo) {
const questionId = extractId(recallInfo);
let questionHeadline = getLocalizedValue(
survey.questions.find((question) => question.id === questionId)?.headline,
language
);
while (questionHeadline?.includes("#recall:")) {
const recallInfo = extractRecallInfo(questionHeadline);
if (recallInfo) {
questionHeadline = questionHeadline.replaceAll(recallInfo, "___");
if (!localizedHeadline?.includes("#recall:")) return headline;
const replaceNestedRecalls = (text: string): string => {
while (text.includes("#recall:")) {
const recallInfo = extractRecallInfo(text);
if (!recallInfo) break;
const recallItemId = extractId(recallInfo);
if (!recallItemId) break;
let recallItemLabel =
getRecallItemLabel(recallItemId, survey, languageCode, attributeClasses) || recallItemId;
while (recallItemLabel.includes("#recall:")) {
const nestedRecallInfo = extractRecallInfo(recallItemLabel);
if (nestedRecallInfo) {
recallItemLabel = recallItemLabel.replace(nestedRecallInfo, "___");
}
}
if (withSlash) {
newHeadline[language] = newHeadline[language].replace(recallInfo, `/${questionHeadline}\\`);
} else {
newHeadline[language] = newHeadline[language].replace(recallInfo, `@${questionHeadline}`);
}
const replacement = withSlash ? `/${recallItemLabel}\\` : `@${recallItemLabel}`;
text = text.replace(recallInfo, replacement);
}
}
return text;
};
newHeadline[languageCode] = replaceNestedRecalls(localizedHeadline);
return newHeadline;
};
// Replaces recall information in a survey question's headline with an ___.
export const replaceRecallInfoWithUnderline = (
recallQuestion: TSurveyQuestion,
language: string
): TSurveyQuestion => {
while (getLocalizedValue(recallQuestion.headline, language).includes("#recall:")) {
const recallInfo = extractRecallInfo(getLocalizedValue(recallQuestion.headline, language));
export const replaceRecallInfoWithUnderline = (label: string): string => {
let newLabel = label;
while (newLabel.includes("#recall:")) {
const recallInfo = extractRecallInfo(newLabel);
if (recallInfo) {
recallQuestion.headline[language] = getLocalizedValue(recallQuestion.headline, language).replace(
recallInfo,
"___"
);
newLabel = newLabel.replace(recallInfo, "___");
}
}
return recallQuestion;
return newLabel;
};
// Checks for survey questions with a "recall" pattern but no fallback value.
export const checkForEmptyFallBackValue = (survey: TSurvey, langauge: string): TSurveyQuestion | null => {
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyQuestion | null => {
const findRecalls = (text: string) => {
const recalls = text.match(/#recall:[^ ]+/g);
return recalls && recalls.some((recall) => !extractFallbackValue(recall));
};
for (const question of survey.questions) {
if (
findRecalls(getLocalizedValue(question.headline, langauge)) ||
(question.subheader && findRecalls(getLocalizedValue(question.subheader, langauge)))
findRecalls(getLocalizedValue(question.headline, language)) ||
(question.subheader && findRecalls(getLocalizedValue(question.subheader, language)))
) {
return question;
}
@@ -119,32 +150,51 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, langauge: string): T
};
// Processes each question in a survey to ensure headlines are formatted correctly for recall and return the modified survey.
export const checkForRecallInHeadline = <T extends TSurveyQuestionsObject>(
export const replaceHeadlineRecall = <T extends TSurveyQuestionsObject>(
survey: T,
langauge: string
language: string,
attributeClasses: TAttributeClass[]
): T => {
const modifiedSurvey: T = structuredClone(survey);
const modifiedSurvey = structuredClone(survey);
modifiedSurvey.questions.forEach((question) => {
question.headline = recallToHeadline(question.headline, modifiedSurvey, false, langauge);
question.headline = recallToHeadline(
question.headline,
modifiedSurvey,
false,
language,
attributeClasses
);
});
return modifiedSurvey;
};
// Retrieves an array of survey questions referenced in a text containing recall information.
export const getRecallQuestions = (text: string, survey: TSurvey, langauge: string): TSurveyQuestion[] => {
export const getRecallItems = (
text: string,
survey: TSurvey,
languageCode: string,
attributeClasses: TAttributeClass[]
): TSurveyRecallItem[] => {
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 = structuredClone(recallQuestion);
recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp, langauge);
recallQuestionArray.push(recallQuestionTemp);
let recallItems: TSurveyRecallItem[] = [];
ids.forEach((recallItemId) => {
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
const isSurveyQuestion = survey.questions.find((question) => question.id === recallItemId);
const recallItemLabel = getRecallItemLabel(recallItemId, survey, languageCode, attributeClasses);
if (recallItemLabel) {
let recallItemLabelTemp = recallItemLabel;
recallItemLabelTemp = replaceRecallInfoWithUnderline(recallItemLabelTemp);
recallItems.push({
id: recallItemId,
label: recallItemLabelTemp,
type: isHiddenField ? "hiddenField" : isSurveyQuestion ? "question" : "attributeClass",
});
}
});
return recallQuestionArray;
return recallItems;
};
// Constructs a fallbacks object from a text containing multiple recall and fallback patterns.
@@ -165,13 +215,76 @@ export const getFallbackValues = (text: string): fallbacks => {
// Transforms headlines in a text to their corresponding recall information.
export const headlineToRecall = (
text: string,
recallQuestions: TSurveyQuestion[],
fallbacks: fallbacks,
langauge: string
recallItems: TSurveyRecallItem[],
fallbacks: fallbacks
): string => {
recallQuestions.forEach((recallQuestion) => {
const recallInfo = `#recall:${recallQuestion.id}/fallback:${fallbacks[recallQuestion.id]}#`;
text = text.replace(`@${recallQuestion.headline[langauge]}`, recallInfo);
recallItems.forEach((recallItem) => {
const recallInfo = `#recall:${recallItem.id}/fallback:${fallbacks[recallItem.id]}#`;
text = text.replace(`@${recallItem.label}`, recallInfo);
});
return text;
};
export const parseRecallInfo = (
text: string,
attributes?: TAttributes,
responseData?: TResponseData,
withSlash: boolean = false
) => {
let modifiedText = text;
const attributeKeys = attributes ? Object.keys(attributes) : [];
const questionIds = responseData ? Object.keys(responseData) : [];
if (attributes && attributeKeys.length > 0) {
attributeKeys.forEach((attributeKey) => {
const recallPattern = `#recall:${attributeKey}`;
while (modifiedText.includes(recallPattern)) {
const recallInfo = extractRecallInfo(modifiedText, attributeKey);
if (!recallInfo) break; // Exit the loop if no recall info is found
const recallItemId = extractId(recallInfo);
if (!recallItemId) continue; // Skip to the next iteration if no ID could be extracted
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
let value = attributes[recallItemId.replace("nbsp", " ")] || fallback;
if (withSlash) {
modifiedText = modifiedText.replace(recallInfo, "#/" + value + "\\#");
} else {
modifiedText = modifiedText.replace(recallInfo, value);
}
}
});
}
if (responseData && questionIds.length > 0) {
while (modifiedText.includes("recall:")) {
const recallInfo = extractRecallInfo(modifiedText);
if (!recallInfo) break; // Exit the loop if no recall info is found
const recallItemId = extractId(recallInfo);
if (!recallItemId) return modifiedText; // Return the text if no ID could be extracted
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
let value;
// Fetching value from responseData or attributes based on recallItemId
if (responseData[recallItemId]) {
value = (responseData[recallItemId] as string) ?? fallback;
}
// Additional value formatting if it exists
if (value) {
if (isValidDateString(value)) {
value = formatDateWithOrdinal(new Date(value));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", "); // Filters out empty values and joins with a comma
}
}
if (withSlash) {
modifiedText = modifiedText.replace(recallInfo, "#/" + (value ?? fallback) + "\\#");
} else {
modifiedText = modifiedText.replace(recallInfo, value ?? fallback);
}
}
}
return modifiedText;
};

View File

@@ -35,6 +35,7 @@ export const Survey = ({
onFileUpload,
responseCount,
startAtQuestionId,
hiddenFieldsRecord,
clickOutside,
shouldResetQuestionId,
}: SurveyBaseProps) => {
@@ -58,7 +59,7 @@ export const Survey = ({
const [loadingElement, setLoadingElement] = useState(false);
const [history, setHistory] = useState<string[]>([]);
const [responseData, setResponseData] = useState<TResponseData>({});
const [responseData, setResponseData] = useState<TResponseData>(hiddenFieldsRecord ?? {});
const [ttc, setTtc] = useState<TResponseTtc>({});
const cardArrangement = useMemo(() => {
if (survey.type === "link") {
@@ -253,6 +254,7 @@ export const Survey = ({
languageCode={languageCode}
responseCount={responseCount}
isInIframe={isInIframe}
replaceRecallInfo={replaceRecallInfo}
/>
);
} else if (questionIdx === survey.questions.length) {

View File

@@ -19,6 +19,7 @@ interface WelcomeCardProps {
languageCode: string;
responseCount?: number;
isInIframe: boolean;
replaceRecallInfo: (text: string, responseData: TResponseData) => string;
}
const TimerIcon = () => {
@@ -68,6 +69,7 @@ export const WelcomeCard = ({
survey,
responseCount,
isInIframe,
replaceRecallInfo,
}: WelcomeCardProps) => {
const calculateTimeToComplete = () => {
let idx = calculateElementIdx(survey, 0);
@@ -109,8 +111,14 @@ export const WelcomeCard = ({
<img src={fileUrl} className="mb-8 max-h-96 w-1/3 rounded-lg object-contain" alt="Company Logo" />
)}
<Headline headline={getLocalizedValue(headline, languageCode)} questionId="welcomeCard" />
<HtmlBody htmlString={getLocalizedValue(html, languageCode)} questionId="welcomeCard" />
<Headline
headline={replaceRecallInfo(getLocalizedValue(headline, languageCode), {})}
questionId="welcomeCard"
/>
<HtmlBody
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), {})}
questionId="welcomeCard"
/>
</div>
</ScrollableContainer>

View File

@@ -6,23 +6,37 @@ import { TResponseData } from "@formbricks/types/responses";
import { TSurveyQuestion } from "@formbricks/types/surveys";
export const replaceRecallInfo = (text: string, responseData: TResponseData): 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;
let modifiedText = text;
while (modifiedText.includes("recall:")) {
const recallInfo = extractRecallInfo(modifiedText);
if (!recallInfo) break; // Exit the loop if no recall info is found
const recallItemId = extractId(recallInfo);
if (!recallItemId) return modifiedText; // Return the text if no ID could be extracted
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
let value = null;
// Fetching value from responseData or attributes based on recallItemId
if (responseData[recallItemId]) {
value = (responseData[recallItemId] as string) ?? fallback;
}
// Additional value formatting if it exists
if (value) {
if (isValidDateString(value)) {
value = formatDateWithOrdinal(new Date(value));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", "); // Filters out empty values and joins with a comma
}
if (Array.isArray(value)) {
value = value.filter((item) => item !== null && item !== undefined && item !== "").join(", ");
}
text = text.replace(recallInfo, value);
}
// Replace the recallInfo in the text with the obtained or fallback value
modifiedText = modifiedText.replace(recallInfo, value || fallback);
}
return text;
return modifiedText;
};
export const parseRecallInformation = (

View File

@@ -24,6 +24,7 @@ export interface SurveyBaseProps {
responseCount?: number;
isCardBorderVisible?: boolean;
startAtQuestionId?: string;
hiddenFieldsRecord?: TResponseData;
clickOutside?: boolean;
shouldResetQuestionId?: boolean;
}

View File

@@ -438,7 +438,10 @@ export const ZSurveyQuestions = z.array(ZSurveyQuestion);
export type TSurveyQuestions = z.infer<typeof ZSurveyQuestions>;
export const ZSurveyQuestionsObject = z.object({ questions: ZSurveyQuestions });
export const ZSurveyQuestionsObject = z.object({
questions: ZSurveyQuestions,
hiddenFields: ZSurveyHiddenFields,
});
export type TSurveyQuestionsObject = z.infer<typeof ZSurveyQuestionsObject>;
@@ -877,3 +880,11 @@ const ZSortOption = z.object({
export type TSortOption = z.infer<typeof ZSortOption>;
export type TSurveySummary = z.infer<typeof ZSurveySummary>;
export const ZSurveyRecallItem = z.object({
id: z.string(),
label: z.string(),
type: z.enum(["question", "hiddenField", "attributeClass"]),
});
export type TSurveyRecallItem = z.infer<typeof ZSurveyRecallItem>;

View File

@@ -1,7 +1,8 @@
import { z } from "zod";
import { ZAttributeClass } from "./attributeClasses";
import { ZResponseData } from "./responses";
import { ZSurveyQuestion, ZSurveyStatus } from "./surveys";
import { ZSurveyHiddenFields, ZSurveyQuestion, ZSurveyStatus } from "./surveys";
import { ZUserNotificationSettings } from "./user";
const ZWeeklySummaryInsights = z.object({
@@ -60,6 +61,7 @@ export const ZWeeklySummarySurveyData = z.object({
status: ZSurveyStatus,
responses: z.array(ZWeeklyEmailResponseData),
displays: z.array(z.object({ id: z.string() })),
hiddenFields: ZSurveyHiddenFields,
});
export type TWeeklySummarySurveyData = z.infer<typeof ZWeeklySummarySurveyData>;
@@ -67,6 +69,7 @@ export type TWeeklySummarySurveyData = z.infer<typeof ZWeeklySummarySurveyData>;
export const ZWeeklySummaryEnvironmentData = z.object({
id: z.string(),
surveys: z.array(ZWeeklySummarySurveyData),
attributeClasses: z.array(ZAttributeClass),
});
export type TWeeklySummaryEnvironmentData = z.infer<typeof ZWeeklySummaryEnvironmentData>;

View File

@@ -1,13 +1,13 @@
import { RefObject } from "react";
import { toast } from "react-hot-toast";
import { TSurveyQuestion } from "@formbricks/types/surveys";
import { TSurveyRecallItem } from "@formbricks/types/surveys";
import { Button } from "../../Button";
import { Input } from "../../Input";
interface FallbackInputProps {
filteredRecallQuestions: (TSurveyQuestion | undefined)[];
filteredRecallItems: (TSurveyRecallItem | undefined)[];
fallbacks: { [type: string]: string };
setFallbacks: (fallbacks: { [type: string]: string }) => void;
fallbackInputRef: RefObject<HTMLInputElement>;
@@ -15,7 +15,7 @@ interface FallbackInputProps {
}
export const FallbackInput = ({
filteredRecallQuestions,
filteredRecallItems,
fallbacks,
setFallbacks,
fallbackInputRef,
@@ -31,16 +31,17 @@ export const FallbackInput = ({
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;
{filteredRecallItems.map((recallItem) => {
if (!recallItem) return;
return (
<div className="mt-2 flex flex-col" key={recallQuestion.id}>
<div className="mt-2 flex flex-col" key={recallItem.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", " ")}
value={fallbacks[recallItem.id]?.replaceAll("nbsp", " ")}
placeholder={"Fallback for " + recallItem.label}
onKeyDown={(e) => {
if (e.key == "Enter") {
e.preventDefault();
@@ -53,7 +54,7 @@ export const FallbackInput = ({
}}
onChange={(e) => {
const newFallbacks = { ...fallbacks };
newFallbacks[recallQuestion.id] = e.target.value;
newFallbacks[recallItem.id] = e.target.value;
setFallbacks(newFallbacks);
}}
/>

View File

@@ -0,0 +1,205 @@
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
import {
CalendarDaysIcon,
EyeOffIcon,
HomeIcon,
ListIcon,
MessageSquareTextIcon,
PhoneIcon,
PresentationIcon,
Rows3Icon,
StarIcon,
TagIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { replaceRecallInfoWithUnderline } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey, TSurveyHiddenFields, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "../../DropdownMenu";
import { Input } from "../../Input";
const questionIconMapping = {
openText: MessageSquareTextIcon,
multipleChoiceSingle: Rows3Icon,
multipleChoiceMulti: ListIcon,
rating: StarIcon,
nps: PresentationIcon,
date: CalendarDaysIcon,
cal: PhoneIcon,
address: HomeIcon,
};
interface RecallItemSelectProps {
localSurvey: TSurvey;
questionId: string;
addRecallItem: (question: TSurveyRecallItem) => void;
setShowRecallItemSelect: (show: boolean) => void;
recallItems: TSurveyRecallItem[];
selectedLanguageCode: string;
hiddenFields: TSurveyHiddenFields;
attributeClasses: TAttributeClass[];
}
export const RecallItemSelect = ({
localSurvey,
questionId,
addRecallItem,
setShowRecallItemSelect,
recallItems,
selectedLanguageCode,
attributeClasses,
}: RecallItemSelectProps) => {
const [searchValue, setSearchValue] = useState("");
const isNotAllowedQuestionType = (question: TSurveyQuestion): boolean => {
return (
question.type === "fileUpload" ||
question.type === "cta" ||
question.type === "consent" ||
question.type === "pictureSelection" ||
question.type === "cal" ||
question.type === "matrix"
);
};
const recallItemIds = useMemo(() => {
return recallItems.map((recallItem) => recallItem.id);
}, [recallItems]);
const hiddenFieldRecallItems = useMemo(() => {
if (localSurvey.type !== "link") return [];
if (localSurvey.hiddenFields.fieldIds) {
return localSurvey.hiddenFields.fieldIds
.filter((hiddenFieldId) => {
return !recallItemIds.includes(hiddenFieldId);
})
.map((hiddenFieldId) => ({
id: hiddenFieldId,
label: hiddenFieldId,
type: "hiddenField" as const,
}));
}
return [];
}, [localSurvey.hiddenFields]);
const attributeClassRecallItems = useMemo(() => {
if (localSurvey.type !== "app") return [];
return attributeClasses
.filter((attributeClass) => !recallItemIds.includes(attributeClass.name.replaceAll(" ", "nbsp")))
.map((attributeClass) => {
return {
id: attributeClass.name.replaceAll(" ", "nbsp"),
label: attributeClass.name,
type: "attributeClass" as const,
};
});
}, [attributeClasses]);
const surveyQuestionRecallItems = 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 (
!recallItemIds.includes(question.id) && !notAllowed && question.id !== questionId && idx > index
);
})
.map((question) => {
return { id: question.id, label: question.headline[selectedLanguageCode], type: "question" as const };
});
return filteredQuestions;
}, [localSurvey.questions, questionId, recallItemIds]);
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...attributeClassRecallItems].filter(
(recallItems) => {
if (searchValue.trim() === "") return true;
else {
return recallItems.label.toLowerCase().startsWith(searchValue.toLowerCase());
}
}
);
}, [surveyQuestionRecallItems, hiddenFieldRecallItems, attributeClassRecallItems, searchValue]);
// function to modify headline (recallInfo to corresponding headline)
const getRecallLabel = (label: string): string => {
return replaceRecallInfoWithUnderline(label);
};
const getQuestionIcon = (recallItem: TSurveyRecallItem) => {
switch (recallItem.type) {
case "question":
const question = localSurvey.questions.find((question) => question.id === questionId);
if (question) {
return questionIconMapping[question?.type as keyof typeof questionIconMapping];
}
case "hiddenField":
return EyeOffIcon;
case "attributeClass":
return TagIcon;
}
};
return (
<>
<DropdownMenu defaultOpen={true} modal={false}>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div className="flex h-0 w-full items-center justify-between overflow-hidden" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-96 bg-slate-50 text-slate-700" align="start" side="bottom">
<p className="m-2 text-sm font-medium">Recall Information from...</p>
<Input
id="recallItemSearchInput"
placeholder="Search options"
className="mb-1 w-full bg-white"
onChange={(e) => setSearchValue(e.target.value)}
autoFocus={true}
value={searchValue}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
document.getElementById("recallItem-0")?.focus();
}
}}
/>
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
{filteredRecallItems.map((recallItem, index) => {
const IconComponent = getQuestionIcon(recallItem);
return (
<DropdownMenuItem
id={"recallItem-" + index}
key={recallItem.id}
title={recallItem.label}
onSelect={() => {
addRecallItem({ id: recallItem.id, label: recallItem.label, type: recallItem.type });
setShowRecallItemSelect(false);
}}
autoFocus={false}
className="flex w-full cursor-pointer rounded-md p-2 focus:bg-slate-200 focus:outline-none"
onKeyDown={(e) => {
if (e.key === "ArrowUp" && index === 0) {
document.getElementById("recallItemSearchInput")?.focus();
} else if (e.key === "ArrowDown" && index === filteredRecallItems.length - 1) {
document.getElementById("recallItemSearchInput")?.focus();
}
}}>
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
<div className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{getRecallLabel(recallItem.label)}
</div>
</DropdownMenuItem>
);
})}
{filteredRecallItems.length === 0 && (
<p className="p-2 text-sm font-medium text-slate-700">No recall items found 🤷</p>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};

View File

@@ -1,150 +0,0 @@
import {
CalendarDaysIcon,
HomeIcon,
ListIcon,
MessageSquareTextIcon,
PhoneIcon,
PresentationIcon,
Rows3Icon,
StarIcon,
} from "lucide-react";
import { RefObject, useEffect, useMemo, useState } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { replaceRecallInfoWithUnderline } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
const questionIconMapping = {
openText: MessageSquareTextIcon,
multipleChoiceSingle: Rows3Icon,
multipleChoiceMulti: ListIcon,
rating: StarIcon,
nps: PresentationIcon,
date: CalendarDaysIcon,
cal: PhoneIcon,
address: HomeIcon,
};
interface RecallQuestionSelectProps {
localSurvey: TSurvey;
questionId: string;
addRecallQuestion: (question: TSurveyQuestion) => void;
setShowQuestionSelect: (show: boolean) => void;
showQuestionSelect: boolean;
inputRef: RefObject<HTMLInputElement>;
recallQuestions: TSurveyQuestion[];
selectedLanguageCode: string;
}
export const RecallQuestionSelect = ({
localSurvey,
questionId,
addRecallQuestion,
setShowQuestionSelect,
showQuestionSelect,
inputRef,
recallQuestions,
selectedLanguageCode,
}: 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" ||
question.type === "matrix"
);
};
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 = structuredClone(question);
questionTemp = replaceRecallInfoWithUnderline(questionTemp, selectedLanguageCode);
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") {
event.preventDefault();
event.stopPropagation();
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-40 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">
{getLocalizedValue(getRecallHeadline(q).headline, selectedLanguageCode)}
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -13,12 +13,19 @@ import {
extractRecallInfo,
findRecallInfoById,
getFallbackValues,
getRecallQuestions,
getRecallItems,
headlineToRecall,
recallToHeadline,
replaceRecallInfoWithUnderline,
} from "@formbricks/lib/utils/recall";
import { TI18nString, TSurvey, TSurveyChoice, TSurveyQuestion } from "@formbricks/types/surveys";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import {
TI18nString,
TSurvey,
TSurveyChoice,
TSurveyQuestion,
TSurveyRecallItem,
} from "@formbricks/types/surveys";
import { LanguageIndicator } from "../../ee/multiLanguage/components/LanguageIndicator";
import { createI18nString } from "../../lib/i18n/utils";
@@ -26,8 +33,7 @@ import { FileInput } from "../FileInput";
import { Input } from "../Input";
import { Label } from "../Label";
import { FallbackInput } from "./components/FallbackInput";
import { RecallQuestionSelect } from "./components/RecallQuestionSelect";
import { isValueIncomplete } from "./lib/utils";
import { RecallItemSelect } from "./components/RecallItemSelect";
import {
determineImageUploaderVisibility,
getCardText,
@@ -36,6 +42,7 @@ import {
getLabelById,
getMatrixLabel,
getPlaceHolderById,
isValueIncomplete,
} from "./utils";
interface QuestionFormInputProps {
@@ -56,6 +63,7 @@ interface QuestionFormInputProps {
ref?: RefObject<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
className?: string;
attributeClasses: TAttributeClass[];
}
export const QuestionFormInput = ({
@@ -75,6 +83,7 @@ export const QuestionFormInput = ({
placeholder,
onBlur,
className,
attributeClasses,
}: QuestionFormInputProps) => {
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
const isChoice = id.includes("choice");
@@ -126,11 +135,16 @@ export const QuestionFormInput = ({
const [showImageUploader, setShowImageUploader] = useState<boolean>(
determineImageUploaderVisibility(questionIdx, localSurvey)
);
const [showQuestionSelect, setShowQuestionSelect] = useState(false);
const [showRecallItemSelect, setShowRecallItemSelect] = useState(false);
const [showFallbackInput, setShowFallbackInput] = useState(false);
const [recallQuestions, setRecallQuestions] = useState<TSurveyQuestion[]>(
const [recallItems, setRecallItems] = useState<TSurveyRecallItem[]>(
getLocalizedValue(text, selectedLanguageCode).includes("#recall:")
? getRecallQuestions(getLocalizedValue(text, selectedLanguageCode), localSurvey, selectedLanguageCode)
? getRecallItems(
getLocalizedValue(text, selectedLanguageCode),
localSurvey,
selectedLanguageCode,
attributeClasses
)
: []
);
const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>(
@@ -143,31 +157,31 @@ export const QuestionFormInput = ({
const fallbackInputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filteredRecallQuestions = Array.from(new Set(recallQuestions.map((q) => q.id))).map((id) => {
return recallQuestions.find((q) => q.id === id);
const filteredRecallItems = Array.from(new Set(recallItems.map((q) => q.id))).map((id) => {
return recallItems.find((q) => q.id === id);
});
// Hook to synchronize the horizontal scroll position of highlightContainerRef and inputRef.
useSyncScroll(highlightContainerRef, inputRef);
useEffect(() => {
if (!isWelcomeCard && (id === "headline" || id === "subheader")) {
if (id === "headline" || id === "subheader") {
checkForRecallSymbol();
}
// Generates an array of headlines from recallQuestions, replacing nested recall questions with '___' .
const recallQuestionHeadlines = recallQuestions.flatMap((recallQuestion) => {
if (!getLocalizedValue(recallQuestion.headline, selectedLanguageCode).includes("#recall:")) {
return [(recallQuestion.headline as TI18nString)[selectedLanguageCode]];
// Generates an array of headlines from recallItems, replacing nested recall questions with '___' .
const recallItemLabels = recallItems.flatMap((recallItem) => {
if (!recallItem.label.includes("#recall:")) {
return [recallItem.label];
}
const recallQuestionText = (recallQuestion[id as keyof typeof recallQuestion] as string) || "";
const recallInfo = extractRecallInfo(recallQuestionText);
const recallItemLabel = recallItem.label;
const recallInfo = extractRecallInfo(recallItemLabel);
if (recallInfo) {
const recallQuestionId = extractId(recallInfo);
const recallQuestion = localSurvey.questions.find((question) => question.id === recallQuestionId);
const recallItemId = extractId(recallInfo);
const recallQuestion = localSurvey.questions.find((question) => question.id === recallItemId);
if (recallQuestion) {
return [recallQuestionText.replace(recallInfo, `___`)];
return [recallItemLabel.replace(recallInfo, `___`)];
}
}
return [];
@@ -176,12 +190,12 @@ export const QuestionFormInput = ({
// 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 = recallToHeadline(text, localSurvey, false, selectedLanguageCode)[
let remainingText = recallToHeadline(text, localSurvey, false, selectedLanguageCode, attributeClasses)[
selectedLanguageCode
];
filterRecallQuestions(remainingText);
recallQuestionHeadlines.forEach((headline) => {
const index = remainingText.indexOf("@" + headline);
filterRecallItems(remainingText);
recallItemLabels.forEach((label) => {
const index = remainingText.indexOf("@" + label);
if (index !== -1) {
if (index > 0) {
parts.push(
@@ -194,10 +208,10 @@ export const QuestionFormInput = ({
<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}
{"@" + label}
</span>
);
remainingText = remainingText.substring(index + headline.length + 1);
remainingText = remainingText.substring(index + label.length + 1);
}
});
if (remainingText?.length) {
@@ -225,73 +239,71 @@ export const QuestionFormInput = ({
const checkForRecallSymbol = () => {
const pattern = /(^|\s)@(\s|$)/;
if (pattern.test(getLocalizedValue(text, selectedLanguageCode))) {
setShowQuestionSelect(true);
setShowRecallItemSelect(true);
} else {
setShowQuestionSelect(false);
setShowRecallItemSelect(false);
}
};
// Adds a new recall question to the recallQuestions array, updates fallbacks, modifies the text with recall details.
const addRecallQuestion = (recallQuestion: TSurveyQuestion) => {
if ((recallQuestion.headline as TI18nString)[selectedLanguageCode].trim() === "") {
// Adds a new recall question to the recallItems array, updates fallbacks, modifies the text with recall details.
const addRecallItem = (recallItem: TSurveyRecallItem) => {
if (recallItem.label.trim() === "") {
toast.error("Cannot add question with empty headline as recall");
return;
}
let recallQuestionTemp = structuredClone(recallQuestion);
recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp, selectedLanguageCode);
setRecallQuestions((prevQuestions) => {
const updatedQuestions = [...prevQuestions, recallQuestionTemp];
let recallItemTemp = structuredClone(recallItem);
recallItemTemp.label = replaceRecallInfoWithUnderline(recallItem.label);
setRecallItems((prevQuestions) => {
const updatedQuestions = [...prevQuestions, recallItemTemp];
return updatedQuestions;
});
if (!Object.keys(fallbacks).includes(recallQuestion.id)) {
if (!Object.keys(fallbacks).includes(recallItem.id)) {
setFallbacks((prevFallbacks) => ({
...prevFallbacks,
[recallQuestion.id]: "",
[recallItem.id]: "",
}));
}
setShowQuestionSelect(false);
setShowRecallItemSelect(false);
let modifiedHeadlineWithId = { ...getElementTextBasedOnType() };
modifiedHeadlineWithId[selectedLanguageCode] = getLocalizedValue(
modifiedHeadlineWithId,
selectedLanguageCode
).replace("@", `#recall:${recallQuestion.id}/fallback:# `);
).replace(/(?<=^|\s)@(?=\s|$)/g, `#recall:${recallItem.id}/fallback:# `);
handleUpdate(getLocalizedValue(modifiedHeadlineWithId, selectedLanguageCode));
const modifiedHeadlineWithName = recallToHeadline(
modifiedHeadlineWithId,
localSurvey,
false,
selectedLanguageCode
selectedLanguageCode,
attributeClasses
);
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 = (remainingText: string) => {
let includedQuestions: TSurveyQuestion[] = [];
recallQuestions.forEach((recallQuestion) => {
if (remainingText.includes(`@${getLocalizedValue(recallQuestion.headline, selectedLanguageCode)}`)) {
includedQuestions.push(recallQuestion);
const filterRecallItems = (remainingText: string) => {
let includedRecallItems: TSurveyRecallItem[] = [];
recallItems.forEach((recallItem) => {
if (remainingText.includes(`@${recallItem.label}`)) {
includedRecallItems.push(recallItem);
} else {
const questionToRemove = getLocalizedValue(recallQuestion.headline, selectedLanguageCode).slice(
0,
-1
);
const recallItemToRemove = recallItem.label.slice(0, -1);
const newText = { ...text };
newText[selectedLanguageCode] = text[selectedLanguageCode].replace(`@${questionToRemove}`, "");
newText[selectedLanguageCode] = text[selectedLanguageCode].replace(`@${recallItemToRemove}`, "");
setText(newText);
handleUpdate(text[selectedLanguageCode].replace(`@${questionToRemove}`, ""));
handleUpdate(text[selectedLanguageCode].replace(`@${recallItemToRemove}`, ""));
let updatedFallback = { ...fallbacks };
delete updatedFallback[recallQuestion.id];
delete updatedFallback[recallItem.id];
setFallbacks(updatedFallback);
}
});
setRecallQuestions(includedQuestions);
setRecallItems(includedRecallItems);
};
const addFallback = () => {
let headlineWithFallback = getElementTextBasedOnType();
filteredRecallQuestions.forEach((recallQuestion) => {
filteredRecallItems.forEach((recallQuestion) => {
if (recallQuestion) {
const recallInfo = findRecallInfoById(
getLocalizedValue(headlineWithFallback, selectedLanguageCode),
@@ -436,8 +448,12 @@ export const QuestionFormInput = ({
id={id}
name={id}
aria-label={label || getLabelById(id)}
autoComplete={showQuestionSelect ? "off" : "on"}
value={recallToHeadline(text, localSurvey, false, selectedLanguageCode)[selectedLanguageCode]}
autoComplete={showRecallItemSelect ? "off" : "on"}
value={
recallToHeadline(text, localSurvey, false, selectedLanguageCode, attributeClasses)[
selectedLanguageCode
]
}
ref={inputRef}
onBlur={onBlur}
onChange={(e) => {
@@ -445,10 +461,16 @@ export const QuestionFormInput = ({
...getElementTextBasedOnType(),
[selectedLanguageCode]: e.target.value,
};
setText(recallToHeadline(translatedText, localSurvey, false, selectedLanguageCode));
handleUpdate(
headlineToRecall(e.target.value, recallQuestions, fallbacks, selectedLanguageCode)
setText(
recallToHeadline(
translatedText,
localSurvey,
false,
selectedLanguageCode,
attributeClasses
)
);
handleUpdate(headlineToRecall(e.target.value, recallItems, fallbacks));
}}
maxLength={maxLength ?? undefined}
isInvalid={
@@ -465,9 +487,9 @@ export const QuestionFormInput = ({
setSelectedLanguageCode={setSelectedLanguageCode}
/>
)}
{!showQuestionSelect && showFallbackInput && recallQuestions.length > 0 && (
{!showRecallItemSelect && showFallbackInput && recallItems.length > 0 && (
<FallbackInput
filteredRecallQuestions={filteredRecallQuestions}
filteredRecallItems={filteredRecallItems}
fallbacks={fallbacks}
setFallbacks={setFallbacks}
fallbackInputRef={fallbackInputRef}
@@ -484,22 +506,23 @@ export const QuestionFormInput = ({
)}
</div>
</div>
{showQuestionSelect && (
<RecallQuestionSelect
{showRecallItemSelect && (
<RecallItemSelect
localSurvey={localSurvey}
questionId={questionId}
addRecallQuestion={addRecallQuestion}
setShowQuestionSelect={setShowQuestionSelect}
showQuestionSelect={showQuestionSelect}
inputRef={inputRef}
recallQuestions={recallQuestions}
addRecallItem={addRecallItem}
setShowRecallItemSelect={setShowRecallItemSelect}
recallItems={recallItems}
selectedLanguageCode={selectedLanguageCode}
hiddenFields={localSurvey.hiddenFields}
attributeClasses={attributeClasses}
/>
)}
</div>
{selectedLanguageCode !== "default" && value && typeof value["default"] !== undefined && (
<div className="mt-1 text-xs text-gray-500">
<strong>Translate:</strong> {recallToHeadline(value, localSurvey, false, "default")["default"]}
<strong>Translate:</strong>{" "}
{recallToHeadline(value, localSurvey, false, "default", attributeClasses)["default"]}
</div>
)}
{selectedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && (

View File

@@ -1,35 +0,0 @@
import { isLabelValidForAllLanguages } from "@formbricks/lib/i18n/utils";
import { TI18nString } from "@formbricks/types/surveys";
export const isValueIncomplete = (
id: string,
isInvalid: boolean,
surveyLanguageCodes: string[],
value?: TI18nString
) => {
// Define a list of IDs for which a default value needs to be checked.
const labelIds = [
"label",
"headline",
"subheader",
"lowerLabel",
"upperLabel",
"buttonLabel",
"placeholder",
"backButtonLabel",
"dismissButtonLabel",
];
// If value is not provided, immediately return false as it cannot be incomplete.
if (value === undefined) return false;
// Check if the default value is incomplete. This applies only to specific label IDs.
// For these IDs, the default value should not be an empty string.
const isDefaultIncomplete = labelIds.includes(id) ? value["default"]?.trim() !== "" : false;
// Return true if all the following conditions are met:
// 1. The field is marked as invalid.
// 2. The label is not valid for all provided language codes in the survey.
// 4. For specific label IDs, the default value is incomplete as defined above.
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
};

View File

@@ -1,4 +1,5 @@
import { createI18nString } from "@formbricks/lib/i18n/utils";
import { isLabelValidForAllLanguages } from "@formbricks/lib/i18n/utils";
import {
TI18nString,
TSurvey,
@@ -92,3 +93,36 @@ export const getPlaceHolderById = (id: string) => {
return "";
}
};
export const isValueIncomplete = (
id: string,
isInvalid: boolean,
surveyLanguageCodes: string[],
value?: TI18nString
) => {
// Define a list of IDs for which a default value needs to be checked.
const labelIds = [
"label",
"headline",
"subheader",
"lowerLabel",
"upperLabel",
"buttonLabel",
"placeholder",
"backButtonLabel",
"dismissButtonLabel",
];
// If value is not provided, immediately return false as it cannot be incomplete.
if (value === undefined) return false;
// Check if the default value is incomplete. This applies only to specific label IDs.
// For these IDs, the default value should not be an empty string.
const isDefaultIncomplete = labelIds.includes(id) ? value["default"]?.trim() !== "" : false;
// Return true if all the following conditions are met:
// 1. The field is marked as invalid.
// 2. The label is not valid for all provided language codes in the survey.
// 4. For specific label IDs, the default value is incomplete as defined above.
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
};

View File

@@ -0,0 +1,44 @@
import { EyeOffIcon } from "lucide-react";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyHiddenFields } from "@formbricks/types/surveys";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../Tooltip";
interface HiddenFieldsProps {
hiddenFields: TSurveyHiddenFields;
responseData: TResponseData;
}
export const HiddenFields = ({ hiddenFields, responseData }: HiddenFieldsProps) => {
const fieldIds = hiddenFields.fieldIds ?? [];
return (
<div className="mt-6 flex flex-col gap-6">
{fieldIds.map((field) => {
if (!responseData[field]) return;
return (
<div key={field}>
<div className="flex space-x-2 text-sm text-slate-500">
<p>{field}</p>
<div className="flex items-center space-x-2 rounded-full bg-slate-100 px-2">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<EyeOffIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]" side="top">
Hidden field
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<p className="ph-no-capture mt-2 font-semibold text-slate-700">
{typeof responseData[field] === "string" ? (responseData[field] as string) : ""}
</p>
</div>
);
})}
</div>
);
};

View File

@@ -1,6 +1,8 @@
import { CheckCircle2Icon, ChevronsDownIcon, XCircleIcon } from "lucide-react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyQuestion } from "@formbricks/types/surveys";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../Tooltip";
@@ -10,6 +12,7 @@ interface QuestionSkipProps {
status: string;
questions: TSurveyQuestion[];
isFirstQuestionAnswered?: boolean;
responseData: TResponseData;
}
export const QuestionSkip = ({
@@ -17,6 +20,7 @@ export const QuestionSkip = ({
status,
questions,
isFirstQuestionAnswered,
responseData,
}: QuestionSkipProps) => {
return (
<div>
@@ -65,9 +69,13 @@ export const QuestionSkip = ({
skippedQuestions.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
{parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
),
{},
responseData
)}
</p>
);
@@ -95,9 +103,13 @@ export const QuestionSkip = ({
skippedQuestions.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
{parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
),
{},
responseData
)}
</p>
);

View File

@@ -0,0 +1,193 @@
import { CheckCircle2Icon } from "lucide-react";
import { getLanguageCode, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyMatrixQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyQuestionType,
} from "@formbricks/types/surveys";
import { AddressResponse } from "../../AddressResponse";
import { FileUploadResponse } from "../../FileUploadResponse";
import { PictureSelectionResponse } from "../../PictureSelectionResponse";
import { RatingResponse } from "../../RatingResponse";
import { isValidValue } from "../util";
import { HiddenFields } from "./HiddenFields";
import { QuestionSkip } from "./QuestionSkip";
import { VerifiedEmail } from "./VerifiedEmail";
interface SingleResponseCardBodyProps {
survey: TSurvey;
response: TResponse;
skippedQuestions: string[][];
}
export const SingleResponseCardBody = ({
survey,
response,
skippedQuestions,
}: SingleResponseCardBodyProps) => {
const isFirstQuestionAnswered = response.data[survey.questions[0].id] ? true : false;
const handleArray = (data: string | number | string[]): string => {
if (Array.isArray(data)) {
return data.join(", ");
} else {
return String(data);
}
};
const formatTextWithSlashes = (text: string) => {
// Updated regex to match content between #/ and \#
const regex = /#\/(.*?)\\#/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside #/ and \#
if (index % 2 === 1) {
return (
<span
key={index}
className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0 ">
@{part}
</span>
);
} else {
return part;
}
});
};
const renderResponse = (
questionType: TSurveyQuestionType,
responseData: string | number | string[] | Record<string, string>,
question: TSurveyQuestion
) => {
switch (questionType) {
case TSurveyQuestionType.Rating:
if (typeof responseData === "number")
return <RatingResponse scale={question.scale} answer={responseData} range={question.range} />;
case TSurveyQuestionType.Date:
if (typeof responseData === "string") {
const formattedDateString = formatDateWithOrdinal(new Date(responseData));
return <p className="ph-no-capture my-1 font-semibold text-slate-700">{formattedDateString}</p>;
}
case TSurveyQuestionType.Cal:
if (typeof responseData === "string")
return <p className="ph-no-capture my-1 font-semibold capitalize text-slate-700">{responseData}</p>;
case TSurveyQuestionType.PictureSelection:
if (Array.isArray(responseData))
return (
<PictureSelectionResponse
choices={(question as TSurveyPictureSelectionQuestion).choices}
selected={responseData}
/>
);
case TSurveyQuestionType.FileUpload:
if (Array.isArray(responseData)) return <FileUploadResponse selected={responseData} />;
case TSurveyQuestionType.Matrix:
if (typeof responseData === "object" && !Array.isArray(responseData)) {
return (question as TSurveyMatrixQuestion).rows.map((row) => {
const languagCode = getLanguageCode(survey.languages, response.language);
const rowValueInSelectedLanguage = getLocalizedValue(row, languagCode);
if (!responseData[rowValueInSelectedLanguage]) return;
return (
<p className="ph-no-capture my-1 font-semibold capitalize text-slate-700">
{rowValueInSelectedLanguage}: {responseData[rowValueInSelectedLanguage]}
</p>
);
});
}
case TSurveyQuestionType.Address:
if (Array.isArray(responseData)) {
return <AddressResponse value={responseData} />;
}
default:
if (
typeof responseData === "string" ||
typeof responseData === "number" ||
Array.isArray(responseData)
)
return (
<p className="ph-no-capture my-1 whitespace-pre-line font-semibold text-slate-700">
{Array.isArray(responseData) ? handleArray(responseData) : responseData}
</p>
);
}
};
return (
<div className="p-6">
{survey.welcomeCard.enabled && (
<QuestionSkip
skippedQuestions={[]}
questions={survey.questions}
status={"welcomeCard"}
isFirstQuestionAnswered={isFirstQuestionAnswered}
responseData={response.data}
/>
)}
<div className="space-y-6">
{survey.verifyEmail && response.data["verifiedEmail"] && (
<VerifiedEmail responseData={response.data} />
)}
{survey.questions.map((question) => {
const skipped = skippedQuestions.find((skippedQuestionElement) =>
skippedQuestionElement.includes(question.id)
);
// If found, remove it from the list
if (skipped) {
skippedQuestions = skippedQuestions.filter((item) => item !== skipped);
}
return (
<div key={`${question.id}`}>
{isValidValue(response.data[question.id]) ? (
<div>
<p className="text-sm text-slate-500">
{formatTextWithSlashes(
parseRecallInfo(
getLocalizedValue(question.headline, "default"),
{},
response.data,
true
)
)}
</p>
{renderResponse(question.type, response.data[question.id], question)}
</div>
) : (
<QuestionSkip
skippedQuestions={skipped}
questions={survey.questions}
responseData={response.data}
status={
response.finished ||
(skippedQuestions.length > 0 &&
!skippedQuestions[skippedQuestions.length - 1].includes(question.id))
? "skipped"
: "aborted"
}
/>
)}
</div>
);
})}
</div>
{survey.hiddenFields.enabled && survey.hiddenFields.fieldIds && (
<HiddenFields hiddenFields={survey.hiddenFields} responseData={response.data} />
)}
{response.finished && (
<div className="mt-4 flex items-center">
<CheckCircle2Icon className="h-6 w-6 text-slate-400" />
<p className="mx-2 rounded-lg bg-slate-100 px-2 text-sm font-medium text-slate-700">Completed</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,194 @@
import { LanguagesIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { ReactNode } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TUser } from "@formbricks/types/user";
import { getLanguageLabel } from "../../../ee/multiLanguage/lib/isoLanguages";
import { PersonAvatar } from "../../Avatars";
import { SurveyStatusIndicator } from "../../SurveyStatusIndicator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../Tooltip";
import { isSubmissionTimeMoreThan5Minutes } from "../util";
interface TooltipRendererProps {
shouldRender: boolean;
tooltipContent: ReactNode;
children: ReactNode;
}
interface SingleResponseCardHeaderProps {
pageType: "people" | "response";
response: TResponse;
survey: TSurvey;
environment: TEnvironment;
user?: TUser;
isViewer: boolean;
setDeleteDialogOpen: (deleteDialogOpen: boolean) => void;
}
export const SingleResponseCardHeader = ({
pageType,
response,
survey,
environment,
user,
isViewer,
setDeleteDialogOpen,
}: SingleResponseCardHeaderProps) => {
const displayIdentifier = response.person
? getPersonIdentifier(response.person, response.personAttributes)
: null;
const environmentId = survey.environmentId;
const canResponseBeDeleted = response.finished
? true
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
const TooltipRenderer = (props: TooltipRendererProps) => {
const { children, shouldRender, tooltipContent } = props;
if (shouldRender) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <>{children}</>;
};
const renderTooltip = Boolean(
(response.personAttributes && Object.keys(response.personAttributes).length > 0) ||
(response.meta.userAgent && Object.keys(response.meta.userAgent).length > 0)
);
const tooltipContent = (
<>
{response.singleUseId && (
<div>
<p className="py-1 font-bold text-slate-700">SingleUse ID:</p>
<span>{response.singleUseId}</span>
</div>
)}
{response.personAttributes && Object.keys(response.personAttributes).length > 0 && (
<div>
<p className="py-1 font-bold text-slate-700">Person attributes:</p>
{Object.keys(response.personAttributes).map((key) => (
<p key={key}>
{key}:{" "}
<span className="font-bold">{response.personAttributes && response.personAttributes[key]}</span>
</p>
))}
</div>
)}
{response.meta.userAgent && Object.keys(response.meta.userAgent).length > 0 && (
<div className="text-slate-600">
{response.personAttributes && Object.keys(response.personAttributes).length > 0 && (
<hr className="my-2 border-slate-200" />
)}
<p className="py-1 font-bold text-slate-700">Device info:</p>
{response.meta.userAgent?.browser && <p>Browser: {response.meta.userAgent.browser}</p>}
{response.meta.userAgent?.os && <p>OS: {response.meta.userAgent.os}</p>}
{response.meta.userAgent && (
<p>
Device:{" "}
{response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}
</p>
)}
{response.meta.url && <p>URL: {response.meta.url}</p>}
{response.meta.action && <p>Action: {response.meta.action}</p>}
{response.meta.source && <p>Source: {response.meta.source}</p>}
{response.meta.country && <p>Country: {response.meta.country}</p>}
</div>
)}
</>
);
const deleteSubmissionToolTip = <>This response is in progress.</>;
return (
<div className="space-y-2 border-b border-slate-200 px-6 pb-4 pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center justify-center space-x-4">
{pageType === "response" && (
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<div className="group">
{response.person?.id ? (
user ? (
<Link
className="flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<PersonAvatar personId={response.person.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
</Link>
) : (
<div className="flex items-center">
<PersonAvatar personId={response.person.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600">
{displayIdentifier}
</h3>
</div>
)
) : (
<div className="flex items-center">
<PersonAvatar personId="anonymous" />
<h3 className="ml-4 pb-1 font-semibold text-slate-600">Anonymous</h3>
</div>
)}
</div>
</TooltipRenderer>
)}
{pageType === "people" && (
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-100 p-1 px-2 text-sm text-slate-600">
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<Link
className="hover:underline"
href={`/environments/${environmentId}/surveys/${survey.id}/summary`}>
{survey.name}
</Link>
</div>
)}
{response.language && response.language !== "default" && (
<div className="flex space-x-2 rounded-full bg-slate-700 px-2 py-1 text-xs text-white">
<div>{getLanguageLabel(response.language)}</div>
<LanguagesIcon className="h-4 w-4" />
</div>
)}
</div>
<div className="flex items-center space-x-4 text-sm">
<time className="text-slate-500" dateTime={timeSince(response.updatedAt.toISOString())}>
{timeSince(response.updatedAt.toISOString())}
</time>
{user && !isViewer && (
<TooltipRenderer shouldRender={!canResponseBeDeleted} tooltipContent={deleteSubmissionToolTip}>
<TrashIcon
onClick={() => {
if (canResponseBeDeleted) {
setDeleteDialogOpen(true);
}
}}
className={`h-4 w-4 ${
canResponseBeDeleted
? "cursor-pointer text-slate-500 hover:text-red-700"
: "cursor-not-allowed text-slate-400"
} `}
/>
</TooltipRenderer>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import { MailIcon } from "lucide-react";
import { TResponseData } from "@formbricks/types/responses";
interface VerifiedEmailProps {
responseData: TResponseData;
}
export const VerifiedEmail = ({ responseData }: VerifiedEmailProps) => {
return (
<div>
<p className="flex items-center space-x-2 text-sm text-slate-500">
<MailIcon className="h-4 w-4" />
<span>Verified Email</span>
</p>
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{typeof responseData["verifiedEmail"] === "string" ? responseData["verifiedEmail"] : ""}
</p>
</div>
);
};

View File

@@ -1,51 +1,26 @@
"use client";
import clsx from "clsx";
import { CheckCircle2Icon, LanguagesIcon, MailIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ReactNode, useState } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { timeSince } from "@formbricks/lib/time";
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyMatrixQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyQuestionType,
} from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
import { getLanguageLabel } from "../../ee/multiLanguage/lib/isoLanguages";
import { AddressResponse } from "../AddressResponse";
import { PersonAvatar } from "../Avatars";
import { DeleteDialog } from "../DeleteDialog";
import { FileUploadResponse } from "../FileUploadResponse";
import { PictureSelectionResponse } from "../PictureSelectionResponse";
import { RatingResponse } from "../RatingResponse";
import { SurveyStatusIndicator } from "../SurveyStatusIndicator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../Tooltip";
import { deleteResponseAction, getResponseAction } from "./actions";
import { QuestionSkip } from "./components/QuestionSkip";
import { ResponseNotes } from "./components/ResponseNote";
import { ResponseTagsWrapper } from "./components/ResponseTagsWrapper";
import { SingleResponseCardBody } from "./components/SingleResponseCardBody";
import { SingleResponseCardHeader } from "./components/SingleResponseCardHeader";
import { isValidValue } from "./util";
const isSubmissionTimeMoreThan5Minutes = (submissionTimeISOString: Date) => {
const submissionTime: Date = new Date(submissionTimeISOString);
const currentTime: Date = new Date();
const timeDifference: number = (currentTime.getTime() - submissionTime.getTime()) / (1000 * 60); // Convert milliseconds to minutes
return timeDifference > 5;
};
export interface SingleResponseCardProps {
interface SingleResponseCardProps {
survey: TSurvey;
response: TResponse;
user?: TUser;
@@ -57,35 +32,6 @@ export interface SingleResponseCardProps {
isViewer: boolean;
}
interface TooltipRendererProps {
shouldRender: boolean;
tooltipContent: ReactNode;
children: ReactNode;
}
const TooltipRenderer = (props: TooltipRendererProps) => {
const { children, shouldRender, tooltipContent } = props;
if (shouldRender) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <>{children}</>;
};
const DateResponse = ({ date }: { date?: string }) => {
if (!date) return null;
const formattedDateString = formatDateWithOrdinal(new Date(date));
return <p className="ph-no-capture my-1 font-semibold text-slate-700">{formattedDateString}</p>;
};
export const SingleResponseCard = ({
survey,
response,
@@ -99,29 +45,13 @@ export const SingleResponseCard = ({
}: SingleResponseCardProps) => {
const environmentId = survey.environmentId;
const router = useRouter();
const displayIdentifier = response.person
? getPersonIdentifier(response.person, response.personAttributes)
: null;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const canResponseBeDeleted = response.finished
? true
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
let skippedQuestions: string[][] = [];
let temp: string[] = [];
const isFirstQuestionAnswered = response.data[survey.questions[0].id] ? true : false;
const isValidValue = (value: any) => {
return (
(typeof value === "string" && value.trim() !== "") ||
(Array.isArray(value) && value.length > 0) ||
typeof value === "number" ||
(typeof value === "object" && Object.entries(value).length > 0)
);
};
if (response.finished) {
survey.questions.forEach((question) => {
if (!isValidValue(response.data[question.id])) {
@@ -156,14 +86,6 @@ export const SingleResponseCard = ({
skippedQuestions.push(temp);
}
const handleArray = (data: string | number | string[]): string => {
if (Array.isArray(data)) {
return data.join(", ");
} else {
return String(data);
}
};
const handleDeleteResponse = async () => {
setIsDeleting(true);
try {
@@ -183,58 +105,6 @@ export const SingleResponseCard = ({
}
};
const renderTooltip = Boolean(
(response.personAttributes && Object.keys(response.personAttributes).length > 0) ||
(response.meta.userAgent && Object.keys(response.meta.userAgent).length > 0)
);
const tooltipContent = (
<>
{response.singleUseId && (
<div>
<p className="py-1 font-bold text-slate-700">SingleUse ID:</p>
<span>{response.singleUseId}</span>
</div>
)}
{response.personAttributes && Object.keys(response.personAttributes).length > 0 && (
<div>
<p className="py-1 font-bold text-slate-700">Person attributes:</p>
{Object.keys(response.personAttributes).map((key) => (
<p key={key}>
{key}:{" "}
<span className="font-bold">{response.personAttributes && response.personAttributes[key]}</span>
</p>
))}
</div>
)}
{response.meta.userAgent && Object.keys(response.meta.userAgent).length > 0 && (
<div className="text-slate-600">
{response.personAttributes && Object.keys(response.personAttributes).length > 0 && (
<hr className="my-2 border-slate-200" />
)}
<p className="py-1 font-bold text-slate-700">Device info:</p>
{response.meta.userAgent?.browser && <p>Browser: {response.meta.userAgent.browser}</p>}
{response.meta.userAgent?.os && <p>OS: {response.meta.userAgent.os}</p>}
{response.meta.userAgent && (
<p>
Device:{" "}
{response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}
</p>
)}
{response.meta.url && <p>URL: {response.meta.url}</p>}
{response.meta.action && <p>Action: {response.meta.action}</p>}
{response.meta.source && <p>Source: {response.meta.source}</p>}
{response.meta.country && <p>Country: {response.meta.country}</p>}
</div>
)}
</>
);
const deleteSubmissionToolTip = <>This response is in progress.</>;
const hasHiddenFieldsEnabled = survey.hiddenFields?.enabled;
const fieldIds = survey.hiddenFields?.fieldIds || [];
const hasFieldIds = !!fieldIds.length;
const updateFetchedResponses = async () => {
const updatedResponse = await getResponseAction(response.id);
if (updatedResponse !== null && updateResponse) {
@@ -242,61 +112,6 @@ export const SingleResponseCard = ({
}
};
const renderResponse = (
questionType: TSurveyQuestionType,
responseData: string | number | string[] | Record<string, string>,
question: TSurveyQuestion
) => {
switch (questionType) {
case TSurveyQuestionType.Rating:
if (typeof responseData === "number")
return <RatingResponse scale={question.scale} answer={responseData} range={question.range} />;
case TSurveyQuestionType.Date:
if (typeof responseData === "string") return <DateResponse date={responseData} />;
case TSurveyQuestionType.Cal:
if (typeof responseData === "string")
return <p className="ph-no-capture my-1 font-semibold capitalize text-slate-700">{responseData}</p>;
case TSurveyQuestionType.PictureSelection:
if (Array.isArray(responseData))
return (
<PictureSelectionResponse
choices={(question as TSurveyPictureSelectionQuestion).choices}
selected={responseData}
/>
);
case TSurveyQuestionType.FileUpload:
if (Array.isArray(responseData)) return <FileUploadResponse selected={responseData} />;
case TSurveyQuestionType.Matrix:
if (typeof responseData === "object" && !Array.isArray(responseData)) {
return (question as TSurveyMatrixQuestion).rows.map((row) => {
const languagCode = getLanguageCode(survey.languages, response.language);
const rowValueInSelectedLanguage = getLocalizedValue(row, languagCode);
if (!responseData[rowValueInSelectedLanguage]) return;
return (
<p className="ph-no-capture my-1 font-semibold capitalize text-slate-700">
{rowValueInSelectedLanguage}: {responseData[rowValueInSelectedLanguage]}
</p>
);
});
}
case TSurveyQuestionType.Address:
if (Array.isArray(responseData)) {
return <AddressResponse value={responseData} />;
}
default:
if (
typeof responseData === "string" ||
typeof responseData === "number" ||
Array.isArray(responseData)
)
return (
<p className="ph-no-capture my-1 whitespace-pre-line font-semibold text-slate-700">
{Array.isArray(responseData) ? handleArray(responseData) : responseData}
</p>
);
}
};
return (
<div className={clsx("group relative", isOpen && "min-h-[300px]")}>
<div
@@ -309,166 +124,17 @@ export const SingleResponseCard = ({
? "w-[96.5%]"
: cn("w-full", user ? "group-hover:w-[96.5%]" : ""))
)}>
<div className="space-y-2 border-b border-slate-200 px-6 pb-4 pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center justify-center space-x-4">
{pageType === "response" && (
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<div className="group">
{response.person?.id ? (
user ? (
<Link
className="flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<PersonAvatar personId={response.person.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
</Link>
) : (
<div className="flex items-center">
<PersonAvatar personId={response.person.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600">
{displayIdentifier}
</h3>
</div>
)
) : (
<div className="flex items-center">
<PersonAvatar personId="anonymous" />
<h3 className="ml-4 pb-1 font-semibold text-slate-600">Anonymous</h3>
</div>
)}
</div>
</TooltipRenderer>
)}
<SingleResponseCardHeader
pageType="response"
response={response}
survey={survey}
environment={environment}
user={user}
isViewer={isViewer}
setDeleteDialogOpen={setDeleteDialogOpen}
/>
{pageType === "people" && (
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-100 p-1 px-2 text-sm text-slate-600">
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<Link
className="hover:underline"
href={`/environments/${environmentId}/surveys/${survey.id}/summary`}>
{survey.name}
</Link>
</div>
)}
{response.language && response.language !== "default" && (
<div className="flex space-x-2 rounded-full bg-slate-700 px-2 py-1 text-xs text-white">
<div>{getLanguageLabel(response.language)}</div>
<LanguagesIcon className="h-4 w-4" />
</div>
)}
</div>
<div className="flex items-center space-x-4 text-sm">
<time className="text-slate-500" dateTime={timeSince(response.updatedAt.toISOString())}>
{timeSince(response.updatedAt.toISOString())}
</time>
{user && !isViewer && (
<TooltipRenderer
shouldRender={!canResponseBeDeleted}
tooltipContent={deleteSubmissionToolTip}>
<TrashIcon
onClick={() => {
if (canResponseBeDeleted) {
setDeleteDialogOpen(true);
}
}}
className={`h-4 w-4 ${
canResponseBeDeleted
? "cursor-pointer text-slate-500 hover:text-red-700"
: "cursor-not-allowed text-slate-400"
} `}
/>
</TooltipRenderer>
)}
</div>
</div>
</div>
<div className="p-6">
{survey.welcomeCard.enabled && (
<QuestionSkip
skippedQuestions={[]}
questions={survey.questions}
status={"welcomeCard"}
isFirstQuestionAnswered={isFirstQuestionAnswered}
/>
)}
<div className="space-y-6">
{survey.verifyEmail && response.data["verifiedEmail"] && (
<div>
<p className="flex items-center space-x-2 text-sm text-slate-500">
<MailIcon className="h-4 w-4" />
<span>Verified Email</span>
</p>
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{typeof response.data["verifiedEmail"] === "string" ? response.data["verifiedEmail"] : ""}
</p>
</div>
)}
{survey.questions.map((question) => {
const skipped = skippedQuestions.find((skippedQuestionElement) =>
skippedQuestionElement.includes(question.id)
);
// If found, remove it from the list
if (skipped) {
skippedQuestions = skippedQuestions.filter((item) => item !== skipped);
}
return (
<div key={`${question.id}`}>
{isValidValue(response.data[question.id]) ? (
<div>
<p className="text-sm text-slate-500">
{getLocalizedValue(question.headline, "default")}
</p>
{renderResponse(question.type, response.data[question.id], question)}
</div>
) : (
<QuestionSkip
skippedQuestions={skipped}
questions={survey.questions}
status={
response.finished ||
(skippedQuestions.length > 0 &&
!skippedQuestions[skippedQuestions.length - 1].includes(question.id))
? "skipped"
: "aborted"
}
/>
)}
</div>
);
})}
</div>
{hasHiddenFieldsEnabled && hasFieldIds && (
<div className="mt-6 flex flex-col gap-6">
{fieldIds.map((field) => {
return (
<div key={field}>
<p className="text-sm text-slate-500">Hidden Field: {field}</p>
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{typeof response.data[field] === "string" ? (response.data[field] as string) : ""}
</p>
</div>
);
})}
</div>
)}
{response.finished && (
<div className="mt-4 flex items-center">
<CheckCircle2Icon className="h-6 w-6 text-slate-400" />
<p className="mx-2 rounded-lg bg-slate-100 px-2 text-sm font-medium text-slate-700">
Completed
</p>
</div>
)}
</div>
<SingleResponseCardBody survey={survey} response={response} skippedQuestions={skippedQuestions} />
<ResponseTagsWrapper
environmentId={environmentId}

View File

@@ -0,0 +1,15 @@
export const isValidValue = (value: string | number | Record<string, string> | string[]) => {
return (
(typeof value === "string" && value.trim() !== "") ||
(Array.isArray(value) && value.length > 0) ||
typeof value === "number" ||
(typeof value === "object" && Object.entries(value).length > 0)
);
};
export const isSubmissionTimeMoreThan5Minutes = (submissionTimeISOString: Date) => {
const submissionTime: Date = new Date(submissionTimeISOString);
const currentTime: Date = new Date();
const timeDifference: number = (currentTime.getTime() - submissionTime.getTime()) / (1000 * 60); // Convert milliseconds to minutes
return timeDifference > 5;
};