adds ranking question logic

This commit is contained in:
Piyush Gupta
2024-09-02 16:19:42 +05:30
48 changed files with 1520 additions and 145 deletions

View File

@@ -7,12 +7,12 @@ import I3 from "./images/3-survey-logs-in-app-survey-popup.webp";
export const metadata = {
title: "Formbricks App Survey SDK",
description:
"An overview of all available methods & how to integrate Formbricks App Surveys for frontend developers in web applications. Learn the key methods, configuration settings, and best practices.",
"Integrate Formbricks App Surveys into your web apps with the Formbricks JS SDK for App Surveys. Learn how to initialize Formbricks, set attributes, track actions, and troubleshoot common issues.",
};
#### Developer Docs
# SDK: App Survey
# SDK: Run Surveys Inside Your Web Apps
### Overview

View File

@@ -10,11 +10,15 @@ export const metadata = {
Welcome to the Developer Docs section, your comprehensive resource for integrating and utilizing Formbricks SDKs &APIs, as well as contributing to our open source codebase. Here's what you can expect to find in this section:
### [SDK: App Survey](/developer-docs/app-survey-sdk)
### [SDK: React Native Apps](/developer-docs/react-native-in-app-surveys)
The Formbricks React Native SDK for App Surveys is designed for React Native applications, enabling seamless integration of surveys within your mobile apps. Dive into the documentation to learn how to leverage the SDK for app surveys and engage with your users effectively.
### [SDK: Web Apps](/developer-docs/app-survey-sdk)
The Formbricks JS SDK tailored for App Surveys is designed for applications where users are logged in, allowing for targeted and identified interactions within Formbricks. This SDK is particularly useful for advanced user tracking, enabling deeper insights into user behavior. Learn how to seamlessly integrate Formbricks into your applications and harness valuable insights from your logged-in users.
### [SDK: Website Survey](/developer-docs/website-survey-sdk)
### [SDK: Public Websites](/developer-docs/website-survey-sdk)
The Formbricks JS SDK for Website Surveys is ideal for public-facing websites without user authentication. It's recommended for pages with high traffic and no authentication walls, facilitating quick and efficient survey collection. Dive into the documentation to discover how to deploy surveys on your website and effectively engage with your audience.

View File

@@ -1,18 +1,22 @@
import { MdxImage } from "@/components/MdxImage";
export const metadata = {
title: "Formbricks App Survey SDK",
title: "React Native: Formbricks App SDK",
description:
"An overview of all available methods & how to integrate Formbricks App Surveys for frontend developers in web applications. Learn the key methods, configuration settings, and best practices.",
"Integrate Formbricks App Surveys into your React Native apps with the Formbricks React Native SDK.",
};
#### Developer Docs
# React Native SDK: App Survey
# React Native: In App Surveys
### Overview
The Formbricks React Native SDK can be used for seamlessly integrating App Surveys into your React Native Apps. In this section, we'll explore how to leverage the SDK for **app** surveys. Its available on npm [here](https://www.npmjs.com/package/@formbricks/js/).
The Formbricks React Native SDK can be used for seamlessly integrating App Surveys into your React Native Apps. Here, w'll explore how to leverage the SDK for in app surveys. The SDK is [available on npm.](https://www.npmjs.com/package/@formbricks/react-native)
<Note>
Our React Native SDK is now in public beta. We're happy to **offer you a free 3-month trial for our Scale plan** if you're providing feedback on your developer experience. Reach out on Discord and shoot Johannes a message to get the extended trial started.
</Note>
### Install

View File

@@ -4,12 +4,12 @@ import I1 from "./images/1-set-up-website-micro-survey-popup.webp";
export const metadata = {
title: "Formbricks Website Survey SDK",
description:
"An overview of all available methods & how to integrate Formbricks Website Surveys for frontend developers in public-facing web applications. Learn the key methods, configuration settings, and best practices.",
"Run targeted pop-up surveys on your public websites with the Formbricks JS SDK for Website Surveys. Learn how to integrate the SDK, track user actions, and trigger surveys based on user interactions.",
};
#### Developer Docs
# SDK: Website Survey
# SDK: Run Surveys On Public Websites
### Overview

View File

@@ -139,9 +139,9 @@ export const navigation: Array<NavGroup> = [
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
],
},
{ title: "JS SDK: App Survey", href: "/developer-docs/app-survey-sdk" },
{ title: "RN SDK: App Survey", href: "/developer-docs/app-survey-rn-sdk" },
{ title: "JS SDK: Website Survey", href: "/developer-docs/website-survey-sdk" },
{ title: "SDK: React Native", href: "/developer-docs/react-native-in-app-surveys" },
{ title: "SDK: Web Apps", href: "/developer-docs/app-survey-sdk" },
{ title: "SDK: Public Websites", href: "/developer-docs/website-survey-sdk" },
{ title: "SDK: Formbricks API", href: "/developer-docs/api-sdk" },
{ title: "REST API", href: "/developer-docs/rest-api" },
{ title: "Webhooks", href: "/developer-docs/webhooks" },

View File

@@ -1,5 +1,6 @@
import { AdvancedLogicEditorActions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedLogicEditorActions";
import { AdvancedLogicEditorConditions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedLogicEditorConditions";
import { ArrowRightIcon } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import { TSurveyAdvancedLogic } from "@formbricks/types/surveys/logic";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
@@ -11,6 +12,7 @@ interface AdvancedLogicEditorProps {
question: TSurveyQuestion;
questionIdx: number;
logicIdx: number;
isLast: boolean;
}
export function AdvancedLogicEditor({
@@ -20,25 +22,34 @@ export function AdvancedLogicEditor({
question,
questionIdx,
logicIdx,
isLast,
}: AdvancedLogicEditorProps) {
return (
<div className={cn("flex w-full flex-col gap-4")}>
<AdvancedLogicEditorConditions
conditions={logicItem.conditions}
updateQuestion={updateQuestion}
question={question}
questionIdx={questionIdx}
localSurvey={localSurvey}
logicIdx={logicIdx}
/>
<AdvancedLogicEditorActions
logicItem={logicItem}
logicIdx={logicIdx}
question={question}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
questionIdx={questionIdx}
/>
</div>
<>
<div className={cn("flex w-full flex-col gap-4 text-sm")}>
<AdvancedLogicEditorConditions
conditions={logicItem.conditions}
updateQuestion={updateQuestion}
question={question}
questionIdx={questionIdx}
localSurvey={localSurvey}
logicIdx={logicIdx}
/>
<AdvancedLogicEditorActions
logicItem={logicItem}
logicIdx={logicIdx}
question={question}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
questionIdx={questionIdx}
/>
{isLast && (
<div className="flex flex-wrap items-center space-x-2">
<ArrowRightIcon className="h-4 w-4" />
<p className="text-slate-700">All other answers will continue to the next question</p>
</div>
)}
</div>
</>
);
}

View File

@@ -86,8 +86,8 @@ export function AdvancedLogicEditorActions({
<div className="flex grow flex-col gap-y-2">
{actions.map((action, idx) => (
<div className="flex w-full items-center justify-between gap-x-2">
<div className="block w-9 shrink-0 text-sm">{idx === 0 ? "Then" : "and"}</div>
<div className="flex grow items-center gap-x-2 text-sm">
<div className="block w-9 shrink-0">{idx === 0 ? "Then" : "and"}</div>
<div className="flex grow items-center gap-x-2">
<InputCombobox
key="objective"
showSearch={false}

View File

@@ -122,10 +122,10 @@ export function AdvancedLogicEditorConditions({
return (
<div key={condition.id} className="flex items-start justify-between gap-4">
{index === 0 ? (
<div className="text-sm">When</div>
<div>When</div>
) : (
<div
className={cn("w-14 text-sm", { "cursor-pointer underline": index === 1 })}
className={cn("w-14", { "cursor-pointer underline": index === 1 })}
onClick={() => {
if (index !== 1) return;
handleConnectorChange(parentConditionGroup.id);
@@ -184,13 +184,13 @@ export function AdvancedLogicEditorConditions({
const { show, options, showInput = false, inputType } = getMatchValueProps(condition, localSurvey);
return (
<div key={condition.id} className="flex items-center justify-between gap-x-2 text-sm">
<div key={condition.id} className="flex items-center justify-between gap-x-2">
<div className="w-10 shrink-0">
{index === 0 ? (
"When"
) : (
<div
className={cn("w-14 text-sm", { "cursor-pointer underline": index === 1 })}
className={cn("w-14", { "cursor-pointer underline": index === 1 })}
onClick={() => {
if (index !== 1) return;
handleConnectorChange(parentConditionGroup.id);

View File

@@ -1,14 +1,6 @@
import { AdvancedLogicEditor } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedLogicEditor";
import { createId } from "@paralleldrive/cuid2";
import {
ArrowDownIcon,
ArrowRightIcon,
ArrowUpIcon,
CopyIcon,
MoreVerticalIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, MoreVerticalIcon, SplitIcon, TrashIcon } from "lucide-react";
import { useMemo } from "react";
import { duplicateLogicItem } from "@formbricks/lib/survey/logic/utils";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
@@ -110,8 +102,8 @@ export function ConditionalLogic({
<SplitIcon className="h-4 w-4 rotate-90" />
</Label>
{question.logic && question.logic?.length > 0 && (
<div className="logic-scrollbar mt-2 flex w-full flex-col gap-4">
{question.logic && question.logic.length > 0 && (
<div className="mt-2 flex w-full flex-col gap-4">
{question.logic.map((logicItem, logicItemIdx) => (
<div
key={logicItem.id}
@@ -123,7 +115,9 @@ export function ConditionalLogic({
question={question}
questionIdx={questionIdx}
logicIdx={logicItemIdx}
isLast={logicItemIdx === (question.logic || []).length - 1}
/>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreVerticalIcon className="h-4 w-4" />
@@ -148,7 +142,7 @@ export function ConditionalLogic({
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
disabled={logicItemIdx === (question.logic?.length || 1) - 1}
disabled={logicItemIdx === (question.logic || []).length - 1}
onClick={() => {
moveLogic(logicItemIdx, logicItemIdx + 1);
}}>
@@ -170,11 +164,6 @@ export function ConditionalLogic({
</div>
)}
<div className="flex flex-wrap items-center space-x-2 py-1 text-sm">
<ArrowRightIcon className="h-4 w-4" />
<p className="text-slate-700">All other answers will continue to the next question</p>
</div>
<div className="mt-2 flex items-center space-x-2">
<Button
id="logicJumps"

View File

@@ -0,0 +1,477 @@
import {
ArrowDownIcon,
ChevronDown,
CornerDownRightIcon,
HelpCircle,
SplitIcon,
TrashIcon,
} from "lucide-react";
import Image from "next/image";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TSurvey,
TSurveyLogic,
TSurveyLogicCondition,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
interface LogicEditorProps {
localSurvey: TSurvey;
questionIdx: number;
question: TSurveyQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
attributeClasses: TAttributeClass[];
}
type LogicConditions = {
[K in TSurveyLogicCondition]: {
label: string;
values: string[] | null;
unique?: boolean;
multiSelect?: boolean;
};
};
const conditions = {
openText: ["submitted", "skipped"],
multipleChoiceSingle: ["submitted", "skipped", "equals", "notEquals", "includesOne"],
multipleChoiceMulti: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
nps: [
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"submitted",
"skipped",
],
rating: [
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"submitted",
"skipped",
],
cta: ["clicked", "skipped"],
consent: ["skipped", "accepted"],
pictureSelection: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
fileUpload: ["uploaded", "notUploaded"],
cal: ["skipped", "booked"],
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
address: ["submitted", "skipped"],
ranking: ["submitted", "skipped"],
};
export const LogicEditor = ({
localSurvey,
question,
questionIdx,
updateQuestion,
attributeClasses,
}: LogicEditorProps) => {
const [searchValue, setSearchValue] = useState<string>("");
const showDropdownSearch = question.type !== "pictureSelection";
const transformedSurvey = useMemo(() => {
return replaceHeadlineRecall(localSurvey, "default", attributeClasses);
}, [localSurvey, attributeClasses]);
const questionValues: string[] = useMemo(() => {
if ("choices" in question) {
if (question.type === "pictureSelection") {
return question.choices.map((choice) => choice.id);
} else {
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
}
} else if ("range" in question) {
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
} else if (question.type === TSurveyQuestionTypeEnum.NPS) {
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
}
return [];
}, [question]);
const logicConditions: LogicConditions = {
submitted: {
label: "is submitted",
values: null,
unique: true,
},
skipped: {
label: "is skipped",
values: null,
unique: true,
},
accepted: {
label: "is accepted",
values: null,
unique: true,
},
clicked: {
label: "is clicked",
values: null,
unique: true,
},
equals: {
label: "equals",
values: questionValues,
},
notEquals: {
label: "does not equal",
values: questionValues,
},
lessThan: {
label: "is less than",
values: questionValues,
},
lessEqual: {
label: "is less or equal to",
values: questionValues,
},
greaterThan: {
label: "is greater than",
values: questionValues,
},
greaterEqual: {
label: "is greater or equal to",
values: questionValues,
},
includesAll: {
label: "includes all of",
values: questionValues,
multiSelect: true,
},
includesOne: {
label: "includes one of",
values: questionValues,
multiSelect: true,
},
uploaded: {
label: "has uploaded file",
values: null,
unique: true,
},
notUploaded: {
label: "has not uploaded file",
values: null,
unique: true,
},
booked: {
label: "has a call booked",
values: null,
unique: true,
},
isCompletelySubmitted: {
label: "is completely submitted",
values: null,
unique: true,
},
isPartiallySubmitted: {
label: "is partially submitted",
values: null,
unique: true,
},
};
const addLogic = () => {
if (question.logic && question.logic?.length >= 0) {
const hasUndefinedLogic = question.logic.some(
(logic) =>
logic.condition === undefined && logic.value === undefined && logic.destination === undefined
);
if (hasUndefinedLogic) {
toast("Please fill current logic jumps first.", {
icon: "🤓",
});
return;
}
}
const newLogic: TSurveyLogic[] = !question.logic ? [] : question.logic;
newLogic.push({
condition: undefined,
value: undefined,
destination: undefined,
});
updateQuestion(questionIdx, { logic: newLogic });
};
const updateLogic = (logicIdx: number, updatedAttributes: any) => {
const currentLogic: any = question.logic ? question.logic[logicIdx] : undefined;
if (!currentLogic) return;
// clean logic value if not needed or if condition changed between multiSelect and singleSelect conditions
const updatedCondition = updatedAttributes?.condition;
const currentCondition = currentLogic?.condition;
const updatedLogicCondition = logicConditions[updatedCondition];
const currentLogicCondition = logicConditions[currentCondition];
if (updatedCondition) {
if (updatedLogicCondition?.multiSelect && !currentLogicCondition?.multiSelect) {
updatedAttributes.value = [];
} else if (
(!updatedLogicCondition?.multiSelect && currentLogicCondition?.multiSelect) ||
updatedLogicCondition?.values === null
) {
updatedAttributes.value = undefined;
}
}
const newLogic = !question.logic
? []
: question.logic.map((logic, idx) => {
if (idx === logicIdx) {
return { ...logic, ...updatedAttributes };
}
return logic;
});
updateQuestion(questionIdx, { logic: newLogic });
};
const updateMultiSelectLogic = (logicIdx: number, checked: boolean, value: string) => {
const newLogic = !question.logic
? []
: question.logic.map((logic, idx) => {
if (idx === logicIdx) {
const newValues = !logic.value ? [] : logic.value;
if (checked) {
newValues.push(value);
} else {
newValues.splice(newValues.indexOf(value), 1);
}
return { ...logic, value: Array.from(new Set(newValues)) };
}
return logic;
});
updateQuestion(questionIdx, { logic: newLogic });
};
const deleteLogic = (logicIdx: number) => {
const updatedLogic = !question.logic ? [] : structuredClone(question.logic);
updatedLogic.splice(logicIdx, 1);
updateQuestion(questionIdx, { logic: updatedLogic });
};
if (!(question.type in conditions)) {
return <></>;
}
const getLogicDisplayValue = (value: string | string[]): string => {
if (question.type === "pictureSelection") {
if (Array.isArray(value)) {
return value
.map((val) => {
const choiceIndex = question.choices.findIndex((choice) => choice.id === val);
return `Picture ${choiceIndex + 1}`;
})
.join(", ");
} else {
const choiceIndex = question.choices.findIndex((choice) => choice.id === value);
return `Picture ${choiceIndex + 1}`;
}
} else if (Array.isArray(value)) {
return value.join(", ");
}
return value;
};
const getOptionPreview = (value: string) => {
if (question.type === "pictureSelection") {
const choice = question.choices.find((choice) => choice.id === value);
if (choice) {
return <Image src={choice.imageUrl} alt={"Picture"} width={20} height={12} className="rounded-xs" />;
}
}
};
return (
<div className="mt-3">
<Label>Logic Jumps</Label>
{question?.logic && question?.logic?.length !== 0 && (
<div className="mt-2 space-y-3">
{question?.logic?.map((logic, logicIdx) => (
<div key={logicIdx} className="flex items-center space-x-2 space-y-1 text-xs xl:text-sm">
<div>
<CornerDownRightIcon className="h-4 w-4" />
</div>
<p className="text-slate-800">If this answer</p>
<Select value={logic.condition} onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
<SelectTrigger className="min-w-fit flex-1">
<SelectValue placeholder="Select condition" className="text-xs lg:text-sm" />
</SelectTrigger>
<SelectContent>
{conditions[question.type].map(
(condition) =>
!(question.required && (condition === "skipped" || condition === "notUploaded")) && (
<SelectItem
key={condition}
value={condition}
title={logicConditions[condition].label}
className="text-xs lg:text-sm">
{logicConditions[condition].label}
</SelectItem>
)
)}
</SelectContent>
</Select>
{logic.condition && logicConditions[logic.condition].values != null && (
<DropdownMenu>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div className="flex h-10 w-full items-center justify-between overflow-hidden rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
{!logic.value || logic.value?.length === 0 ? (
<p className="line-clamp-1 text-slate-400" title="Select match type">
Select match type
</p>
) : (
<p className="line-clamp-1" title={getLogicDisplayValue(logic.value)}>
{getLogicDisplayValue(logic.value)}
</p>
)}
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-40 bg-slate-50 text-slate-700"
align="start"
side="bottom">
{showDropdownSearch && (
<Input
autoFocus
placeholder="Search options"
className="mb-1 w-full bg-white"
onChange={(e) => setSearchValue(e.target.value)}
value={searchValue}
onKeyDown={(e) => e.stopPropagation()}
/>
)}
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
{logicConditions[logic.condition].values
?.filter((value) => value.includes(searchValue))
?.map((value) => (
<DropdownMenuCheckboxItem
key={value}
title={value}
checked={
!logicConditions[logic.condition].multiSelect
? logic.value === value
: logic.value?.includes(value)
}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(e) =>
!logicConditions[logic.condition].multiSelect
? updateLogic(logicIdx, { value })
: updateMultiSelectLogic(logicIdx, e, value)
}>
<div className="flex space-x-2">
{question.type === "pictureSelection" && getOptionPreview(value)}
<p>{getLogicDisplayValue(value)}</p>
</div>
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<p className="text-slate-800">jump to</p>
<Select
value={logic.destination}
onValueChange={(e) => updateLogic(logicIdx, { destination: e })}>
<SelectTrigger className="w-fit overflow-hidden">
<SelectValue placeholder="Select question" />
</SelectTrigger>
<SelectContent>
{transformedSurvey.questions.map(
(question, idx) =>
idx !== questionIdx && (
<SelectItem
key={question.id}
value={question.id}
title={getLocalizedValue(question.headline, "default")}>
<div className="w-96">
<p className="truncate text-left">
{idx + 1}
{". "}
{getLocalizedValue(question.headline, "default")}
</p>
</div>
</SelectItem>
)
)}
{localSurvey.endings.map((ending) => {
return (
<SelectItem value={ending.id}>
{ending.type === "endScreen"
? getLocalizedValue(ending.headline, "default")
: ending.label}
</SelectItem>
);
})}
</SelectContent>
</Select>
<div>
<TrashIcon
className="h-4 w-4 cursor-pointer text-slate-400"
onClick={() => deleteLogic(logicIdx)}
/>
</div>
</div>
))}
<div className="flex flex-wrap items-center space-x-2 py-1 text-sm">
<ArrowDownIcon className="h-4 w-4" />
<p className="text-slate-700">All other answers will continue to the next question</p>
</div>
</div>
)}
<div className="mt-2 flex items-center space-x-2">
<Button
id="logicJumps"
type="button"
name="logicJumps"
size="sm"
variant="secondary"
StartIcon={SplitIcon}
onClick={() => addLogic()}>
Add logic
</Button>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="ml-2 inline h-4 w-4 cursor-default text-slate-500" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]" side="top">
With logic jumps you can skip questions based on the responses users give.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
};

View File

@@ -20,7 +20,7 @@ import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { SelectQuestionChoice } from "./SelectQuestionChoice";
import { QuestionOptionChoice } from "./QuestionOptionChoice";
interface OpenQuestionFormProps {
localSurvey: TSurvey;
@@ -238,7 +238,7 @@ export const MultipleChoiceQuestionForm = ({
<div className="flex flex-col">
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<SelectQuestionChoice
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
@@ -260,7 +260,7 @@ export const MultipleChoiceQuestionForm = ({
</div>
</SortableContext>
</DndContext>
<div className="flex items-center justify-between space-x-2">
<div className="mt-2 flex items-center justify-between space-x-2">
{question.choices.filter((c) => c.id === "other").length === 0 && (
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
Add &quot;Other&quot;
@@ -289,7 +289,7 @@ export const MultipleChoiceQuestionForm = ({
onValueChange={(e: TShuffleOption) => {
updateQuestion(questionIdx, { shuffleOption: e });
}}>
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-semibold text-slate-600">
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-medium text-slate-600">
<SelectValue placeholder="Select ordering" />
</SelectTrigger>
<SelectContent>

View File

@@ -1,5 +1,6 @@
"use client";
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions";
import { useSortable } from "@dnd-kit/sortable";
@@ -367,6 +368,18 @@ export const QuestionCard = ({
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
<RankingQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : null}
<div className="mt-4">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">

View File

@@ -9,15 +9,14 @@ import {
TSurvey,
TSurveyLanguage,
TSurveyMultipleChoiceQuestion,
TSurveyQuestionChoice,
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { isLabelValidForAllLanguages } from "../lib/validation";
interface ChoiceProps {
choice: {
id: string;
label: Record<string, string>;
};
choice: TSurveyQuestionChoice;
choiceIdx: number;
questionIdx: number;
updateChoice: (choiceIdx: number, updatedAttributes: { label: TI18nString }) => void;
@@ -28,13 +27,16 @@ interface ChoiceProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
surveyLanguages: TSurveyLanguage[];
question: TSurveyMultipleChoiceQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceQuestion>) => void;
question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion;
updateQuestion: (
questionIdx: number,
updatedAttributes: Partial<TSurveyMultipleChoiceQuestion> | Partial<TSurveyRankingQuestion>
) => void;
surveyLanguageCodes: string[];
attributeClasses: TAttributeClass[];
}
export const SelectQuestionChoice = ({
export const QuestionOptionChoice = ({
addChoice,
choice,
choiceIdx,

View File

@@ -0,0 +1,251 @@
"use client";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { createId } from "@paralleldrive/cuid2";
import { PlusIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TI18nString,
TShuffleOption,
TSurvey,
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { QuestionOptionChoice } from "./QuestionOptionChoice";
interface RankingQuestionFormProps {
localSurvey: TSurvey;
question: TSurveyRankingQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyRankingQuestion>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
}
export const RankingQuestionForm = ({
question,
questionIdx,
updateQuestion,
isInvalid,
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: RankingQuestionFormProps): JSX.Element => {
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isInvalidValue, setIsInvalidValue] = useState<string | null>(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const surveyLanguages = localSurvey.languages ?? [];
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
if (question.choices) {
const newChoices = question.choices.map((choice, idx) => {
if (idx !== choiceIdx) return choice;
return { ...choice, ...updatedAttributes };
});
updateQuestion(questionIdx, { choices: newChoices });
}
};
const addChoice = (choiceIdx: number) => {
let newChoices = !question.choices ? [] : question.choices;
const newChoice = {
id: createId(),
label: createI18nString("", surveyLanguageCodes),
};
updateQuestion(questionIdx, {
choices: [...newChoices.slice(0, choiceIdx + 1), newChoice, ...newChoices.slice(choiceIdx + 1)],
});
};
const addOption = () => {
const choices = !question.choices ? [] : question.choices;
const newChoice = {
id: createId(),
label: createI18nString("", surveyLanguageCodes),
};
updateQuestion(questionIdx, { choices: [...choices, newChoice] });
};
const deleteChoice = (choiceIdx: number) => {
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode];
if (isInvalidValue === choiceValue) {
setIsInvalidValue(null);
}
updateQuestion(questionIdx, { choices: newChoices });
};
const shuffleOptionsTypes = {
none: {
id: "none",
label: "Keep current order",
show: true,
},
all: {
id: "all",
label: "Randomize all",
show: question.choices.length > 0,
},
};
useEffect(() => {
if (lastChoiceRef.current) {
lastChoiceRef.current?.focus();
}
}, [question.choices?.length]);
return (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
label={"Question*"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
{question.subheader !== undefined && (
<div className="inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
label={"Description"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
</div>
)}
{question.subheader === undefined && (
<Button
size="sm"
variant="minimal"
className="mt-3"
type="button"
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
}}>
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
)}
</div>
<div className="mt-3">
<Label htmlFor="choices">Options*</Label>
<div className="mt-2" id="choices">
<DndContext
onDragEnd={(event) => {
const { active, over } = event;
if (!active || !over) {
return;
}
const activeIndex = question.choices.findIndex((choice) => choice.id === active.id);
const overIndex = question.choices.findIndex((choice) => choice.id === over.id);
const newChoices = [...question.choices];
newChoices.splice(activeIndex, 1);
newChoices.splice(overIndex, 0, question.choices[activeIndex]);
updateQuestion(questionIdx, { choices: newChoices });
}}>
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col">
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
attributeClasses={attributeClasses}
/>
))}
</div>
</SortableContext>
</DndContext>
<div className="mt-2 flex flex-1 items-center justify-between gap-2">
<Button
size="sm"
variant="secondary"
EndIcon={PlusIcon}
type="button"
onClick={() => addOption()}>
Add option
</Button>
<Select
defaultValue={question.shuffleOption}
value={question.shuffleOption}
onValueChange={(option: TShuffleOption) => {
updateQuestion(questionIdx, { shuffleOption: option });
}}>
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-medium text-slate-600">
<SelectValue placeholder="Select ordering" />
</SelectTrigger>
<SelectContent>
{Object.values(shuffleOptionsTypes).map(
(shuffleOptionsType) =>
shuffleOptionsType.show && (
<SelectItem
key={shuffleOptionsType.id}
value={shuffleOptionsType.id}
title={shuffleOptionsType.label}>
{shuffleOptionsType.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</div>
</div>
</form>
);
};

View File

@@ -206,7 +206,7 @@ export const SurveyEditor = ({
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 pb-2 pt-4 shadow-inner md:flex md:flex-col">
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 shadow-inner md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
questionId={activeQuestionId}

View File

@@ -301,6 +301,18 @@ export const ruleEngine = {
},
],
},
[TSurveyQuestionTypeEnum.Ranking]: {
options: [
{
label: "is submitted",
value: ZSurveyLogicCondition.Enum.isSubmitted,
},
{
label: "is skipped",
value: ZSurveyLogicCondition.Enum.isSkipped,
},
],
},
[TSurveyQuestionTypeEnum.Cal]: {
options: [
{

View File

@@ -10,6 +10,7 @@ import {
HomeIcon,
ImageIcon,
ListIcon,
ListOrderedIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
PhoneIcon,
@@ -65,6 +66,7 @@ const questionIconMapping = {
fileUpload: ArrowUpFromLineIcon,
cal: PhoneIcon,
matrix: Grid3X3Icon,
ranking: ListOrderedIcon,
address: HomeIcon,
};

View File

@@ -9,6 +9,7 @@ export const BackButton = () => {
return (
<Button
variant="secondary"
size="sm"
StartIcon={ArrowLeftIcon}
onClick={() => {
router.back();

View File

@@ -5,7 +5,7 @@ import { BackButton } from "@/app/(app)/(survey-editor)/environments/[environmen
export const MenuBar = () => {
return (
<>
<div className="border-b border-slate-200 bg-white px-5 py-3 sm:flex sm:items-center sm:justify-between">
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2 whitespace-nowrap">
<BackButton />
</div>

View File

@@ -65,16 +65,14 @@ export const TemplateContainerWithPreview = ({
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
{activeTemplate && (
<div className="my-6 flex h-[90%] w-full flex-col items-center justify-center">
<PreviewSurvey
survey={{ ...minimalSurvey, ...activeTemplate.preset }}
questionId={activeQuestionId}
product={product}
environment={environment}
languageCode={"default"}
onFileUpload={async (file) => file.name}
/>
</div>
<PreviewSurvey
survey={{ ...minimalSurvey, ...activeTemplate.preset }}
questionId={activeQuestionId}
product={product}
environment={environment}
languageCode={"default"}
onFileUpload={async (file) => file.name}
/>
)}
</aside>
</div>

View File

@@ -0,0 +1,62 @@
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface RankingSummaryProps {
questionSummary: TSurveyQuestionSummaryRanking;
surveyType: TSurveyType;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const RankingSummary = ({
questionSummary,
surveyType,
survey,
attributeClasses,
}: RankingSummaryProps) => {
// sort by count and transform to array
const results = Object.values(questionSummary.choices).sort((a, b) => {
return a.avgRanking - b.avgRanking; // Sort by count
});
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="flex w-full items-center">
<span className="mr-2 text-gray-400">#{resultsIdx + 1}</span>
<div className="rounded bg-gray-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 1)}
</span>
<span>average</span>
</span>
</div>
</div>
</div>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">Other values found</div>
<div className="col-span-1 pl-6">{surveyType === "app" && "User"}</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
};

View File

@@ -16,6 +16,7 @@ import { MultipleChoiceSummary } from "@/app/(app)/environments/[environmentId]/
import { NPSSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary";
import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary";
import { PictureChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary";
import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary";
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
@@ -257,6 +258,17 @@ export const SummaryList = ({
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.Ranking) {
return (
<RankingSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
surveyType={survey.type}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
if (questionSummary.type === "hiddenField") {
return (
<HiddenFieldsSummary

View File

@@ -14,6 +14,7 @@ import {
ImageIcon,
LanguagesIcon,
ListIcon,
ListOrderedIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
Rows3Icon,
@@ -86,6 +87,8 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
return <ImageIcon width={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Matrix:
return <GridIcon width={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Ranking:
return <ListOrderedIcon width={18} className="text-white" />;
}
case OptionsType.ATTRIBUTES:
return <User width={18} height={18} className="text-white" />;

View File

@@ -7,6 +7,7 @@ import {
HomeIcon,
ImageIcon,
ListIcon,
ListOrderedIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
PhoneIcon,
@@ -28,6 +29,7 @@ import {
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
TSurveyRankingQuestion,
TSurveyRatingQuestion,
} from "@formbricks/types/surveys/types";
import { replaceQuestionPresetPlaceholders } from "./templates";
@@ -130,9 +132,37 @@ export const questionTypes: TQuestion[] = [
upperLabel: { default: "Extremely likely" },
} as Partial<TSurveyNPSQuestion>,
},
{
id: QuestionId.Ranking,
label: "Ranking",
description: "Allow respondents to rank items",
icon: ListOrderedIcon,
preset: {
headline: { default: "What is most important for you in life?" },
choices: [
{ id: createId(), label: { default: "Work" } },
{ id: createId(), label: { default: "Money" } },
{ id: createId(), label: { default: "Travel" } },
{ id: createId(), label: { default: "Family" } },
{ id: createId(), label: { default: "Friends" } },
],
} as Partial<TSurveyRankingQuestion>,
},
{
id: QuestionId.Matrix,
label: "Matrix",
description: "This is a matrix question",
icon: Grid3X3Icon,
preset: {
headline: { default: "How much do you love these flowers?" },
subheader: { default: "0: Not at all, 3: Love it" },
rows: [{ default: "Rose 🌹" }, { default: "Sunflower 🌻" }, { default: "Hibiscus 🌺" }],
columns: [{ default: "0" }, { default: "1" }, { default: "2" }, { default: "3" }],
} as Partial<TSurveyMatrixQuestion>,
},
{
id: QuestionId.CTA,
label: "Call-to-Action (Statement)",
label: "Statement (Call to Action)",
description: "Prompt respondents to perform an action",
icon: MousePointerClickIcon,
preset: {
@@ -157,16 +187,6 @@ export const questionTypes: TQuestion[] = [
label: { default: "I agree to the terms and conditions" },
} as Partial<TSurveyConsentQuestion>,
},
{
id: QuestionId.Date,
label: "Date",
description: "Ask your users to select a date",
icon: CalendarDaysIcon,
preset: {
headline: { default: "When is your birthday?" },
format: "M-d-y",
} as Partial<TSurveyDateQuestion>,
},
{
id: QuestionId.FileUpload,
label: "File Upload",
@@ -177,6 +197,16 @@ export const questionTypes: TQuestion[] = [
allowMultipleFiles: false,
} as Partial<TSurveyFileUploadQuestion>,
},
{
id: QuestionId.Date,
label: "Date",
description: "Ask your users to select a date",
icon: CalendarDaysIcon,
preset: {
headline: { default: "When is your birthday?" },
format: "M-d-y",
} as Partial<TSurveyDateQuestion>,
},
{
id: QuestionId.Cal,
label: "Schedule a meeting",
@@ -187,18 +217,6 @@ export const questionTypes: TQuestion[] = [
calUserName: "rick/get-rick-rolled",
} as Partial<TSurveyCalQuestion>,
},
{
id: QuestionId.Matrix,
label: "Matrix",
description: "This is a matrix question",
icon: Grid3X3Icon,
preset: {
headline: { default: "How much do you love these flowers?" },
subheader: { default: "0: Not at all, 3: Love it" },
rows: [{ default: "Rose 🌹" }, { default: "Sunflower 🌻" }, { default: "Hibiscus 🌺" }],
columns: [{ default: "0" }, { default: "1" }, { default: "2" }, { default: "3" }],
} as Partial<TSurveyMatrixQuestion>,
},
{
id: QuestionId.Address,
label: "Address",

View File

@@ -32,6 +32,7 @@ const conditionOptions = {
consent: ["is"],
matrix: [""],
address: ["is"],
ranking: ["is"],
};
const filterOptions = {
openText: ["Filled out", "Skipped"],
@@ -41,6 +42,7 @@ const filterOptions = {
tags: ["Applied", "Not applied"],
consent: ["Accepted", "Dismissed"],
address: ["Filled out", "Skipped"],
ranking: ["Filled out", "Skipped"],
};
// creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata
@@ -283,6 +285,18 @@ export const getFormattedFilters = (
}
break;
}
case TSurveyQuestionTypeEnum.Ranking: {
if (filterType.filterComboBoxValue === "Filled out") {
filters.data[questionType.id ?? ""] = {
op: "submitted",
};
} else if (filterType.filterComboBoxValue === "Skipped") {
filters.data[questionType.id ?? ""] = {
op: "skipped",
};
}
break;
}
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
if (filterType.filterValue === "Includes either") {

View File

@@ -12,7 +12,12 @@ import { SurveyState } from "@formbricks/lib/surveyState";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TJsFileUploadParams } from "@formbricks/types/js";
import { TProduct } from "@formbricks/types/product";
import { TResponse, TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
import {
TResponse,
TResponseData,
TResponseHiddenFieldValue,
TResponseUpdate,
} from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurvey } from "@formbricks/types/surveys/types";
import { SurveyInline } from "@formbricks/ui/Survey";
@@ -20,6 +25,7 @@ import { SurveyInline } from "@formbricks/ui/Survey";
let setIsError = (_: boolean) => {};
let setIsResponseSendingFinished = (_: boolean) => {};
let setQuestionId = (_: string) => {};
let setResponseData = (_: TResponseData) => {};
interface LinkSurveyProps {
survey: TSurvey;
@@ -123,10 +129,6 @@ export const LinkSurvey = ({
if (window.self === window.top) {
setAutofocus(true);
}
// For safari on mobile devices, scroll is a bit off due to dynamic height of address bar, so on inital load, we scroll to the bottom
// window.scrollTo({
// top: document.body.scrollHeight,
// });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -203,12 +205,17 @@ export const LinkSurvey = ({
return product.styling;
};
const handleResetSurvey = () => {
setQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id);
setResponseData({});
};
return (
<LinkSurveyWrapper
product={product}
survey={survey}
isPreview={isPreview}
setQuestionId={setQuestionId}
handleResetSurvey={handleResetSurvey}
determineStyling={determineStyling}
isEmbed={isEmbed}
webAppUrl={webAppUrl}
@@ -291,6 +298,9 @@ export const LinkSurvey = ({
getSetQuestionId={(f: (value: string) => void) => {
setQuestionId = f;
}}
getSetResponseData={(f: (value: TResponseData) => void) => {
setResponseData = f;
}}
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
fullSizeCards={isEmbed ? true : false}
hiddenFieldsRecord={hiddenFieldsRecord}

View File

@@ -15,7 +15,7 @@ interface LinkSurveyWrapperProps {
isPreview: boolean;
isEmbed: boolean;
determineStyling: () => TSurveyStyling | TProductStyling;
setQuestionId: (_: string) => void;
handleResetSurvey: () => void;
IMPRINT_URL?: string;
PRIVACY_URL?: string;
IS_FORMBRICKS_CLOUD: boolean;
@@ -29,7 +29,7 @@ export const LinkSurveyWrapper = ({
isPreview,
isEmbed,
determineStyling,
setQuestionId,
handleResetSurvey,
IMPRINT_URL,
PRIVACY_URL,
IS_FORMBRICKS_CLOUD,
@@ -68,11 +68,7 @@ export const LinkSurveyWrapper = ({
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />
Survey Preview 👀
<ResetProgressButton
onClick={() =>
setQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id)
}
/>
<ResetProgressButton onClick={handleResetSurvey} />
</div>
)}
{children}

View File

@@ -178,8 +178,14 @@ test.describe("Survey Create & Submit Response", async () => {
await expect(page.getByText(surveys.createAndSubmit.address.question)).toBeVisible();
await expect(page.getByPlaceholder(surveys.createAndSubmit.address.placeholder)).toBeVisible();
await page.getByPlaceholder(surveys.createAndSubmit.address.placeholder).fill("This is my Address");
await page.getByRole("button", { name: "Finish" }).click();
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
// Ranking Question
await expect(page.getByText(surveys.createAndSubmit.ranking.question)).toBeVisible();
for (let i = 0; i < surveys.createAndSubmit.ranking.choices.length; i++) {
await page.getByText(surveys.createAndSubmit.ranking.choices[i]).click();
}
await page.getByRole("button", { name: "Finish" }).click();
// loading spinner -> wait for it to disappear
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
@@ -279,6 +285,12 @@ test.describe("Multi Language Survey Create", async () => {
.nth(1)
.click();
await page.getByRole("button", { name: "Address" }).click();
await page
.locator("div")
.filter({ hasText: /^Add QuestionAdd a new question to your survey$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Ranking" }).click();
// Enable translation in german
await page.getByText("Welcome CardShownOn").click();
@@ -412,6 +424,21 @@ test.describe("Multi Language Survey Create", async () => {
.getByPlaceholder("Your question here. Recall")
.fill(surveys.germanCreate.addressQuestion.question);
// Fill Ranking question in german
await page.getByRole("main").getByText("Ranking").click();
await page.getByPlaceholder("Your question here. Recall").click();
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.ranking.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.ranking.choices[0]);
await page.getByPlaceholder("Option 2").click();
await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.ranking.choices[1]);
await page.getByPlaceholder("Option 3").click();
await page.getByPlaceholder("Option 3").fill(surveys.germanCreate.ranking.choices[2]);
await page.getByPlaceholder("Option 4").click();
await page.getByPlaceholder("Option 4").fill(surveys.germanCreate.ranking.choices[3]);
await page.getByPlaceholder("Option 5").click();
await page.getByPlaceholder("Option 5").fill(surveys.germanCreate.ranking.choices[4]);
// Fill Thank you card in german
await page.getByText("Ending card").first().click();
await page.getByPlaceholder("Your question here. Recall").click();

View File

@@ -247,7 +247,6 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
await page.getByRole("button", { name: "File Upload" }).click();
await page.getByLabel("Question*").fill(params.fileUploadQuestion.question);
// Fill Matrix question in german
// File Upload Question
await page
.locator("div")
@@ -272,7 +271,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
await page.locator("#column-3").click();
await page.locator("#column-3").fill(params.matrix.columns[3]);
// File Address Question
// Fill Address Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
@@ -281,6 +280,14 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
await page.getByRole("button", { name: "Address" }).click();
await page.getByLabel("Question*").fill(params.address.question);
// Fill Ranking question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Ranking" }).click();
// Thank You Card
await page
.locator("div")

View File

@@ -157,6 +157,10 @@ export const surveys = {
question: "Where do you live?",
placeholder: "Address Line 1",
},
ranking: {
question: "What is most important for you in life?",
choices: ["Work", "Money", "Travel", "Family", "Friends"],
},
thankYouCard: {
headline: "This is my Thank You Card Headline!",
description: "This is my Thank you Card Description!",
@@ -223,6 +227,10 @@ export const surveys = {
addressQuestion: {
question: "Wo wohnst du ?",
},
ranking: {
question: "Was ist für Sie im Leben am wichtigsten?",
choices: ["Arbeit", "Geld", "Reisen", "Familie", "Freunde"],
},
thankYouCard: {
headline: "Dies ist meine Dankeskarte Überschrift!", // German translation
description: "Dies ist meine Beschreibung zur Dankeskarte!", // German translation

View File

@@ -3,12 +3,12 @@ import { type TLanguage } from "@formbricks/types/product";
import {
type TI18nString,
type TSurveyCTAQuestion,
type TSurveyChoice,
type TSurveyConsentQuestion,
type TSurveyMultipleChoiceQuestion,
type TSurveyNPSQuestion,
type TSurveyOpenTextQuestion,
type TSurveyQuestion,
type TSurveyQuestionChoice,
type TSurveyQuestions,
type TSurveyRatingQuestion,
type TSurveyWelcomeCard,
@@ -63,7 +63,7 @@ export const createI18nString = (text: string | TI18nString, languages: string[]
};
// Function to translate a choice label
const translateChoice = (choice: TSurveyChoice, languages: string[]): TSurveyChoice => {
const translateChoice = (choice: TSurveyQuestionChoice, languages: string[]): TSurveyQuestionChoice => {
if (typeof choice.label !== "undefined") {
return {
...choice,

View File

@@ -276,6 +276,27 @@ export function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Ranking:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="border-input-border-color bg-input-color text-question-color rounded-custom mt-2 block w-full border border-solid p-4"
key={choice.id}>
{getLocalizedValue(choice.label, defaultLanguageCode)}
</Section>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>

View File

@@ -18,7 +18,7 @@ export const renderEmailResponseValue = (
case TSurveyQuestionTypeEnum.FileUpload:
return (
<Container>
{typeof response !== "string" &&
{Array.isArray(response) &&
response.map((responseItem) => (
<Link
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-gray-200 p-2 text-black shadow-sm"
@@ -30,25 +30,39 @@ export const renderEmailResponseValue = (
))}
</Container>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<Container className="flex">
<Container>
<Row>
{typeof response !== "string" &&
{Array.isArray(response) &&
response.map((responseItem) => (
<Column key={responseItem}>
<Img
alt={responseItem.split("/").pop()}
className="m-2 h-28"
id={responseItem}
src={responseItem}
/>
<Img alt={responseItem.split("/").pop()} className="m-2 h-28" src={responseItem} />
</Column>
))}
</Row>
</Container>
);
case TSurveyQuestionTypeEnum.Ranking:
return (
<Container>
<Row className="my-1 font-semibold text-slate-700" dir="auto">
{Array.isArray(response) &&
response.map(
(item, index) =>
item && (
<Row key={index} className="mb-1 flex items-center">
<Column className="w-6 text-gray-400">#{index + 1}</Column>
<Column className="rounded bg-gray-100 px-2 py-1">{item}</Column>
</Row>
)
)}
</Row>
</Container>
);
default:
return <Text className="mt-0 whitespace-pre-wrap break-words font-bold">{response}</Text>;
}

View File

@@ -20,6 +20,7 @@ import {
TSurveyQuestionSummaryMultipleChoice,
TSurveyQuestionSummaryOpenText,
TSurveyQuestionSummaryPictureSelection,
TSurveyQuestionSummaryRanking,
TSurveyQuestionSummaryRating,
TSurveyQuestionTypeEnum,
TSurveySummary,
@@ -734,7 +735,7 @@ const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: strin
const checkForI18n = (response: TResponse, id: string, survey: TSurvey, languageCode: string) => {
const question = survey.questions.find((question) => question.id === id);
if (question?.type === "multipleChoiceMulti") {
if (question?.type === "multipleChoiceMulti" || question?.type === "ranking") {
// Initialize an array to hold the choice values
let choiceValues = [] as string[];
@@ -1251,6 +1252,56 @@ export const getQuestionWiseSummary = (
values = [];
break;
}
case TSurveyQuestionTypeEnum.Ranking: {
let values: TSurveyQuestionSummaryRanking["choices"] = [];
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
let totalResponseCount = 0;
const choiceRankSums: Record<string, number> = {};
const choiceCountMap: Record<string, number> = {};
questionChoices.forEach((choice) => {
choiceRankSums[choice] = 0;
choiceCountMap[choice] = 0;
});
responses.forEach((response) => {
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
const answer =
responseLanguageCode === "default"
? response.data[question.id]
: checkForI18n(response, question.id, survey, responseLanguageCode);
if (Array.isArray(answer)) {
totalResponseCount++;
answer.forEach((value, index) => {
const ranking = index + 1; // Calculate ranking based on index
if (questionChoices.includes(value)) {
choiceRankSums[value] += ranking;
choiceCountMap[value]++;
}
});
}
});
questionChoices.forEach((choice) => {
const count = choiceCountMap[choice];
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
values.push({
value: choice,
count,
avgRanking: convertFloatTo2Decimal(avgRanking),
});
});
summary.push({
type: question.type,
question,
responseCount: totalResponseCount,
choices: values,
});
break;
}
}
});

View File

@@ -11,6 +11,7 @@ export const convertResponseValue = (
if (!answer) return "";
else {
switch (question.type) {
case "ranking":
case "fileUpload":
if (typeof answer === "string") {
return [answer];

View File

@@ -10,11 +10,17 @@ import { MultipleChoiceSingleQuestion } from "@/components/questions/MultipleCho
import { NPSQuestion } from "@/components/questions/NPSQuestion";
import { OpenTextQuestion } from "@/components/questions/OpenTextQuestion";
import { PictureSelectionQuestion } from "@/components/questions/PictureSelectionQuestion";
import { RankingQuestion } from "@/components/questions/RankingQuestion";
import { RatingQuestion } from "@/components/questions/RatingQuestion";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TJsFileUploadParams } from "@formbricks/types/js";
import { TResponseData, TResponseDataValue, TResponseTtc } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
interface QuestionConditionalProps {
question: TSurveyQuestion;
@@ -53,6 +59,15 @@ export const QuestionConditional = ({
autoFocusEnabled,
currentQuestionId,
}: QuestionConditionalProps) => {
const getResponseValueForRankingQuestion = (
value: string[],
choices: TSurveyQuestionChoice[]
): string[] => {
return value
.map((label) => choices.find((choice) => getLocalizedValue(choice.label, languageCode) === label)?.id)
.filter((id): id is TSurveyQuestionChoice["id"] => id !== undefined);
};
if (!value && (prefilledQuestionValue || prefilledQuestionValue === "")) {
if (skipPrefilled) {
onSubmit({ [question.id]: prefilledQuestionValue }, { [question.id]: 0 });
@@ -268,5 +283,20 @@ export const QuestionConditional = ({
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
<RankingQuestion
question={question}
value={Array.isArray(value) ? getResponseValueForRankingQuestion(value, question.choices) : []}
onChange={onChange}
onSubmit={onSubmit}
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : null;
};

View File

@@ -42,6 +42,7 @@ export const Survey = ({
getSetIsError,
getSetIsResponseSendingFinished,
getSetQuestionId,
getSetResponseData,
onFileUpload,
responseCount,
startAtQuestionId,
@@ -144,6 +145,14 @@ export const Survey = ({
}
}, [getSetQuestionId]);
useEffect(() => {
if (getSetResponseData) {
getSetResponseData((value: TResponseData) => {
setResponseData(value);
});
}
}, [getSetResponseData]);
useEffect(() => {
if (getSetIsResponseSendingFinished) {
getSetIsResponseSendingFinished((value: boolean) => {

View File

@@ -169,8 +169,8 @@ export const MultipleChoiceMultiQuestion = ({
className={cn(
value.includes(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 hover:fb-bg-input-bg-selected focus: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"
: "fb-border-border fb-bg-input-bg",
"fb-text-heading focus-within:fb-border-brand hover:fb-bg-input-bg-selected focus: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

View File

@@ -0,0 +1,257 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useCallback, useMemo, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyQuestionChoice, TSurveyRankingQuestion } from "@formbricks/types/surveys/types";
interface RankingQuestionProps {
question: TSurveyRankingQuestion;
value: string[];
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentQuestionId: string;
}
export const RankingQuestion = ({
question,
value,
onChange,
onSubmit,
onBack,
isFirstQuestion,
isLastQuestion,
languageCode,
ttc,
setTtc,
autoFocusEnabled,
currentQuestionId,
}: RankingQuestionProps) => {
const [startTime, setStartTime] = useState(performance.now());
const [sortedItems, setSortedItems] = useState<TSurveyQuestionChoice[]>(
value
.map((id) => question.choices.find((c) => c.id === id))
.filter((item): item is TSurveyQuestionChoice => item !== undefined)
);
const [unsortedItems, setUnsortedItems] = useState<TSurveyQuestionChoice[]>(
question.choices.filter((c) => !value.includes(c.id))
);
const [error, setError] = useState<string | null>(null);
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const questionChoices = useMemo(
() => question.choices.map((choice) => ({ id: choice.id, label: choice.label })),
[question.choices]
);
const handleItemClick = useCallback(
(item: TSurveyQuestionChoice) => {
setSortedItems((prev) => {
const isAlreadySorted = prev.some((sortedItem) => sortedItem.id === item.id);
const newSortedItems = isAlreadySorted
? prev.filter((sortedItem) => sortedItem.id !== item.id)
: [...prev, item];
onChange({ [question.id]: newSortedItems.map((item) => item.id) });
return newSortedItems;
});
setUnsortedItems((prev) => {
const isAlreadySorted = sortedItems.some((sortedItem) => sortedItem.id === item.id);
return isAlreadySorted ? [...prev, item] : prev.filter((unsortedItem) => unsortedItem.id !== item.id);
});
setError(null);
},
[onChange, question.id, sortedItems]
);
const handleMove = useCallback(
(itemId: string, direction: "up" | "down") => {
const index = sortedItems.findIndex((item) => item.id === itemId);
if (index === -1) return;
const newSortedItems = [...sortedItems];
const [movedItem] = newSortedItems.splice(index, 1);
const newIndex =
direction === "up" ? Math.max(0, index - 1) : Math.min(newSortedItems.length, index + 1);
newSortedItems.splice(newIndex, 0, movedItem);
setSortedItems(newSortedItems);
onChange({ [question.id]: newSortedItems.map((item) => item.id) });
setError(null);
},
[sortedItems, onChange, question.id]
);
const handleSubmit = (e: Event) => {
e.preventDefault();
const hasIncompleteRanking =
(question.required && sortedItems.length !== questionChoices.length) ||
(!question.required && sortedItems.length > 0 && sortedItems.length < questionChoices.length);
if (hasIncompleteRanking) {
setError("Please rank all items before submitting.");
return;
}
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit(
{ [question.id]: sortedItems.map((item) => getLocalizedValue(item.label, languageCode)) },
updatedTtcObj
);
};
return (
<form onSubmit={handleSubmit} className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Ranking Items</legend>
<div className="fb-relative">
{[...sortedItems, ...unsortedItems].map((item, idx) => {
if (!item) return;
const isSorted = sortedItems.includes(item);
const isFirst = isSorted && idx === 0;
const isLast = isSorted && idx === sortedItems.length - 1;
return (
<div
key={item.id}
tabIndex={idx + 1}
className={cn(
"fb-flex fb-h-12 fb-items-center fb-mb-2 fb-border fb-border-border fb-transition-all fb-text-heading focus-within:fb-border-brand hover:fb-bg-input-bg-selected focus:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-cursor-pointer focus:fb-outline-none fb-transform fb-duration-500 fb-ease-in-out",
isSorted ? "fb-bg-input-bg-selected" : "fb-bg-input-bg"
)}
autoFocus={idx === 0 && autoFocusEnabled}>
<div
className="fb-flex fb-gap-x-4 fb-px-4 fb-items-center fb-grow fb-h-full group"
onClick={() => handleItemClick(item)}>
<span
className={cn(
"fb-w-6 fb-grow-0 fb-h-6 fb-flex fb-items-center fb-justify-center fb-rounded-full fb-text-xs fb-font-semibold fb-border-brand fb-border",
isSorted
? "fb-bg-brand fb-text-white fb-border"
: "fb-border-dashed group-hover:fb-bg-white fb-text-transparent group-hover:fb-text-heading"
)}>
{(idx + 1).toString()}
</span>
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm">
{getLocalizedValue(item.label, languageCode)}
</div>
</div>
{isSorted && (
<div className="fb-flex fb-flex-col fb-h-full fb-grow-0 fb-border-l fb-border-border">
<button
type="button"
onClick={() => handleMove(item.id, "up")}
className={cn(
"fb-px-2 fb-flex fb-flex-1 fb-items-center fb-justify-center",
isFirst
? "fb-opacity-30 fb-cursor-not-allowed"
: "hover:fb-bg-black/5 fb-rounded-tr-custom fb-transition-colors"
)}
disabled={isFirst}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-up">
<path d="m18 15-6-6-6 6" />
</svg>
</button>
<button
type="button"
onClick={() => handleMove(item.id, "down")}
className={cn(
"fb-px-2 fb-flex-1 fb-border-t fb-border-border fb-flex fb-items-center fb-justify-center",
isLast
? "fb-opacity-30 fb-cursor-not-allowed"
: "hover:fb-bg-black/5 fb-rounded-br-custom fb-transition-colors"
)}
disabled={isLast}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-down">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
)}
</div>
);
})}
</div>
</fieldset>
</div>
{error && <div className="fb-text-red-500 fb-mt-2 fb-text-sm">{error}</div>}
</div>
</ScrollableContainer>
<div className="fb-flex fb-w-full fb-justify-between fb-px-6 fb-py-4">
{!isFirstQuestion && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
tabIndex={questionChoices.length + 3}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
<div></div>
<SubmitButton
tabIndex={questionChoices.length + 2}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
</div>
</form>
);
};

View File

@@ -1,5 +1,5 @@
import { TAction, TSurveyAdvancedLogic } from "@formbricks/types/surveys/logic";
import { TSurvey, TSurveyChoice, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestion, TSurveyQuestionChoice } from "@formbricks/types/surveys/types";
export const cn = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
@@ -12,7 +12,7 @@ const shuffle = (array: any[]) => {
}
};
export const getShuffledChoicesIds = (choices: TSurveyChoice[], shuffleOption: string): string[] => {
export const getShuffledChoicesIds = (choices: TSurveyQuestionChoice[], shuffleOption: string): string[] => {
const otherOption = choices.find((choice) => {
return choice.id === "other";
});

View File

@@ -11,6 +11,7 @@ export interface SurveyBaseProps {
getSetIsError?: (getSetError: (value: boolean) => void) => void;
getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void;
getSetQuestionId?: (getSetQuestionId: (value: string) => void) => void;
getSetResponseData?: (getSetResponseData: (value: TResponseData) => void) => void;
onDisplay?: () => void;
onResponse?: (response: TResponseUpdate) => void;
onFinished?: () => void;

View File

@@ -66,6 +66,7 @@ export enum TSurveyQuestionTypeEnum {
Date = "date",
Matrix = "matrix",
Address = "address",
Ranking = "ranking",
}
export const ZSurveyQuestionId = z.string().superRefine((id, ctx) => {
@@ -215,7 +216,7 @@ export const ZSurveySingleUse = z
export type TSurveySingleUse = z.infer<typeof ZSurveySingleUse>;
export const ZSurveyChoice = z.object({
export const ZSurveyQuestionChoice = z.object({
id: z.string(),
label: ZI18nString,
});
@@ -225,7 +226,7 @@ export const ZSurveyPictureChoice = z.object({
imageUrl: z.string(),
});
export type TSurveyChoice = z.infer<typeof ZSurveyChoice>;
export type TSurveyQuestionChoice = z.infer<typeof ZSurveyQuestionChoice>;
export const ZSurveyQuestionBase = z.object({
id: ZSurveyQuestionId,
@@ -274,7 +275,7 @@ export const ZSurveyMultipleChoiceQuestion = ZSurveyQuestionBase.extend({
z.literal(TSurveyQuestionTypeEnum.MultipleChoiceMulti),
]),
choices: z
.array(ZSurveyChoice)
.array(ZSurveyQuestionChoice)
.min(2, { message: "Multiple Choice Question must have at least two choices" }),
shuffleOption: ZShuffleOption.optional(),
otherOptionPlaceholder: ZI18nString.optional(),
@@ -382,6 +383,17 @@ export const ZSurveyAddressQuestion = ZSurveyQuestionBase.extend({
});
export type TSurveyAddressQuestion = z.infer<typeof ZSurveyAddressQuestion>;
export const ZSurveyRankingQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionTypeEnum.Ranking),
choices: z
.array(ZSurveyQuestionChoice)
.min(2, { message: "Ranking Question must have at least two options" }),
otherOptionPlaceholder: ZI18nString.optional(),
shuffleOption: ZShuffleOption.optional(),
});
export type TSurveyRankingQuestion = z.infer<typeof ZSurveyRankingQuestion>;
export const ZSurveyQuestion = z.union([
ZSurveyOpenTextQuestion,
ZSurveyConsentQuestion,
@@ -395,6 +407,7 @@ export const ZSurveyQuestion = z.union([
ZSurveyCalQuestion,
ZSurveyMatrixQuestion,
ZSurveyAddressQuestion,
ZSurveyRankingQuestion,
]);
export type TSurveyQuestion = z.infer<typeof ZSurveyQuestion>;
@@ -417,6 +430,7 @@ export const ZSurveyQuestionType = z.enum([
TSurveyQuestionTypeEnum.PictureSelection,
TSurveyQuestionTypeEnum.Rating,
TSurveyQuestionTypeEnum.Cal,
TSurveyQuestionTypeEnum.Ranking,
]);
export type TSurveyQuestionType = z.infer<typeof ZSurveyQuestionType>;
@@ -651,7 +665,8 @@ export const ZSurvey = z
if (
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyQuestionTypeEnum.Ranking
) {
question.choices.forEach((choice, choiceIndex) => {
multiLangIssue = validateQuestionLabels(
@@ -1226,6 +1241,35 @@ export const ZSurveyQuestionSummaryAddress = z.object({
export type TSurveyQuestionSummaryAddress = z.infer<typeof ZSurveyQuestionSummaryAddress>;
export const ZSurveyQuestionSummaryRanking = z.object({
type: z.literal("ranking"),
question: ZSurveyRankingQuestion,
responseCount: z.number(),
choices: z.array(
z.object({
value: z.string(),
count: z.number(),
avgRanking: z.number(),
others: z
.array(
z.object({
value: z.string(),
person: z
.object({
id: ZId,
userId: z.string(),
})
.nullable(),
personAttributes: ZAttributes.nullable(),
})
)
.optional(),
})
),
});
export type TSurveyQuestionSummaryRanking = z.infer<typeof ZSurveyQuestionSummaryRanking>;
export const ZSurveyQuestionSummary = z.union([
ZSurveyQuestionSummaryOpenText,
ZSurveyQuestionSummaryMultipleChoice,
@@ -1239,6 +1283,7 @@ export const ZSurveyQuestionSummary = z.union([
ZSurveyQuestionSummaryCal,
ZSurveyQuestionSummaryMatrix,
ZSurveyQuestionSummaryAddress,
ZSurveyQuestionSummaryRanking,
]);
export type TSurveyQuestionSummary = z.infer<typeof ZSurveyQuestionSummary>;

View File

@@ -109,7 +109,7 @@ export const PreviewSurvey = ({
},
shrink: {
display: "relative",
width: ["83.33%"],
width: ["95%"],
height: ["95%"],
},
};
@@ -219,10 +219,9 @@ export const PreviewSurvey = ({
}
return (
<div className="flex h-full w-full flex-col items-center justify-items-center" id="survey-preview">
<div className="flex h-full w-full flex-col items-center justify-items-center py-4" id="survey-preview">
<motion.div
variants={previewParentContainerVariant}
className="fixed hidden h-[95%] w-5/6"
animate={isFullScreenPreview ? "expanded" : "shrink"}
/>
<motion.div
@@ -235,7 +234,7 @@ export const PreviewSurvey = ({
: "expanded_with_fixed_positioning"
: "shrink"
}
className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
className="relative flex items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
{previewMode === "mobile" && (
<>
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
@@ -295,7 +294,7 @@ export const PreviewSurvey = ({
</>
)}
{previewMode === "desktop" && (
<div className="flex h-full w-5/6 flex-1 flex-col">
<div className="flex h-full flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>

View File

@@ -6,6 +6,7 @@ import {
FileTextIcon,
HomeIcon,
ListIcon,
ListOrderedIcon,
MessageSquareTextIcon,
PhoneIcon,
PresentationIcon,
@@ -34,6 +35,7 @@ const questionIconMapping = {
date: CalendarDaysIcon,
cal: PhoneIcon,
address: HomeIcon,
ranking: ListOrderedIcon,
};
interface RecallItemSelectProps {

View File

@@ -20,9 +20,9 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
TI18nString,
TSurvey,
TSurveyChoice,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRecallItem,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
@@ -51,7 +51,7 @@ interface QuestionFormInputProps {
questionIdx: number;
updateQuestion?: (questionIdx: number, data: Partial<TSurveyQuestion>) => void;
updateSurvey?: (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => void;
updateChoice?: (choiceIdx: number, data: Partial<TSurveyChoice>) => void;
updateChoice?: (choiceIdx: number, data: Partial<TSurveyQuestionChoice>) => void;
updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial<TSurveyQuestion>) => void;
isInvalid: boolean;
selectedLanguageCode: string;

View File

@@ -0,0 +1,19 @@
interface RankingResponseProps {
value: string[];
}
export const RankingRespone = ({ value }: RankingResponseProps) => {
return (
<div className="my-1 font-semibold text-slate-700" dir="auto">
{value.map(
(item, index) =>
item && (
<div key={index} className="mb-1 flex items-center">
<span className="mr-2 text-gray-400">#{index + 1}</span>
<div className="rounded bg-gray-100 px-2 py-1">{item}</div>
</div>
)
)}
</div>
);
};

View File

@@ -14,6 +14,7 @@ import {
import { AddressResponse } from "../../AddressResponse";
import { FileUploadResponse } from "../../FileUploadResponse";
import { PictureSelectionResponse } from "../../PictureSelectionResponse";
import { RankingRespone } from "../../RankingResponse";
import { RatingResponse } from "../../RatingResponse";
import { isValidValue } from "../util";
import { HiddenFields } from "./HiddenFields";
@@ -113,6 +114,10 @@ export const SingleResponseCardBody = ({
if (Array.isArray(responseData)) {
return <AddressResponse value={responseData} />;
}
case TSurveyQuestionTypeEnum.Ranking:
if (Array.isArray(responseData)) {
return <RankingRespone value={responseData} />;
}
default:
if (
typeof responseData === "string" ||

2
pnpm-lock.yaml generated
View File

@@ -29589,4 +29589,4 @@ snapshots:
'@types/react': 18.3.3
react: 18.3.1
zwitch@2.0.4: {}
zwitch@2.0.4: {}