mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-19 19:21:15 -05:00
adds ranking question logic
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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. It’s 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 "Other"
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const BackButton = () => {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => {
|
||||
router.back();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const convertResponseValue = (
|
||||
if (!answer) return "";
|
||||
else {
|
||||
switch (question.type) {
|
||||
case "ranking":
|
||||
case "fileUpload":
|
||||
if (typeof answer === "string") {
|
||||
return [answer];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
257
packages/surveys/src/components/questions/RankingQuestion.tsx
Normal file
257
packages/surveys/src/components/questions/RankingQuestion.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
packages/ui/RankingResponse/index.tsx
Normal file
19
packages/ui/RankingResponse/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
2
pnpm-lock.yaml
generated
@@ -29589,4 +29589,4 @@ snapshots:
|
||||
'@types/react': 18.3.3
|
||||
react: 18.3.1
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
zwitch@2.0.4: {}
|
||||
Reference in New Issue
Block a user