mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-05 02:58:36 -06:00
fixes sonarqube issues
This commit is contained in:
@@ -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} />
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"));
|
||||
};
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user