fixes sonarqube issues

This commit is contained in:
pandeymangg
2025-11-27 18:06:00 +05:30
parent 86cc8fb8ff
commit e1e5ce6270
16 changed files with 1624 additions and 1480 deletions

View File

@@ -46,6 +46,45 @@ import {
} from "@/modules/ui/components/select";
import { IntegrationModalInputs } from "../lib/types";
const ElementCheckbox = ({
element,
selectedSurvey,
field,
}: {
element: TSurveyElement;
selectedSurvey: TSurvey;
field: {
value: string[] | undefined;
onChange: (value: string[]) => void;
};
}) => {
const handleCheckedChange = (checked: boolean) => {
if (checked) {
field.onChange([...(field.value || []), element.id]);
} else {
field.onChange(field.value?.filter((value) => value !== element.id) || []);
}
};
return (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={element.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={element.id}
value={element.id}
className="bg-white"
checked={field.value?.includes(element.id)}
onCheckedChange={handleCheckedChange}
/>
<span className="ml-2">
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}
</span>
</label>
</div>
);
};
type EditModeProps =
| { isEditMode: false; defaultData?: never }
| { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } };
@@ -108,27 +147,7 @@ const renderElementSelection = ({
control={control}
name={"elements"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={element.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={element.id}
value={element.id}
className="bg-white"
checked={field.value?.includes(element.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...(field.value || []), element.id])
: field.onChange(field.value?.filter((value) => value !== element.id) || []);
}}
/>
<span className="ml-2">
{getTextContent(
recallToHeadline(element.headline, selectedSurvey, false, "default")["default"]
)}
</span>
</label>
</div>
<ElementCheckbox element={element} selectedSurvey={selectedSurvey} field={field} />
)}
/>
))}

View File

@@ -39,6 +39,59 @@ import {
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
const MappingErrorMessage = ({
error,
col,
elem,
t,
}: {
error: { type: string; msg?: React.ReactNode | string } | null | undefined;
col: { id: string; name: string; type: string };
elem: { id: string; name: string; type: string };
t: ReturnType<typeof useTranslation>["t"];
}) => {
const showErrorMsg = useMemo(() => {
switch (error?.type) {
case ERRORS.UNSUPPORTED_TYPE:
return (
<>
-{" "}
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
col_name: col.name,
type: col.type,
})}
</>
);
case ERRORS.MAPPING:
const element = getElementTypes(t).find((et) => et.id === elem.type);
if (!element) return null;
return (
<>
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
que_name: elem.name,
question_label: element.label,
col_name: col.name,
col_type: col.type,
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
})}
</>
);
default:
return null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error, col, elem, t]);
if (!error) return null;
return (
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
<span className="mb-2 block">{error.type}</span>
{showErrorMsg}
</div>
);
};
interface AddIntegrationModalProps {
environmentId: string;
surveys: TSurvey[];
@@ -294,49 +347,6 @@ export const AddIntegrationModal = ({
});
};
const ErrorMsg = ({ error, col, elem }) => {
const showErrorMsg = useMemo(() => {
switch (error?.type) {
case ERRORS.UNSUPPORTED_TYPE:
return (
<>
-{" "}
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
col_name: col.name,
type: col.type,
})}
</>
);
case ERRORS.MAPPING:
const element = getElementTypes(t).find((et) => et.id === elem.type);
if (!element) return null;
return (
<>
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
que_name: elem.name,
question_label: element.label,
col_name: col.name,
col_type: col.type,
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
})}
</>
);
default:
return null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
if (!error) return null;
return (
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
<span className="mb-2 block">{error.type}</span>
{showErrorMsg}
</div>
);
};
const getFilteredDbItems = () => {
const colMapping = mapping.map((m) => m.column.id);
return dbItems.filter((item) => !colMapping.includes(item.id));
@@ -344,11 +354,12 @@ export const AddIntegrationModal = ({
return (
<div className="w-full">
<ErrorMsg
<MappingErrorMessage
key={idx}
error={mapping[idx]?.error}
col={mapping[idx].column}
elem={mapping[idx].element}
t={t}
/>
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">

View File

@@ -2716,7 +2716,7 @@ describe("NPS question type tests", () => {
test("getQuestionSummary includes individual score breakdown in choices array for NPS", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
type: TSurveyElementTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
@@ -2725,7 +2725,13 @@ describe("NPS question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -2784,10 +2790,15 @@ describe("NPS question type tests", () => {
];
const dropOff = [
{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
@@ -2822,7 +2833,7 @@ describe("NPS question type tests", () => {
test("getQuestionSummary handles NPS individual score breakdown with no responses", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
type: TSurveyElementTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
@@ -2831,7 +2842,13 @@ describe("NPS question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -2839,10 +2856,15 @@ describe("NPS question type tests", () => {
const responses: any[] = [];
const dropOff = [
{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
@@ -3116,7 +3138,7 @@ describe("Rating question type tests", () => {
test("getQuestionSummary calculates CSAT for Rating question with range 3", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
@@ -3127,7 +3149,13 @@ describe("Rating question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -3166,10 +3194,15 @@ describe("Rating question type tests", () => {
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// Range 3: satisfied = score 3
// 2 out of 3 responses are satisfied (score 3)
@@ -3180,7 +3213,7 @@ describe("Rating question type tests", () => {
test("getQuestionSummary calculates CSAT for Rating question with range 4", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
@@ -3191,7 +3224,13 @@ describe("Rating question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -3230,10 +3269,15 @@ describe("Rating question type tests", () => {
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// Range 4: satisfied = scores 3-4
// 2 out of 3 responses are satisfied (scores 3 and 4)
@@ -3244,7 +3288,7 @@ describe("Rating question type tests", () => {
test("getQuestionSummary calculates CSAT for Rating question with range 5", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
@@ -3255,7 +3299,13 @@ describe("Rating question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -3294,10 +3344,15 @@ describe("Rating question type tests", () => {
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// Range 5: satisfied = scores 4-5
// 2 out of 3 responses are satisfied (scores 4 and 5)
@@ -3308,7 +3363,7 @@ describe("Rating question type tests", () => {
test("getQuestionSummary calculates CSAT for Rating question with range 6", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
@@ -3319,7 +3374,13 @@ describe("Rating question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -3358,10 +3419,15 @@ describe("Rating question type tests", () => {
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// Range 6: satisfied = scores 5-6
// 2 out of 3 responses are satisfied (scores 5 and 6)
@@ -3372,7 +3438,7 @@ describe("Rating question type tests", () => {
test("getQuestionSummary calculates CSAT for Rating question with range 7", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
@@ -3383,7 +3449,13 @@ describe("Rating question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -3422,10 +3494,15 @@ describe("Rating question type tests", () => {
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// Range 7: satisfied = scores 6-7
// 2 out of 3 responses are satisfied (scores 6 and 7)
@@ -3436,7 +3513,7 @@ describe("Rating question type tests", () => {
test("getQuestionSummary calculates CSAT for Rating question with range 10", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
@@ -3447,7 +3524,13 @@ describe("Rating question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -3496,10 +3579,15 @@ describe("Rating question type tests", () => {
];
const dropOff = [
{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// Range 10: satisfied = scores 8-10
// 3 out of 4 responses are satisfied (scores 8, 9, 10)
@@ -3510,7 +3598,7 @@ describe("Rating question type tests", () => {
test("getQuestionSummary calculates CSAT for Rating question with all satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
@@ -3521,7 +3609,13 @@ describe("Rating question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -3550,10 +3644,15 @@ describe("Rating question type tests", () => {
];
const dropOff = [
{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// Range 5: satisfied = scores 4-5
// All 2 responses are satisfied
@@ -3564,7 +3663,7 @@ describe("Rating question type tests", () => {
test("getQuestionSummary calculates CSAT for Rating question with none satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
@@ -3575,7 +3674,13 @@ describe("Rating question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -3614,10 +3719,15 @@ describe("Rating question type tests", () => {
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// Range 5: satisfied = scores 4-5
// None of the responses are satisfied (all are 1, 2, or 3)
@@ -3628,7 +3738,7 @@ describe("Rating question type tests", () => {
test("getQuestionSummary calculates CSAT for Rating question with no responses", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
@@ -3639,7 +3749,13 @@ describe("Rating question type tests", () => {
const survey = {
id: "survey-1",
questions: [question],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [question],
},
],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
@@ -3647,10 +3763,15 @@ describe("Rating question type tests", () => {
const responses: any[] = [];
const dropOff = [
{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
{ elementId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);

View File

@@ -26,6 +26,15 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
const DEFAULT_LANGUAGE_CODE = "default";
// Helper to get localized option value
const getOptionValue = (option: string | TI18nString): string => {
return typeof option === "object" && option !== null
? getLocalizedValue(option, DEFAULT_LANGUAGE_CODE)
: option;
};
type ElementFilterComboBoxProps = {
filterOptions: (string | TI18nString)[] | undefined;
filterComboBoxOptions: (string | TI18nString)[] | undefined;
@@ -58,32 +67,28 @@ export const ElementFilterComboBox = ({
useClickOutside(commandRef, () => setOpen(false));
const defaultLanguageCode = "default";
// Check if multiple selection is allowed
const isMultiple = useMemo(
() =>
type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
type === TSurveyElementTypeEnum.PictureSelection ||
(type === TSurveyElementTypeEnum.NPS && filterValue === "Includes either"),
[type, filterValue]
);
const isMultiSelectType =
type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
type === TSurveyElementTypeEnum.PictureSelection;
const isNPSIncludesEither = type === TSurveyElementTypeEnum.NPS && filterValue === "Includes either";
const isMultiple = isMultiSelectType || isNPSIncludesEither;
// Filter out already selected options for multi-select
const options = useMemo(() => {
if (!isMultiple) return filterComboBoxOptions;
return filterComboBoxOptions?.filter((o) => {
const optionValue = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = getOptionValue(o);
return !filterComboBoxValue?.includes(optionValue);
});
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue]);
// Disable combo box for NPS/Rating when Submitted/Skipped
const isDisabledComboBox =
(type === TSurveyElementTypeEnum.NPS || type === TSurveyElementTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped");
const isNPSOrRating = type === TSurveyElementTypeEnum.NPS || type === TSurveyElementTypeEnum.Rating;
const isSubmittedOrSkipped = filterValue === "Submitted" || filterValue === "Skipped";
const isDisabledComboBox = isNPSOrRating && isSubmittedOrSkipped;
// Check if this is a text input field (URL meta field)
const isTextInputField = type === OptionsType.META && fieldId === "url";
@@ -92,15 +97,14 @@ export const ElementFilterComboBox = ({
const filteredOptions = useMemo(
() =>
options?.filter((o) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = getOptionValue(o);
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}),
[options, searchQuery, defaultLanguageCode]
[options, searchQuery]
);
const handleCommandItemSelect = (o: string | TI18nString) => {
const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const value = getOptionValue(o);
if (isMultiple) {
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
@@ -203,8 +207,7 @@ export const ElementFilterComboBox = ({
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white">
{filterOptions?.map((o, index) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = getOptionValue(o);
return (
<DropdownMenuItem
key={`${optionValue}-${index}`}
@@ -275,8 +278,7 @@ export const ElementFilterComboBox = ({
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = getOptionValue(o);
return (
<CommandItem
key={optionValue}

View File

@@ -5,7 +5,7 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TResponseMeta } from "@formbricks/types/responses";
import { TResponseDataValue, TResponseMeta } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
@@ -242,6 +242,37 @@ const handleSlackIntegration = async (
}
};
// Helper to process a single element's response for integrations
const processElementResponse = (
element: ReturnType<typeof getElementsFromBlocks>[number],
responseValue: TResponseDataValue
): string => {
if (responseValue === undefined) {
return "";
}
if (element.type === TSurveyElementTypeEnum.PictureSelection) {
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl)
.join("\n");
}
return processResponseData(responseValue);
};
// Helper to create empty response object for non-slack integrations
const createEmptyResponseObject = (responseData: Record<string, unknown>): Record<string, string> => {
return Object.keys(responseData).reduce(
(acc, key) => {
acc[key] = "";
return acc;
},
{} as Record<string, string>
);
};
const extractResponses = async (
integrationType: TIntegrationType,
pipelineData: TPipelineInput,
@@ -253,60 +284,39 @@ const extractResponses = async (
}> => {
const responses: string[] = [];
const elements: string[] = [];
const surveyElements = getElementsFromBlocks(survey.blocks);
const emptyResponseObject = createEmptyResponseObject(pipelineData.response.data);
for (const elementId of elementIds) {
//check for hidden field Ids
// Check for hidden field Ids
if (survey.hiddenFields.fieldIds?.includes(elementId)) {
responses.push(processResponseData(pipelineData.response.data[elementId]));
elements.push(elementId);
continue;
}
const element = surveyElements.find((q) => q.id === elementId);
if (!element) {
continue;
}
const responseValue = pipelineData.response.data[elementId];
responses.push(processElementResponse(element, responseValue));
if (responseValue !== undefined) {
let answer: typeof responseValue;
if (element.type === TSurveyElementTypeEnum.PictureSelection) {
const selectedChoiceIds = responseValue as string[];
answer = element?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl)
.join("\n");
} else {
answer = responseValue;
}
const responseDataForRecall =
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject;
const variablesForRecall = integrationType === "slack" ? pipelineData.response.variables : {};
responses.push(processResponseData(answer));
} else {
responses.push("");
}
// Create emptyResponseObject with same keys but empty string values
const emptyResponseObject = Object.keys(pipelineData.response.data).reduce(
(acc, key) => {
acc[key] = "";
return acc;
},
{} as Record<string, string>
);
elements.push(
parseRecallInfo(
getTextContent(getLocalizedValue(element?.headline, "default")),
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject,
integrationType === "slack" ? pipelineData.response.variables : {}
getTextContent(getLocalizedValue(element.headline, "default")),
responseDataForRecall,
variablesForRecall
) || ""
);
}
return {
responses,
elements,
};
return { responses, elements };
};
const handleNotionIntegration = async (

View File

@@ -313,7 +313,7 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
blocks: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
@@ -339,7 +339,7 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
blocks: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
@@ -1016,7 +1016,7 @@ describe("surveys", () => {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
elementType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Screened in" },
},
],
@@ -1032,7 +1032,7 @@ describe("surveys", () => {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
elementType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Screened out (overquota)" },
},
],
@@ -1048,7 +1048,7 @@ describe("surveys", () => {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
elementType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Not in quota" },
},
],
@@ -1064,11 +1064,11 @@ describe("surveys", () => {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
elementType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Screened in" },
},
{
questionType: { type: "Quotas", label: "Quota 2", id: "quota2" },
elementType: { type: "Quotas", label: "Quota 2", id: "quota2" },
filterType: { filterComboBoxValue: "Not in quota" },
},
],

View File

@@ -24,7 +24,7 @@ import { getLocalizedValue } from "@/lib/i18n/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
const conditionOptions = {
const conditionOptions: Record<string, string[]> = {
openText: ["is"],
multipleChoiceSingle: ["Includes either"],
multipleChoiceMulti: ["Includes all", "Includes either"],
@@ -41,7 +41,7 @@ const conditionOptions = {
contactInfo: ["is"],
ranking: ["is"],
};
const filterOptions = {
const filterOptions: Record<string, string[]> = {
openText: ["Filled out", "Skipped"],
rating: ["1", "2", "3", "4", "5"],
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
@@ -53,6 +53,51 @@ const filterOptions = {
ranking: ["Filled out", "Skipped"],
};
// Helper function to get filter options for a specific element type
const getElementFilterOption = (
element: ReturnType<typeof getElementsFromBlocks>[number]
): ElementFilterOptions | null => {
if (!Object.keys(conditionOptions).includes(element.type)) {
return null;
}
const baseOption = {
type: element.type,
filterOptions: conditionOptions[element.type],
id: element.id,
};
switch (element.type) {
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return {
...baseOption,
filterComboBoxOptions: element.choices?.map((c) => c.label) ?? [""],
};
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return {
...baseOption,
filterComboBoxOptions: element.choices?.filter((c) => c.id !== "other").map((c) => c.label) ?? [""],
};
case TSurveyElementTypeEnum.PictureSelection:
return {
...baseOption,
filterComboBoxOptions: element.choices?.map((_, idx) => `Picture ${idx + 1}`) ?? [""],
};
case TSurveyElementTypeEnum.Matrix:
return {
type: element.type,
filterOptions: element.rows.map((row) => getLocalizedValue(row.label, "default")),
filterComboBoxOptions: element.columns.map((column) => getLocalizedValue(column.label, "default")),
id: element.id,
};
default:
return {
...baseOption,
filterComboBoxOptions: filterOptions[element.type],
};
}
};
// URL/meta text operators mapping
const META_OP_MAP = {
Equals: "equals",
@@ -96,45 +141,9 @@ export const generateElementAndFilterOptions = (
});
elementOptions = [...elementOptions, { header: OptionsType.ELEMENTS, option: elementsOptions }];
elements.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
if (q.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
elementFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""],
id: q.id,
});
} else if (q.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
elementFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: q?.choices
? q?.choices?.filter((c) => c.id !== "other")?.map((c) => c?.label)
: [""],
id: q.id,
});
} else if (q.type === TSurveyElementTypeEnum.PictureSelection) {
elementFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: q?.choices ? q?.choices?.map((_, idx) => `Picture ${idx + 1}`) : [""],
id: q.id,
});
} else if (q.type === TSurveyElementTypeEnum.Matrix) {
elementFilterOptions.push({
type: q.type,
filterOptions: q.rows.map((row) => getLocalizedValue(row.label, "default")),
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
id: q.id,
});
} else {
elementFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: filterOptions[q.type],
id: q.id,
});
}
const filterOption = getElementFilterOption(q);
if (filterOption) {
elementFilterOptions.push(filterOption);
}
});
@@ -247,6 +256,292 @@ export const generateElementAndFilterOptions = (
return { elementOptions: [...elementOptions], elementFilterOptions: [...elementFilterOptions] };
};
// Helper function to process filled out/skipped filters
const processFilledOutSkippedFilter = (
filterType: FilterValue["filterType"],
elementId: string,
filters: TResponseFilterCriteria
) => {
if (filterType.filterComboBoxValue === "Filled out") {
filters.data![elementId] = { op: "filledOut" };
} else if (filterType.filterComboBoxValue === "Skipped") {
filters.data![elementId] = { op: "skipped" };
}
};
// Helper function to process ranking filters
const processRankingFilter = (
filterType: FilterValue["filterType"],
elementId: string,
filters: TResponseFilterCriteria
) => {
if (filterType.filterComboBoxValue === "Filled out") {
filters.data![elementId] = { op: "submitted" };
} else if (filterType.filterComboBoxValue === "Skipped") {
filters.data![elementId] = { op: "skipped" };
}
};
// Helper function to process multiple choice filters
const processMultipleChoiceFilter = (
filterType: FilterValue["filterType"],
elementId: string,
filters: TResponseFilterCriteria
) => {
if (filterType.filterValue === "Includes either") {
filters.data![elementId] = {
op: "includesOne",
value: filterType.filterComboBoxValue as string[],
};
} else if (filterType.filterValue === "Includes all") {
filters.data![elementId] = {
op: "includesAll",
value: filterType.filterComboBoxValue as string[],
};
}
};
// Helper function to process NPS/Rating filters
const processNPSRatingFilter = (
filterType: FilterValue["filterType"],
elementId: string,
filters: TResponseFilterCriteria
) => {
if (filterType.filterValue === "Is equal to") {
filters.data![elementId] = {
op: "equals",
value: parseInt(filterType.filterComboBoxValue as string),
};
} else if (filterType.filterValue === "Is less than") {
filters.data![elementId] = {
op: "lessThan",
value: parseInt(filterType.filterComboBoxValue as string),
};
} else if (filterType.filterValue === "Is more than") {
filters.data![elementId] = {
op: "greaterThan",
value: parseInt(filterType.filterComboBoxValue as string),
};
} else if (filterType.filterValue === "Submitted") {
filters.data![elementId] = { op: "submitted" };
} else if (filterType.filterValue === "Skipped") {
filters.data![elementId] = { op: "skipped" };
} else if (filterType.filterValue === "Includes either") {
filters.data![elementId] = {
op: "includesOne",
value: (filterType.filterComboBoxValue as string[]).map((value) => parseInt(value)),
};
}
};
// Helper function to process CTA filters
const processCTAFilter = (
filterType: FilterValue["filterType"],
elementId: string,
filters: TResponseFilterCriteria
) => {
if (filterType.filterComboBoxValue === "Clicked") {
filters.data![elementId] = { op: "clicked" };
} else if (filterType.filterComboBoxValue === "Dismissed") {
filters.data![elementId] = { op: "skipped" };
}
};
// Helper function to process Consent filters
const processConsentFilter = (
filterType: FilterValue["filterType"],
elementId: string,
filters: TResponseFilterCriteria
) => {
if (filterType.filterComboBoxValue === "Accepted") {
filters.data![elementId] = { op: "accepted" };
} else if (filterType.filterComboBoxValue === "Dismissed") {
filters.data![elementId] = { op: "skipped" };
}
};
// Helper function to process Picture Selection filters
const processPictureSelectionFilter = (
filterType: FilterValue["filterType"],
elementId: string,
element: ReturnType<typeof getElementsFromBlocks>[number] | undefined,
filters: TResponseFilterCriteria
) => {
if (
element?.type !== TSurveyElementTypeEnum.PictureSelection ||
!Array.isArray(filterType.filterComboBoxValue)
) {
return;
}
const selectedOptions = filterType.filterComboBoxValue.map((option) => {
const index = parseInt(option.split(" ")[1]);
return element?.choices[index - 1].id;
});
if (filterType.filterValue === "Includes all") {
filters.data![elementId] = { op: "includesAll", value: selectedOptions };
} else if (filterType.filterValue === "Includes either") {
filters.data![elementId] = { op: "includesOne", value: selectedOptions };
}
};
// Helper function to process Matrix filters
const processMatrixFilter = (
filterType: FilterValue["filterType"],
elementId: string,
filters: TResponseFilterCriteria
) => {
if (
filterType.filterValue &&
filterType.filterComboBoxValue &&
typeof filterType.filterComboBoxValue === "string"
) {
filters.data![elementId] = {
op: "matrix",
value: { [filterType.filterValue]: filterType.filterComboBoxValue },
};
}
};
// Helper function to process element filters
const processElementFilters = (
elements: FilterValue[],
survey: TSurvey,
filters: TResponseFilterCriteria
) => {
if (!elements.length) return;
const surveyElements = getElementsFromBlocks(survey.blocks);
filters.data = filters.data || {};
elements.forEach(({ filterType, elementType }) => {
const elementId = elementType.id ?? "";
const element = surveyElements.find((q) => q.id === elementId);
switch (elementType.elementType) {
case TSurveyElementTypeEnum.OpenText:
case TSurveyElementTypeEnum.Address:
case TSurveyElementTypeEnum.ContactInfo:
processFilledOutSkippedFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.Ranking:
processRankingFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.MultipleChoiceSingle:
case TSurveyElementTypeEnum.MultipleChoiceMulti:
processMultipleChoiceFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.NPS:
case TSurveyElementTypeEnum.Rating:
processNPSRatingFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.CTA:
processCTAFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.Consent:
processConsentFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.PictureSelection:
processPictureSelectionFilter(filterType, elementId, element, filters);
break;
case TSurveyElementTypeEnum.Matrix:
processMatrixFilter(filterType, elementId, filters);
break;
}
});
};
// Helper function to process equals/not equals filters (for hiddenFields, attributes, others)
const processEqualsNotEqualsFilter = (
filterType: FilterValue["filterType"],
label: string | undefined,
filters: TResponseFilterCriteria,
targetKey: "data" | "contactAttributes" | "others"
) => {
if (!filterType.filterComboBoxValue) return;
if (targetKey === "data") {
filters.data = filters.data || {};
if (filterType.filterValue === "Equals") {
filters.data[label ?? ""] = { op: "equals", value: filterType.filterComboBoxValue as string };
} else if (filterType.filterValue === "Not equals") {
filters.data[label ?? ""] = { op: "notEquals", value: filterType.filterComboBoxValue as string };
}
} else if (targetKey === "contactAttributes") {
filters.contactAttributes = filters.contactAttributes || {};
if (filterType.filterValue === "Equals") {
filters.contactAttributes[label ?? ""] = {
op: "equals",
value: filterType.filterComboBoxValue as string,
};
} else if (filterType.filterValue === "Not equals") {
filters.contactAttributes[label ?? ""] = {
op: "notEquals",
value: filterType.filterComboBoxValue as string,
};
}
} else if (targetKey === "others") {
filters.others = filters.others || {};
if (filterType.filterValue === "Equals") {
filters.others[label ?? ""] = { op: "equals", value: filterType.filterComboBoxValue as string };
} else if (filterType.filterValue === "Not equals") {
filters.others[label ?? ""] = { op: "notEquals", value: filterType.filterComboBoxValue as string };
}
}
};
// Helper function to process meta filters
const processMetaFilters = (meta: FilterValue[], filters: TResponseFilterCriteria) => {
if (!meta.length) return;
filters.meta = filters.meta || {};
meta.forEach(({ filterType, elementType }) => {
const label = elementType.label ?? "";
const metaFilters = filters.meta!; // Safe because we initialized it above
// For text input cases (URL filtering)
if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
const value = filterType.filterComboBoxValue.trim();
const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
if (op) {
metaFilters[label] = { op, value };
}
}
// For dropdown/select cases (existing metadata fields)
else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
const value = filterType.filterComboBoxValue[0];
if (filterType.filterValue === "Equals") {
metaFilters[label] = { op: "equals", value };
} else if (filterType.filterValue === "Not equals") {
metaFilters[label] = { op: "notEquals", value };
}
}
});
};
// Helper function to process quota filters
const processQuotaFilters = (quotas: FilterValue[], filters: TResponseFilterCriteria) => {
if (!quotas.length) return;
filters.quotas = filters.quotas || {};
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
"Screened in": "screenedIn",
"Screened out (overquota)": "screenedOut",
"Not in quota": "screenedOutNotInQuota",
};
quotas.forEach(({ filterType, elementType }) => {
const quotaId = elementType.id;
if (!quotaId) return;
const op = statusMap[String(filterType.filterComboBoxValue)];
if (op) filters.quotas![quotaId] = { op };
});
};
// get the formatted filter expression to fetch filtered responses
export const getFormattedFilters = (
survey: TSurvey,
@@ -311,252 +606,34 @@ export const getFormattedFilters = (
});
}
if (elements.length) {
const surveyElements = getElementsFromBlocks(survey.blocks);
elements.forEach(({ filterType, elementType }) => {
if (!filters.data) filters.data = {};
switch (elementType.elementType) {
case TSurveyElementTypeEnum.OpenText:
case TSurveyElementTypeEnum.Address:
case TSurveyElementTypeEnum.ContactInfo: {
if (filterType.filterComboBoxValue === "Filled out") {
filters.data[elementType.id ?? ""] = {
op: "filledOut",
};
} else if (filterType.filterComboBoxValue === "Skipped") {
filters.data[elementType.id ?? ""] = {
op: "skipped",
};
}
break;
}
case TSurveyElementTypeEnum.Ranking: {
if (filterType.filterComboBoxValue === "Filled out") {
filters.data[elementType.id ?? ""] = {
op: "submitted",
};
} else if (filterType.filterComboBoxValue === "Skipped") {
filters.data[elementType.id ?? ""] = {
op: "skipped",
};
}
break;
}
case TSurveyElementTypeEnum.MultipleChoiceSingle:
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
if (filterType.filterValue === "Includes either") {
filters.data[elementType.id ?? ""] = {
op: "includesOne",
value: filterType.filterComboBoxValue as string[],
};
} else if (filterType.filterValue === "Includes all") {
filters.data[elementType.id ?? ""] = {
op: "includesAll",
value: filterType.filterComboBoxValue as string[],
};
}
break;
}
case TSurveyElementTypeEnum.NPS:
case TSurveyElementTypeEnum.Rating: {
if (filterType.filterValue === "Is equal to") {
filters.data[elementType.id ?? ""] = {
op: "equals",
value: parseInt(filterType.filterComboBoxValue as string),
};
} else if (filterType.filterValue === "Is less than") {
filters.data[elementType.id ?? ""] = {
op: "lessThan",
value: parseInt(filterType.filterComboBoxValue as string),
};
} else if (filterType.filterValue === "Is more than") {
filters.data[elementType.id ?? ""] = {
op: "greaterThan",
value: parseInt(filterType.filterComboBoxValue as string),
};
} else if (filterType.filterValue === "Submitted") {
filters.data[elementType.id ?? ""] = {
op: "submitted",
};
} else if (filterType.filterValue === "Skipped") {
filters.data[elementType.id ?? ""] = {
op: "skipped",
};
} else if (filterType.filterValue === "Includes either") {
filters.data[elementType.id ?? ""] = {
op: "includesOne",
value: (filterType.filterComboBoxValue as string[]).map((value) => parseInt(value)),
};
}
break;
}
case TSurveyElementTypeEnum.CTA: {
if (filterType.filterComboBoxValue === "Clicked") {
filters.data[elementType.id ?? ""] = {
op: "clicked",
};
} else if (filterType.filterComboBoxValue === "Dismissed") {
filters.data[elementType.id ?? ""] = {
op: "skipped",
};
}
break;
}
case TSurveyElementTypeEnum.Consent: {
if (filterType.filterComboBoxValue === "Accepted") {
filters.data[elementType.id ?? ""] = {
op: "accepted",
};
} else if (filterType.filterComboBoxValue === "Dismissed") {
filters.data[elementType.id ?? ""] = {
op: "skipped",
};
}
break;
}
case TSurveyElementTypeEnum.PictureSelection: {
const elementId = elementType.id ?? "";
const element = surveyElements.find((q) => q.id === elementId);
if (
element?.type !== TSurveyElementTypeEnum.PictureSelection ||
!Array.isArray(filterType.filterComboBoxValue)
) {
return;
}
const selectedOptions = filterType.filterComboBoxValue.map((option) => {
const index = parseInt(option.split(" ")[1]);
return element?.choices[index - 1].id;
});
if (filterType.filterValue === "Includes all") {
filters.data[elementId] = {
op: "includesAll",
value: selectedOptions,
};
} else if (filterType.filterValue === "Includes either") {
filters.data[elementId] = {
op: "includesOne",
value: selectedOptions,
};
}
break;
}
case TSurveyElementTypeEnum.Matrix: {
if (
filterType.filterValue &&
filterType.filterComboBoxValue &&
typeof filterType.filterComboBoxValue === "string"
) {
filters.data[elementType.id ?? ""] = {
op: "matrix",
value: { [filterType.filterValue]: filterType.filterComboBoxValue },
};
}
break;
}
}
});
}
processElementFilters(elements, survey, filters);
// for hidden fields
if (hiddenFields.length) {
filters.data = filters.data || {};
hiddenFields.forEach(({ filterType, elementType }) => {
if (!filters.data) filters.data = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.data[elementType.label ?? ""] = {
op: "equals",
value: filterType.filterComboBoxValue as string,
};
} else if (filterType.filterValue === "Not equals") {
filters.data[elementType.label ?? ""] = {
op: "notEquals",
value: filterType.filterComboBoxValue as string,
};
}
processEqualsNotEqualsFilter(filterType, elementType.label, filters, "data");
});
}
// for attributes
if (attributes.length) {
filters.contactAttributes = filters.contactAttributes || {};
attributes.forEach(({ filterType, elementType }) => {
if (!filters.contactAttributes) filters.contactAttributes = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.contactAttributes[elementType.label ?? ""] = {
op: "equals",
value: filterType.filterComboBoxValue as string,
};
} else if (filterType.filterValue === "Not equals") {
filters.contactAttributes[elementType.label ?? ""] = {
op: "notEquals",
value: filterType.filterComboBoxValue as string,
};
}
processEqualsNotEqualsFilter(filterType, elementType.label, filters, "contactAttributes");
});
}
// for others
if (others.length) {
filters.others = filters.others || {};
others.forEach(({ filterType, elementType }) => {
if (!filters.others) filters.others = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.others[elementType.label ?? ""] = {
op: "equals",
value: filterType.filterComboBoxValue as string,
};
} else if (filterType.filterValue === "Not equals") {
filters.others[elementType.label ?? ""] = {
op: "notEquals",
value: filterType.filterComboBoxValue as string,
};
}
processEqualsNotEqualsFilter(filterType, elementType.label, filters, "others");
});
}
// for meta
if (meta.length) {
meta.forEach(({ filterType, elementType }) => {
if (!filters.meta) filters.meta = {};
// For text input cases (URL filtering)
if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
const value = filterType.filterComboBoxValue.trim();
const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
if (op) {
filters.meta[elementType.label ?? ""] = { op, value };
}
}
// For dropdown/select cases (existing metadata fields)
else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
const value = filterType.filterComboBoxValue[0]; // Take first selected value
if (filterType.filterValue === "Equals") {
filters.meta[elementType.label ?? ""] = { op: "equals", value };
} else if (filterType.filterValue === "Not equals") {
filters.meta[elementType.label ?? ""] = { op: "notEquals", value };
}
}
});
}
if (quotas.length) {
quotas.forEach(({ filterType, elementType }) => {
filters.quotas ??= {};
const quotaId = elementType.id;
if (!quotaId) return;
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
"Screened in": "screenedIn",
"Screened out (overquota)": "screenedOut",
"Not in quota": "screenedOutNotInQuota",
};
const op = statusMap[String(filterType.filterComboBoxValue)];
if (op) filters.quotas[quotaId] = { op };
});
}
processMetaFilters(meta, filters);
processQuotaFilters(quotas, filters);
return filters;
};

View File

@@ -1245,8 +1245,8 @@ checksums:
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
@@ -1564,8 +1564,8 @@ checksums:
environments/surveys/edit/unlock_targeting_description: 8e315dc41c2849754839a1460643c5fb
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7

View File

@@ -178,242 +178,68 @@ export const BlockCard = ({
);
};
// Common props shared by all element forms
const getCommonFormProps = (element: TSurveyElement, elementIdx: number) => ({
localSurvey,
element,
elementIdx,
updateElement,
selectedLanguageCode,
setSelectedLanguageCode,
isInvalid: invalidElements ? invalidElements.includes(element.id) : false,
locale,
isStorageConfigured,
isExternalUrlsAllowed,
});
// Element form components mapped by type
const elementFormMap = {
[TSurveyElementTypeEnum.OpenText]: OpenElementForm,
[TSurveyElementTypeEnum.MultipleChoiceSingle]: MultipleChoiceElementForm,
[TSurveyElementTypeEnum.MultipleChoiceMulti]: MultipleChoiceElementForm,
[TSurveyElementTypeEnum.NPS]: NPSElementForm,
[TSurveyElementTypeEnum.CTA]: CTAElementForm,
[TSurveyElementTypeEnum.Rating]: RatingElementForm,
[TSurveyElementTypeEnum.Consent]: ConsentElementForm,
[TSurveyElementTypeEnum.Date]: DateElementForm,
[TSurveyElementTypeEnum.PictureSelection]: PictureSelectionForm,
[TSurveyElementTypeEnum.FileUpload]: FileUploadElementForm,
[TSurveyElementTypeEnum.Cal]: CalElementForm,
[TSurveyElementTypeEnum.Matrix]: MatrixElementForm,
[TSurveyElementTypeEnum.Address]: AddressElementForm,
[TSurveyElementTypeEnum.Ranking]: RankingElementForm,
[TSurveyElementTypeEnum.ContactInfo]: ContactInfoElementForm,
};
// Elements that need lastElement prop
const elementsWithLastElement = new Set([
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.CTA,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.Cal,
TSurveyElementTypeEnum.ContactInfo,
]);
const renderElementForm = (element: TSurveyElement, elementIdx: number) => {
switch (element.type) {
case TSurveyElementTypeEnum.OpenText:
return (
<OpenElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
lastElement={lastElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return (
<MultipleChoiceElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return (
<MultipleChoiceElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.NPS:
return (
<NPSElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.CTA:
return (
<CTAElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
lastElement={lastElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Rating:
return (
<RatingElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
lastElement={lastElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Consent:
return (
<ConsentElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Date:
return (
<DateElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.PictureSelection:
return (
<PictureSelectionForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
);
case TSurveyElementTypeEnum.FileUpload:
return (
<FileUploadElementForm
localSurvey={localSurvey}
project={project}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
isFormbricksCloud={isFormbricksCloud}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Cal:
return (
<CalElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
lastElement={lastElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Matrix:
return (
<MatrixElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Address:
return (
<AddressElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.Ranking:
return (
<RankingElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
case TSurveyElementTypeEnum.ContactInfo:
return (
<ContactInfoElementForm
localSurvey={localSurvey}
element={element}
elementIdx={elementIdx}
updateElement={updateElement}
lastElement={lastElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidElements ? invalidElements.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
default:
return null;
const FormComponent = elementFormMap[element.type];
if (!FormComponent) return null;
const commonProps = getCommonFormProps(element, elementIdx);
// Add lastElement for specific element types
const additionalProps: Record<string, unknown> = {};
if (elementsWithLastElement.has(element.type)) {
additionalProps.lastElement = lastElement;
}
// FileUpload needs extra props
if (element.type === TSurveyElementTypeEnum.FileUpload) {
additionalProps.project = project;
additionalProps.isFormbricksCloud = isFormbricksCloud;
}
// @ts-expect-error - These props should cover everything
return <FormComponent {...commonProps} {...additionalProps} />;
};
const style = {

View File

@@ -27,7 +27,7 @@ import { getDefaultEndingCard } from "@/app/lib/survey-builder";
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isConditionGroup } from "@/lib/surveyLogic/utils";
import { checkForEmptyFallBackValue, extractRecallInfo } from "@/lib/utils/recall";
import { checkForEmptyFallBackValue } from "@/lib/utils/recall";
import { MultiLanguageCard } from "@/modules/ee/multi-language-surveys/components/multi-language-card";
import { AddElementButton } from "@/modules/survey/editor/components/add-element-button";
import { AddEndingCardButton } from "@/modules/survey/editor/components/add-ending-card-button";
@@ -205,7 +205,7 @@ export const ElementsView = ({
useEffect(() => {
if (!invalidElements) return;
let updatedInvalidElements: string[] = { ...invalidElements };
let updatedInvalidElements: string[] = [...invalidElements];
// Check welcome card
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
@@ -400,33 +400,25 @@ export const ElementsView = ({
});
};
const deleteElement = (elementIdx: number) => {
const element = elements[elementIdx];
if (!element) return;
const elementId = element.id;
const activeElementIdTemp = activeElementId ?? elements[0]?.id;
let updatedSurvey: TSurvey = { ...localSurvey };
// checking if this element is used in logic of any other element
const validateElementDeletion = (elementId: string, elementIdx: number): boolean => {
const usedElementIdx = findElementUsedInLogic(localSurvey, elementId);
if (usedElementIdx !== -1) {
toast.error(
t("environments.surveys.edit.question_used_in_logic", { questionIndex: usedElementIdx + 1 })
);
return;
return false;
}
const recallElementIdx = isUsedInRecall(localSurvey, elementId);
if (recallElementIdx === elements.length) {
toast.error(t("environments.surveys.edit.question_used_in_recall_ending_card"));
return;
return false;
}
if (recallElementIdx !== -1) {
toast.error(
t("environments.surveys.edit.question_used_in_recall", { questionIndex: recallElementIdx + 1 })
);
return;
return false;
}
const quotaIdx = quotas.findIndex((quota) => isUsedInQuota(quota, { elementId: elementId }));
@@ -437,65 +429,61 @@ export const ElementsView = ({
quotaName: quotas[quotaIdx].name,
})
);
return;
return false;
}
// check if we are recalling from this element for every language
updatedSurvey.blocks = (updatedSurvey.blocks ?? []).map((block) => ({
...block,
elements: block.elements.map((element) => {
const updatedElement = { ...element };
for (const [languageCode, headline] of Object.entries(element.headline)) {
if (headline.includes(`recall:${elementId}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
updatedElement.headline = {
...updatedElement.headline,
[languageCode]: headline.replace(recallInfo, ""),
};
}
}
}
return updatedElement;
}),
}));
// Find the block containing this element
const { blockId, blockIndex } = findElementLocation(localSurvey, elementId);
if (!blockId || blockIndex === -1) return;
const block = updatedSurvey.blocks[blockIndex];
// If this is the only element in the block, delete the entire block
if (block.elements.length === 1) {
const result = deleteBlock(updatedSurvey, blockId);
if (!result.ok) {
toast.error(result.error.message);
return;
}
updatedSurvey = result.data;
} else {
// Otherwise, just remove this element from the block
const result = deleteElementFromBlock(updatedSurvey, blockId, elementId);
if (!result.ok) {
toast.error(result.error.message);
return;
}
updatedSurvey = result.data;
}
const firstEndingCard = localSurvey.endings[0];
setLocalSurvey(updatedSurvey);
delete internalElementIdMap[elementId];
return true;
};
const handleActiveElementAfterDeletion = (
elementId: string,
elementIdx: number,
updatedSurvey: TSurvey,
activeElementIdTemp: string
) => {
if (elementId === activeElementIdTemp) {
const newElements = updatedSurvey.blocks.flatMap((b) => b.elements) ?? [];
const firstEndingCard = localSurvey.endings[0];
if (elementIdx <= newElements.length && newElements.length > 0) {
setActiveElementId(newElements[elementIdx % newElements.length].id);
} else if (firstEndingCard) {
setActiveElementId(firstEndingCard.id);
}
}
};
const deleteElement = (elementIdx: number) => {
const element = elements[elementIdx];
if (!element) return;
const elementId = element.id;
if (!validateElementDeletion(elementId, elementIdx)) {
return;
}
const activeElementIdTemp = activeElementId ?? elements[0]?.id;
// let updatedSurvey = removeRecallReferences(localSurvey, elementId);
let updatedSurvey = structuredClone(localSurvey);
const { blockId, blockIndex } = findElementLocation(localSurvey, elementId);
if (!blockId || blockIndex === -1) return;
const block = updatedSurvey.blocks[blockIndex];
const result =
block.elements.length === 1
? deleteBlock(updatedSurvey, blockId)
: deleteElementFromBlock(updatedSurvey, blockId, elementId);
if (!result.ok) {
toast.error(result.error.message);
return;
}
updatedSurvey = result.data;
setLocalSurvey(updatedSurvey);
delete internalElementIdMap[elementId];
handleActiveElementAfterDeletion(elementId, elementIdx, updatedSurvey, activeElementIdTemp);
toast.success(t("environments.surveys.edit.question_deleted"));
};

View File

@@ -13,5 +13,5 @@ export const copySurveyLink = (surveyUrl: string, singleUseId?: string): string
* @param blocks - Array of survey blocks
* @returns An array of TSurveyElement (pure elements without block-level properties)
*/
export const getElementsFromBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
blocks.flatMap((block) => block.elements);
export const getElementsFromBlocks = (blocks: TSurveyBlock[] | undefined): TSurveyElement[] =>
blocks?.flatMap((block) => block.elements) ?? [];

View File

@@ -20,6 +20,29 @@ interface MultipleChoiceMultiElementProps {
dir?: "ltr" | "rtl" | "auto";
}
const getInitialOtherSelected = (
value: string[],
element: TSurveyMultipleChoiceElement,
languageCode: string
): boolean => {
if (!value) return false;
const choicesWithoutOther = element.choices
.filter((choice) => choice.id !== "other")
.map((item) => getLocalizedValue(item.label, languageCode));
const valueArray = Array.isArray(value) ? value : [value];
return valueArray.some((item) => !choicesWithoutOther.includes(item));
};
const getInitialOtherValue = (
value: string[],
element: TSurveyMultipleChoiceElement,
languageCode: string
): string => {
if (!Array.isArray(value)) return "";
const filtered = value.filter((v) => !element.choices.find((c) => c.label[languageCode] === v));
return filtered[0] || "";
};
export function MultipleChoiceMultiElement({
element,
value,
@@ -50,17 +73,11 @@ export function MultipleChoiceMultiElement({
.map((item) => getLocalizedValue(item.label, languageCode)),
[element, languageCode]
);
const [otherSelected, setOtherSelected] = useState<boolean>(
Boolean(value) &&
(Array.isArray(value) ? value : [value]).some((item) => {
return !getChoicesWithoutOtherLabels().includes(item);
})
);
const [otherValue, setOtherValue] = useState(
(Array.isArray(value) &&
value.filter((v) => !element.choices.find((c) => c.label[languageCode] === v))[0]) ||
""
const [otherSelected, setOtherSelected] = useState<boolean>(() =>
getInitialOtherSelected(value, element, languageCode)
);
const [otherValue, setOtherValue] = useState(() => getInitialOtherValue(value, element, languageCode));
const elementChoices = useMemo(() => {
if (!element.choices) {
@@ -109,27 +126,19 @@ export function MultipleChoiceMultiElement({
const addItem = (item: string) => {
const isOtherValue = !elementChoiceLabels.includes(item);
const currentValue = Array.isArray(value) ? value : [];
if (Array.isArray(value)) {
if (isOtherValue) {
const newValue = value.filter((v) => {
return elementChoiceLabels.includes(v);
});
onChange({ [element.id]: [...newValue, item] });
return;
}
onChange({ [element.id]: [...value, item] });
return;
if (isOtherValue) {
const newValue = currentValue.filter((v) => elementChoiceLabels.includes(v));
onChange({ [element.id]: [...newValue, item] });
} else {
onChange({ [element.id]: [...currentValue, item] });
}
onChange({ [element.id]: [item] }); // if not array, make it an array
};
const removeItem = (item: string) => {
if (Array.isArray(value)) {
onChange({ [element.id]: value.filter((i) => i !== item) });
return;
}
onChange({ [element.id]: [] }); // if not array, make it an array
const currentValue = Array.isArray(value) ? value : [];
onChange({ [element.id]: currentValue.filter((i) => i !== item) });
};
const getIsRequired = () => {
@@ -137,28 +146,201 @@ export function MultipleChoiceMultiElement({
if (otherSelected && otherValue) {
responseValues.push(otherValue);
}
return element.required && Array.isArray(responseValues) && responseValues.length
? false
: element.required;
const hasResponse = Array.isArray(responseValues) && responseValues.length > 0;
return element.required && hasResponse ? false : element.required;
};
const handleFormSubmit = (e: Event) => {
e.preventDefault();
const choicesWithoutOther = getChoicesWithoutOtherLabels();
const newValue = value.filter((item) => choicesWithoutOther.includes(item) || item === otherValue);
if (otherValue && otherSelected && !newValue.includes(otherValue)) {
newValue.push(otherValue);
}
onChange({ [element.id]: newValue });
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
};
const handleOtherOptionToggle = () => {
if (otherSelected) {
setOtherValue("");
onChange({
[element.id]: value.filter((item) => getChoicesWithoutOtherLabels().includes(item)),
});
}
setOtherSelected(!otherSelected);
};
const handleOtherValueBlur = () => {
const newValue = value.filter((item) => getChoicesWithoutOtherLabels().includes(item));
if (otherValue && otherSelected) {
newValue.push(otherValue);
onChange({ [element.id]: newValue });
}
};
const handleNoneOptionChange = (checked: boolean) => {
if (checked) {
setOtherSelected(false);
setOtherValue("");
onChange({ [element.id]: [getLocalizedValue(noneOption!.label, languageCode)] });
} else {
removeItem(getLocalizedValue(noneOption!.label, languageCode));
}
};
const handleKeyDown = (choiceId: string) => (e: KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
document.getElementById(choiceId)?.click();
}
};
const otherOptionInputDir = !otherValue ? dir : "auto";
const renderChoice = (choice: NonNullable<(typeof elementChoices)[0]>, idx: number) => {
if (choice.id === "other" || choice.id === "none") return null;
const choiceLabel = getLocalizedValue(choice.label, languageCode);
const isChecked = Array.isArray(value) && value.includes(choiceLabel);
const labelClassName = cn(
isChecked ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border fb-bg-input-bg",
isNoneSelected ? "fb-opacity-50" : "",
baseLabelClassName
);
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
id={choice.id}
name={element.id}
tabIndex={-1}
value={choiceLabel}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
disabled={isNoneSelected}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
if (checked) {
addItem(choiceLabel);
} else {
removeItem(choiceLabel);
}
}}
checked={isChecked}
required={getIsRequired()}
/>
<span id={`${choice.id}-label`} className="fb-mx-3 fb-grow fb-font-medium" dir="auto">
{choiceLabel}
</span>
</span>
</label>
);
};
const renderOtherOption = () => {
if (!otherOption) return null;
const otherLabel = getLocalizedValue(otherOption.label, languageCode);
const labelClassName = cn(
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border fb-bg-input-bg",
isNoneSelected ? "fb-opacity-50" : "",
baseLabelClassName
);
const placeholder =
getLocalizedValue(element.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(element.otherOptionPlaceholder, languageCode)
: "Please specify";
return (
<label
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(otherOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={isCurrent ? 0 : -1}
id={otherOption.id}
name={element.id}
value={otherLabel}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
disabled={isNoneSelected}
onChange={handleOtherOptionToggle}
checked={otherSelected}
/>
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium" dir="auto">
{otherLabel}
</span>
</span>
{otherSelected && (
<input
ref={otherSpecify}
dir={otherOptionInputDir}
id={`${otherOption.id}-specify`}
maxLength={250}
name={element.id}
tabIndex={isCurrent ? 0 : -1}
value={otherValue}
pattern=".*\S+.*"
onChange={(e) => setOtherValue(e.currentTarget.value)}
onBlur={handleOtherValueBlur}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={placeholder}
required={element.required}
aria-labelledby={`${otherOption.id}-label`}
/>
)}
</label>
);
};
const renderNoneOption = () => {
if (!noneOption) return null;
const noneLabel = getLocalizedValue(noneOption.label, languageCode);
const labelClassName = cn(
isNoneSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border fb-bg-input-bg",
baseLabelClassName
);
return (
<label
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={-1}
id={noneOption.id}
name={element.id}
value={noneLabel}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${noneOption.id}-label`}
onChange={(e) => handleNoneOptionChange((e.target as HTMLInputElement).checked)}
checked={isNoneSelected}
/>
<span id={`${noneOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium" dir="auto">
{noneLabel}
</span>
</span>
</label>
);
};
return (
<form
key={element.id}
onSubmit={(e) => {
e.preventDefault();
const newValue = value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item) || item === otherValue;
}); // filter out all those values which are either in getChoicesWithoutOtherLabels() (i.e. selected by checkbox) or the latest entered otherValue
if (otherValue && otherSelected && !newValue.includes(otherValue)) newValue.push(otherValue);
onChange({ [element.id]: newValue });
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} /> : null}
<form key={element.id} onSubmit={handleFormSubmit} className="fb-w-full">
{isMediaAvailable && <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} />}
<Headline
headline={getLocalizedValue(element.headline, languageCode)}
elementId={element.id}
@@ -172,184 +354,9 @@ export function MultipleChoiceMultiElement({
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-bg-survey-bg fb-relative fb-space-y-2" ref={choicesContainerRef}>
{elementChoices.map((choice, idx) => {
if (!choice || choice.id === "other" || choice.id === "none") return;
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
className={cn(
value.includes(getLocalizedValue(choice.label, languageCode))
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border fb-bg-input-bg",
isNoneSelected ? "fb-opacity-50" : "",
baseLabelClassName
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(choice.id)?.click();
}
}}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
id={choice.id}
name={element.id}
tabIndex={-1}
value={getLocalizedValue(choice.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
disabled={isNoneSelected}
onChange={(e) => {
if ((e.target as HTMLInputElement).checked) {
addItem(getLocalizedValue(choice.label, languageCode));
} else {
removeItem(getLocalizedValue(choice.label, languageCode));
}
}}
checked={
Array.isArray(value) && value.includes(getLocalizedValue(choice.label, languageCode))
}
required={getIsRequired()}
/>
<span id={`${choice.id}-label`} className="fb-mx-3 fb-grow fb-font-medium" dir="auto">
{getLocalizedValue(choice.label, languageCode)}
</span>
</span>
</label>
);
})}
{otherOption ? (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
otherSelected
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border fb-bg-input-bg",
isNoneSelected ? "fb-opacity-50" : "",
baseLabelClassName
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the checkbox
if (e.key === " ") {
e.preventDefault();
document.getElementById(otherOption.id)?.click();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={isCurrent ? 0 : -1}
id={otherOption.id}
name={element.id}
value={getLocalizedValue(otherOption.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
disabled={isNoneSelected}
onChange={() => {
if (otherSelected) {
setOtherValue("");
onChange({
[element.id]: value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item);
}),
});
}
setOtherSelected(!otherSelected);
}}
checked={otherSelected}
/>
<span
id={`${otherOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
{otherSelected ? (
<input
ref={otherSpecify}
dir={otherOptionInputDir}
id={`${otherOption.id}-specify`}
maxLength={250}
name={element.id}
tabIndex={isCurrent ? 0 : -1}
value={otherValue}
pattern=".*\S+.*"
onChange={(e) => {
setOtherValue(e.currentTarget.value);
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(element.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(element.otherOptionPlaceholder, languageCode)
: "Please specify"
}
required={element.required}
aria-labelledby={`${otherOption.id}-label`}
onBlur={() => {
const newValue = value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item);
});
if (otherValue && otherSelected) {
newValue.push(otherValue);
onChange({ [element.id]: newValue });
}
}}
/>
) : null}
</label>
) : null}
{noneOption ? (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
isNoneSelected
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border fb-bg-input-bg",
baseLabelClassName
)}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
document.getElementById(noneOption.id)?.click();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={-1}
id={noneOption.id}
name={element.id}
value={getLocalizedValue(noneOption.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${noneOption.id}-label`}
onChange={(e) => {
if ((e.target as HTMLInputElement).checked) {
setOtherSelected(false);
setOtherValue("");
onChange({ [element.id]: [getLocalizedValue(noneOption.label, languageCode)] });
} else {
removeItem(getLocalizedValue(noneOption.label, languageCode));
}
}}
checked={isNoneSelected}
/>
<span
id={`${noneOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(noneOption.label, languageCode)}
</span>
</span>
</label>
) : null}
{elementChoices.map((choice, idx) => choice && renderChoice(choice, idx))}
{renderOtherOption()}
{renderNoneOption()}
</div>
</fieldset>
</div>

View File

@@ -70,11 +70,13 @@ export function MultipleChoiceSingleElement({
useEffect(() => {
if (!value) {
const prefillAnswer = new URLSearchParams(window.location.search).get(element.id);
if (prefillAnswer) {
if (otherOption && prefillAnswer === getLocalizedValue(otherOption.label, languageCode)) {
setOtherSelected(true);
return;
}
if (
prefillAnswer &&
otherOption &&
prefillAnswer === getLocalizedValue(otherOption.label, languageCode)
) {
setOtherSelected(true);
return;
}
}
@@ -93,16 +95,184 @@ export function MultipleChoiceSingleElement({
const otherOptionInputDir = !value ? dir : "auto";
const handleFormSubmit = (e: Event) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
};
const handleChoiceClick = (choiceValue: string) => {
if (!element.required && value === choiceValue) {
onChange({ [element.id]: undefined });
} else {
setOtherSelected(false);
onChange({ [element.id]: choiceValue });
}
};
const handleOtherOptionClick = () => {
if (otherSelected && !element.required) {
onChange({ [element.id]: undefined });
setOtherSelected(false);
} else if (!otherSelected) {
setOtherSelected(true);
onChange({ [element.id]: "" });
}
};
const handleNoneOptionClick = () => {
const noneValue = getLocalizedValue(noneOption!.label, languageCode);
if (!element.required && value === noneValue) {
onChange({ [element.id]: undefined });
} else {
setOtherSelected(false);
onChange({ [element.id]: noneValue });
}
};
const handleKeyDown = (choiceId: string) => (e: KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
document.getElementById(choiceId)?.click();
document.getElementById(choiceId)?.focus();
}
};
const handleOtherKeyDown = (e: KeyboardEvent) => {
if (e.key === " ") {
if (otherSelected) return;
document.getElementById(otherOption!.id)?.click();
document.getElementById(otherOption!.id)?.focus();
}
};
const renderChoice = (choice: NonNullable<(typeof elementChoices)[0]>, idx: number) => {
if (choice.id === "other" || choice.id === "none") return null;
const choiceLabel = getLocalizedValue(choice.label, languageCode);
const isChecked = value === choiceLabel;
const labelClassName = cn(
isChecked ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading fb-bg-input-bg focus-within:fb-border-brand focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
);
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
type="radio"
id={choice.id}
name={element.id}
value={choiceLabel}
dir={dir}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onClick={() => handleChoiceClick(choiceLabel)}
checked={isChecked}
required={element.required ? idx === 0 : undefined}
/>
<span id={`${choice.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium" dir="auto">
{choiceLabel}
</span>
</span>
</label>
);
};
const renderOtherOption = () => {
if (!otherOption) return null;
const otherLabel = getLocalizedValue(otherOption.label, languageCode);
const labelClassName = cn(
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
);
const placeholder =
getLocalizedValue(element.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(element.otherOptionPlaceholder, languageCode)
: "Please specify";
return (
<label tabIndex={isCurrent ? 0 : -1} className={labelClassName} onKeyDown={handleOtherKeyDown}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
dir={dir}
type="radio"
id={otherOption.id}
name={element.id}
value={otherLabel}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onClick={handleOtherOptionClick}
checked={otherSelected}
/>
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium" dir="auto">
{otherLabel}
</span>
</span>
{otherSelected && (
<input
ref={otherSpecify}
id={`${otherOption.id}-input`}
dir={otherOptionInputDir}
name={element.id}
pattern=".*\S+.*"
value={value ?? ""}
onChange={(e) => onChange({ [element.id]: e.currentTarget.value })}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={placeholder}
required={element.required}
aria-labelledby={`${otherOption.id}-label`}
maxLength={250}
/>
)}
</label>
);
};
const renderNoneOption = () => {
if (!noneOption) return null;
const noneLabel = getLocalizedValue(noneOption.label, languageCode);
const isChecked = value === noneLabel;
const labelClassName = cn(
isChecked ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
);
return (
<label
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
dir={dir}
type="radio"
id={noneOption.id}
name={element.id}
value={noneLabel}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${noneOption.id}-label`}
onClick={handleNoneOptionClick}
checked={isChecked}
/>
<span id={`${noneOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium" dir="auto">
{noneLabel}
</span>
</span>
</label>
);
};
return (
<form
key={element.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} /> : null}
<form key={element.id} onSubmit={handleFormSubmit} className="fb-w-full">
{isMediaAvailable && <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} />}
<Headline
headline={getLocalizedValue(element.headline, languageCode)}
elementId={element.id}
@@ -115,178 +285,13 @@ export function MultipleChoiceSingleElement({
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div
className="fb-bg-survey-bg fb-relative fb-space-y-2"
role="radiogroup"
ref={choicesContainerRef}>
{elementChoices.map((choice, idx) => {
if (!choice || choice.id === "other" || choice.id === "none") return;
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
className={cn(
value === getLocalizedValue(choice.label, languageCode)
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border",
"fb-text-heading fb-bg-input-bg focus-within:fb-border-brand focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
type="radio"
id={choice.id}
name={element.id}
value={getLocalizedValue(choice.label, languageCode)}
dir={dir}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onClick={() => {
const choiceValue = getLocalizedValue(choice.label, languageCode);
if (!element.required && value === choiceValue) {
onChange({ [element.id]: undefined });
} else {
setOtherSelected(false);
onChange({ [element.id]: choiceValue });
}
}}
checked={value === getLocalizedValue(choice.label, languageCode)}
required={element.required ? idx === 0 : undefined}
/>
<span
id={`${choice.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(choice.label, languageCode)}
</span>
</span>
</label>
);
})}
{otherOption ? (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
if (otherSelected) return;
document.getElementById(otherOption.id)?.click();
document.getElementById(otherOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
dir={dir}
type="radio"
id={otherOption.id}
name={element.id}
value={getLocalizedValue(otherOption.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onClick={() => {
if (otherSelected && !element.required) {
onChange({ [element.id]: undefined });
setOtherSelected(false);
} else if (!otherSelected) {
setOtherSelected(true);
onChange({ [element.id]: "" });
}
}}
checked={otherSelected}
/>
<span
id={`${otherOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
{otherSelected ? (
<input
ref={otherSpecify}
id={`${otherOption.id}-input`}
dir={otherOptionInputDir}
name={element.id}
pattern=".*\S+.*"
value={value ?? ""}
onChange={(e) => {
onChange({ [element.id]: e.currentTarget.value });
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(element.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(element.otherOptionPlaceholder, languageCode)
: "Please specify"
}
required={element.required}
aria-labelledby={`${otherOption.id}-label`}
maxLength={250}
/>
) : null}
</label>
) : null}
{noneOption ? (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
value === getLocalizedValue(noneOption.label, languageCode)
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(noneOption.id)?.click();
document.getElementById(noneOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
dir={dir}
type="radio"
id={noneOption.id}
name={element.id}
value={getLocalizedValue(noneOption.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${noneOption.id}-label`}
onClick={() => {
const noneValue = getLocalizedValue(noneOption.label, languageCode);
if (!element.required && value === noneValue) {
onChange({ [element.id]: undefined });
} else {
setOtherSelected(false);
onChange({ [element.id]: noneValue });
}
}}
checked={value === getLocalizedValue(noneOption.label, languageCode)}
/>
<span
id={`${noneOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(noneOption.label, languageCode)}
</span>
</span>
</label>
) : null}
{elementChoices.map((choice, idx) => choice && renderChoice(choice, idx))}
{renderOtherOption()}
{renderNoneOption()}
</div>
</fieldset>
</div>

View File

@@ -54,49 +54,168 @@ export function OpenTextElement({
onChange({ [element.id]: inputValue });
};
const validateRequired = (input: HTMLInputElement | HTMLTextAreaElement | null): boolean => {
if (element.required && (!value || value.trim() === "")) {
input?.setCustomValidity(t("errors.please_fill_out_this_field"));
input?.reportValidity();
return false;
}
return true;
};
const validateEmail = (input: HTMLInputElement | HTMLTextAreaElement | null): boolean => {
if (!ZEmail.safeParse(value).success) {
input?.setCustomValidity(t("errors.please_enter_a_valid_email_address"));
input?.reportValidity();
return false;
}
return true;
};
const validateUrl = (input: HTMLInputElement | HTMLTextAreaElement | null): boolean => {
if (!ZUrl.safeParse(value).success) {
input?.setCustomValidity(t("errors.please_enter_a_valid_url"));
input?.reportValidity();
return false;
}
return true;
};
const validatePhone = (input: HTMLInputElement | HTMLTextAreaElement | null): boolean => {
const phoneRegex = /^[+]?[\d\s\-()]{7,}$/;
if (!phoneRegex.test(value)) {
input?.setCustomValidity(t("errors.please_enter_a_valid_phone_number"));
input?.reportValidity();
return false;
}
return true;
};
const validateInput = (input: HTMLInputElement | HTMLTextAreaElement | null): boolean => {
if (!value || value.trim() === "") return true;
if (element.inputType === "email") {
return validateEmail(input);
}
if (element.inputType === "url") {
return validateUrl(input);
}
if (element.inputType === "phone") {
return validatePhone(input);
}
return true;
};
const handleOnSubmit = (e: Event) => {
e.preventDefault();
const input = inputRef.current;
input?.setCustomValidity("");
if (element.required && (!value || value.trim() === "")) {
input?.setCustomValidity(t("errors.please_fill_out_this_field"));
input?.reportValidity();
return;
}
if (value && value.trim() !== "") {
if (element.inputType === "email") {
if (!ZEmail.safeParse(value).success) {
input?.setCustomValidity(t("errors.please_enter_a_valid_email_address"));
input?.reportValidity();
return;
}
} else if (element.inputType === "url") {
if (!ZUrl.safeParse(value).success) {
input?.setCustomValidity(t("errors.please_enter_a_valid_url"));
input?.reportValidity();
return;
}
} else if (element.inputType === "phone") {
const phoneRegex = /^[+]?[\d\s\-()]{7,}$/;
if (!phoneRegex.test(value)) {
input?.setCustomValidity(t("errors.please_enter_a_valid_phone_number"));
input?.reportValidity();
return;
}
}
}
if (!validateRequired(input)) return;
if (!validateInput(input)) return;
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtc);
};
const computedDir = !value ? dir : "auto";
const getInputTitle = (): string | undefined => {
if (element.inputType === "phone") return t("errors.please_enter_a_valid_phone_number");
if (element.inputType === "email") return t("errors.please_enter_a_valid_email_address");
if (element.inputType === "url") return t("errors.please_enter_a_valid_url");
return undefined;
};
const getInputPattern = (): string => {
return element.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*";
};
const getInputMinLength = (): number | undefined => {
return element.inputType === "text" ? element.charLimit?.min : undefined;
};
const getInputMaxLength = (): number | undefined => {
if (element.inputType === "text") return element.charLimit?.max;
if (element.inputType === "phone") return 30;
return undefined;
};
const getTextareaTitle = (): string | undefined => {
return element.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined;
};
const handleInputOnInput = (e: Event) => {
const input = e.currentTarget as HTMLInputElement;
handleInputChange(input.value);
input.setCustomValidity("");
};
const handleTextareaOnInput = (e: Event) => {
const textarea = e.currentTarget as HTMLTextAreaElement;
handleInputChange(textarea.value);
};
const renderCharLimit = () => {
if (element.inputType !== "text" || element.charLimit?.max === undefined) return null;
const isOverLimit = currentLength >= element.charLimit.max;
const className = `fb-text-xs ${isOverLimit ? "fb-text-red-500 fb-font-semibold" : "fb-text-neutral-400"}`;
return (
<span className={className}>
{currentLength}/{element.charLimit.max}
</span>
);
};
const renderInput = () => {
const computedDir = !value ? dir : "auto";
return (
<input
ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={isCurrent ? 0 : -1}
name={element.id}
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode)}
dir={computedDir}
step="any"
required={element.required}
value={value || ""}
type={element.inputType}
onInput={handleInputOnInput}
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
pattern={getInputPattern()}
title={getInputTitle()}
minLength={getInputMinLength()}
maxLength={getInputMaxLength()}
/>
);
};
const renderTextarea = () => {
return (
<textarea
ref={inputRef as RefObject<HTMLTextAreaElement>}
rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={element.id}
tabIndex={isCurrent ? 0 : -1}
aria-label="textarea"
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode, true)}
dir={dir}
required={element.required}
value={value}
onInput={handleTextareaOnInput}
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
title={getTextareaTitle()}
minLength={getInputMinLength()}
maxLength={getInputMaxLength()}
/>
);
};
return (
<form key={element.id} onSubmit={handleOnSubmit} className="fb-w-full">
{isMediaAvailable ? <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} /> : null}
{isMediaAvailable && <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} />}
<Headline
headline={getLocalizedValue(element.headline, languageCode)}
elementId={element.id}
@@ -107,72 +226,8 @@ export function OpenTextElement({
elementId={element.id}
/>
<div className="fb-mt-4">
{element.longAnswer === false ? (
<input
ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={isCurrent ? 0 : -1}
name={element.id}
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode)}
dir={computedDir}
step="any"
required={element.required}
value={value ? value : ""}
type={element.inputType}
onInput={(e) => {
const input = e.currentTarget;
handleInputChange(input.value);
input.setCustomValidity("");
}}
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
pattern={element.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
title={
element.inputType === "phone"
? t("errors.please_enter_a_valid_phone_number")
: element.inputType === "email"
? t("errors.please_enter_a_valid_email_address")
: element.inputType === "url"
? t("errors.please_enter_a_valid_url")
: undefined
}
minLength={element.inputType === "text" ? element.charLimit?.min : undefined}
maxLength={
element.inputType === "text"
? element.charLimit?.max
: element.inputType === "phone"
? 30
: undefined
}
/>
) : (
<textarea
ref={inputRef as RefObject<HTMLTextAreaElement>}
rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={element.id}
tabIndex={isCurrent ? 0 : -1}
aria-label="textarea"
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode, true)}
dir={dir}
required={element.required}
value={value}
onInput={(e) => {
handleInputChange(e.currentTarget.value);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
title={element.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined}
minLength={element.inputType === "text" ? element.charLimit?.min : undefined}
maxLength={element.inputType === "text" ? element.charLimit?.max : undefined}
/>
)}
{element.inputType === "text" && element.charLimit?.max !== undefined && (
<span
className={`fb-text-xs ${currentLength >= element.charLimit?.max ? "fb-text-red-500 fb-font-semibold" : "fb-text-neutral-400"}`}>
{currentLength}/{element.charLimit?.max}
</span>
)}
{element.longAnswer === false ? renderInput() : renderTextarea()}
{renderCharLimit()}
</div>
</form>
);

View File

@@ -47,26 +47,12 @@ export function PictureSelectionElement({
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const addItem = (item: string) => {
let values: string[] = [];
if (element.allowMulti) {
values = [...value, item];
} else {
values = [item];
}
const values = element.allowMulti ? [...value, item] : [item];
onChange({ [element.id]: values });
};
const removeItem = (item: string) => {
let values: string[] = [];
if (element.allowMulti) {
values = value.filter((i) => i !== item);
} else {
values = [];
}
const values = element.allowMulti ? value.filter((i) => i !== item) : [];
onChange({ [element.id]: values });
};
@@ -78,6 +64,29 @@ export function PictureSelectionElement({
}
};
const handleFormSubmit = (e: Event) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
const target = e.currentTarget as HTMLButtonElement;
target.click();
target.focus();
}
};
const handleImageLoad = (choiceId: string) => {
setLoadingImages((prev) => ({ ...prev, [choiceId]: false }));
};
const handleLinkClick = (e: Event) => {
e.stopPropagation();
};
useEffect(() => {
if (!element.allowMulti && value.length > 1) {
onChange({ [element.id]: [] });
@@ -85,18 +94,98 @@ export function PictureSelectionElement({
// eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute when the allowMulti changes
}, [element.allowMulti]);
const elementChoices = element.choices;
const getButtonClassName = (choiceId: string): string => {
const isSelected = Array.isArray(value) && value.includes(choiceId);
return cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus-visible:fb-outline-none focus-visible:fb-ring-2 focus-visible:fb-ring-brand focus-visible:fb-ring-offset-2 fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] group/image",
isSelected ? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm" : ""
);
};
const getInputClassName = (isSelected: boolean): string => {
const baseClasses = element.allowMulti
? "fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border"
: "fb-border-border fb-pointer-events-none fb-absolute fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border";
return cn(
baseClasses,
isSelected ? "fb-border-brand fb-text-brand" : "",
dir === "rtl" ? "fb-left-2" : "fb-right-2"
);
};
const renderInput = (choiceId: string) => {
const isSelected = value.includes(choiceId);
if (element.allowMulti) {
return (
<input
id={`${choiceId}-checked`}
name={`${choiceId}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={isSelected}
className={getInputClassName(isSelected)}
required={element.required && value.length === 0}
/>
);
}
return (
<input
id={`${choiceId}-radio`}
name={element.id}
type="radio"
tabIndex={-1}
checked={isSelected}
className={getInputClassName(isSelected)}
required={element.required && value.length ? false : element.required}
/>
);
};
const renderChoice = (choice: (typeof element.choices)[0]) => {
const isLoading = loadingImages[choice.id];
return (
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={isCurrent ? 0 : -1}
onKeyDown={handleKeyDown}
onClick={() => handleChange(choice.id)}
className={getButtonClassName(choice.id)}>
{isLoading && (
<div className="fb-absolute fb-inset-0 fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
)}
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className={cn("fb-h-full fb-w-full fb-object-cover", isLoading ? "fb-opacity-0" : "")}
onLoad={() => handleImageLoad(choice.id)}
onError={() => handleImageLoad(choice.id)}
/>
{renderInput(choice.id)}
</button>
<a
tabIndex={-1}
href={choice.imageUrl}
target="_blank"
title={t("common.open_in_new_tab")}
rel="noreferrer"
onClick={handleLinkClick}
className={cn(
"fb-absolute fb-bottom-4 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20",
dir === "rtl" ? "fb-left-2" : "fb-right-2"
)}>
<span className="fb-sr-only">{t("common.open_in_new_tab")}</span>
<ImageDownIcon />
</a>
</div>
);
};
return (
<form
key={element.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} /> : null}
<form key={element.id} onSubmit={handleFormSubmit} className="fb-w-full">
{isMediaAvailable && <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} />}
<Headline
headline={getLocalizedValue(element.headline, languageCode)}
elementId={element.id}
@@ -110,94 +199,7 @@ export function PictureSelectionElement({
<fieldset>
<legend className="fb-sr-only">{t("common.options")}</legend>
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
{elementChoices.map((choice) => (
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
e.currentTarget.click();
e.currentTarget.focus();
}
}}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus-visible:fb-outline-none focus-visible:fb-ring-2 focus-visible:fb-ring-brand focus-visible:fb-ring-offset-2 fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] group/image",
Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: ""
)}>
{loadingImages[choice.id] && (
<div className="fb-absolute fb-inset-0 fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
)}
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className={cn(
"fb-h-full fb-w-full fb-object-cover",
loadingImages[choice.id] ? "fb-opacity-0" : ""
)}
onLoad={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
onError={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
/>
{element.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : "",
dir === "rtl" ? "fb-left-2" : "fb-right-2"
)}
required={element.required && value.length === 0}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${element.id}`}
type="radio"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : "",
dir === "rtl" ? "fb-left-2" : "fb-right-2"
)}
required={element.required && value.length ? false : element.required}
/>
)}
</button>
<a
tabIndex={-1}
href={choice.imageUrl}
target="_blank"
title={t("common.open_in_new_tab")}
rel="noreferrer"
onClick={(e) => {
e.stopPropagation();
}}
className={cn(
"fb-absolute fb-bottom-4 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20",
dir === "rtl" ? "fb-left-2" : "fb-right-2"
)}>
<span className="fb-sr-only">{t("common.open_in_new_tab")}</span>
<ImageDownIcon />
</a>
</div>
))}
{element.choices.map(renderChoice)}
</div>
</fieldset>
</div>

View File

@@ -92,16 +92,152 @@ export function RatingElement({
return "fb-bg-rose-100";
};
const handleFormSubmit = (e: Event) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
};
const handleKeyDown = (number: number) => (e: KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
};
const handleMouseOver = (number: number) => () => {
setHoveredNumber(number);
};
const handleMouseLeave = () => {
setHoveredNumber(0);
};
const handleFocus = (number: number) => () => {
setHoveredNumber(number);
};
const handleBlur = () => {
setHoveredNumber(0);
};
const getNumberLabelClassName = (number: number, totalLength: number): string => {
const isSelected = value === number;
const isLast = totalLength === number;
const isFirst = number === 1;
const isHovered = hoveredNumber === number;
return cn(
isSelected
? "fb-bg-accent-selected-bg fb-border-border-highlight fb-z-10 fb-border"
: "fb-border-border",
isLast ? (dir === "rtl" ? "fb-rounded-l-custom fb-border-l" : "fb-rounded-r-custom fb-border-r") : "",
isFirst ? (dir === "rtl" ? "fb-rounded-r-custom fb-border-r" : "fb-rounded-l-custom fb-border-l") : "",
isHovered ? "fb-bg-accent-bg" : "",
element.isColorCodingEnabled ? "fb-min-h-[47px]" : "fb-min-h-[41px]",
"fb-text-heading focus:fb-border-brand fb-relative fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-justify-center fb-overflow-hidden fb-border-b fb-border-l fb-border-t focus:fb-border-2 focus:fb-outline-none"
);
};
const getStarLabelClassName = (number: number): string => {
const isActive = number <= hoveredNumber || number <= (value ?? 0);
const isHovered = hoveredNumber === number;
return cn(
isActive || isHovered ? "fb-text-amber-400" : "fb-text-[#8696AC]",
"fb-relative fb-flex fb-max-h-16 fb-min-h-9 fb-cursor-pointer fb-justify-center focus:fb-outline-none"
);
};
const getSmileyLabelClassName = (number: number): string => {
const isActive = value === number || hoveredNumber === number;
return cn(
"fb-relative fb-flex fb-max-h-16 fb-min-h-9 fb-w-full fb-cursor-pointer fb-justify-center",
isActive
? "fb-stroke-rating-selected fb-text-rating-selected"
: "fb-stroke-heading fb-text-heading focus:fb-border-accent-bg focus:fb-border-2 focus:fb-outline-none"
);
};
const renderNumberScale = (number: number, totalLength: number) => {
return (
<label
tabIndex={isCurrent ? 0 : -1}
onKeyDown={handleKeyDown(number)}
className={getNumberLabelClassName(number, totalLength)}>
{element.isColorCodingEnabled && (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getRatingNumberOptionColor(element.range, number)}`}
/>
)}
<HiddenRadioInput number={number} id={number.toString()} />
{number}
</label>
);
};
const renderStarScale = (number: number) => {
return (
<label
tabIndex={isCurrent ? 0 : -1}
onKeyDown={handleKeyDown(number)}
className={getStarLabelClassName(number)}
onFocus={handleFocus(number)}
onBlur={handleBlur}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
fillRule="evenodd"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg>
</div>
</label>
);
};
const renderSmileyScale = (number: number, idx: number) => {
return (
<label
tabIndex={isCurrent ? 0 : -1}
className={getSmileyLabelClassName(number)}
onKeyDown={handleKeyDown(number)}
onFocus={handleFocus(number)}
onBlur={handleBlur}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<RatingSmiley
active={value === number || hoveredNumber === number}
idx={idx}
range={element.range}
addColors={element.isColorCodingEnabled}
/>
</div>
</label>
);
};
const renderRatingOption = (number: number, idx: number, totalLength: number) => {
return (
<span
key={number}
onMouseOver={handleMouseOver(number)}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus(number)}
className="fb-bg-survey-bg fb-flex-1 fb-text-center fb-text-sm">
{element.scale === "number" && renderNumberScale(number, totalLength)}
{element.scale === "star" && renderStarScale(number)}
{element.scale !== "number" && element.scale !== "star" && renderSmileyScale(number, idx)}
</span>
);
};
return (
<form
key={element.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} /> : null}
<form key={element.id} onSubmit={handleFormSubmit} className="fb-w-full">
{isMediaAvailable && <ElementMedia imgUrl={element.imageUrl} videoUrl={element.videoUrl} />}
<Headline
headline={getLocalizedValue(element.headline, languageCode)}
elementId={element.id}
@@ -115,124 +251,9 @@ export function RatingElement({
<fieldset className="fb-w-full">
<legend className="fb-sr-only">Choices</legend>
<div className="fb-flex fb-w-full">
{Array.from({ length: element.range }, (_, i) => i + 1).map((number, i, a) => (
<span
key={number}
onMouseOver={() => {
setHoveredNumber(number);
}}
onMouseLeave={() => {
setHoveredNumber(0);
}}
onFocus={() => {
setHoveredNumber(number);
}}
className="fb-bg-survey-bg fb-flex-1 fb-text-center fb-text-sm">
{element.scale === "number" ? (
<label
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
className={cn(
value === number
? "fb-bg-accent-selected-bg fb-border-border-highlight fb-z-10 fb-border"
: "fb-border-border",
a.length === number
? dir === "rtl"
? "fb-rounded-l-custom fb-border-l"
: "fb-rounded-r-custom fb-border-r"
: "",
number === 1
? dir === "rtl"
? "fb-rounded-r-custom fb-border-r"
: "fb-rounded-l-custom fb-border-l"
: "",
hoveredNumber === number ? "fb-bg-accent-bg" : "",
element.isColorCodingEnabled ? "fb-min-h-[47px]" : "fb-min-h-[41px]",
"fb-text-heading focus:fb-border-brand fb-relative fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-justify-center fb-overflow-hidden fb-border-b fb-border-l fb-border-t focus:fb-border-2 focus:fb-outline-none"
)}>
{element.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getRatingNumberOptionColor(element.range, number)}`}
/>
) : null}
<HiddenRadioInput number={number} id={number.toString()} />
{number}
</label>
) : element.scale === "star" ? (
<label
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
className={cn(
number <= hoveredNumber || number <= value! ? "fb-text-amber-400" : "fb-text-[#8696AC]",
hoveredNumber === number ? "fb-text-amber-400" : "",
"fb-relative fb-flex fb-max-h-16 fb-min-h-9 fb-cursor-pointer fb-justify-center focus:fb-outline-none"
)}
onFocus={() => {
setHoveredNumber(number);
}}
onBlur={() => {
setHoveredNumber(0);
}}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
fillRule="evenodd"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg>
</div>
</label>
) : (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
"fb-relative fb-flex fb-max-h-16 fb-min-h-9 fb-w-full fb-cursor-pointer fb-justify-center",
value === number || hoveredNumber === number
? "fb-stroke-rating-selected fb-text-rating-selected"
: "fb-stroke-heading fb-text-heading focus:fb-border-accent-bg focus:fb-border-2 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
onFocus={() => {
setHoveredNumber(number);
}}
onBlur={() => {
setHoveredNumber(0);
}}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className={cn("fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain")}>
<RatingSmiley
active={value === number || hoveredNumber === number}
idx={i}
range={element.range}
addColors={element.isColorCodingEnabled}
/>
</div>
</label>
)}
</span>
))}
{Array.from({ length: element.range }, (_, i) => i + 1).map((number, i, a) =>
renderRatingOption(number, i, a.length)
)}
</div>
<div className="fb-text-subheading fb-mt-4 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-gap-8">
<p className="fb-max-w-[50%]" dir="auto">