Improve Preview in Survey Editor with Mobile & Desktop View (#573)

* made modal component responsive

* added tab switch

* added mobile preview mode for surveys

* did some refactors

* did some refactors

* added type defs

* ran pnpm format

* removed an unused comment

* fixed variable name typo

* fixed UI bugs and added mobile mockup to link surveys

* restored changes from fix long description PR

* fixed scroll to top issue and toggle hide bug

* fixed minor animation bug

* fixed placement issue

* re-embed restart button, make phone preview more responsive

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-08-11 14:25:49 +05:30
committed by GitHub
parent 52a09aa3ae
commit 98cdf941e6
9 changed files with 312 additions and 112 deletions

View File

@@ -4,6 +4,7 @@ import FormbricksSignature from "@/components/preview/FormbricksSignature";
import Modal from "@/components/preview/Modal";
import Progress from "@/components/preview/Progress";
import QuestionConditional from "@/components/preview/QuestionConditional";
import TabOption from "@/components/preview/TabOption";
import ThankYouCard from "@/components/preview/ThankYouCard";
import type { Logic, Question } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
@@ -11,6 +12,7 @@ import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { Button } from "@formbricks/ui";
import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline";
import { ComputerDesktopIcon, DevicePhoneMobileIcon } from "@heroicons/react/24/solid";
import { useEffect, useRef, useState } from "react";
interface PreviewSurveyProps {
setActiveQuestionId: (id: string | null) => void;
@@ -26,6 +28,77 @@ interface PreviewSurveyProps {
environment: TEnvironment;
}
function QuestionRenderer({
activeQuestionId,
lastActiveQuestionId,
questions,
brandColor,
thankYouCard,
gotoNextQuestion,
showBackButton,
goToPreviousQuestion,
storedResponseValue,
}) {
return (
<div>
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
<ThankYouCard
brandColor={brandColor}
headline={thankYouCard?.headline || "Thank you!"}
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
/>
) : (
questions.map((question, idx) =>
(activeQuestionId || lastActiveQuestionId) === question.id ? (
<QuestionConditional
key={question.id}
question={question}
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
)
)}
</div>
);
}
function PreviewModalContent({
activeQuestionId,
lastActiveQuestionId,
questions,
brandColor,
thankYouCard,
gotoNextQuestion,
showBackButton,
goToPreviousQuestion,
storedResponseValue,
showFormbricksSignature,
}) {
return (
<div className="px-4 py-6 sm:p-6">
<QuestionRenderer
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
/>
{showFormbricksSignature && <FormbricksSignature />}
</div>
);
}
export default function PreviewSurvey({
setActiveQuestionId,
activeQuestionId,
@@ -46,8 +119,9 @@ export default function PreviewSurvey({
const [finished, setFinished] = useState(false);
const [storedResponseValue, setStoredResponseValue] = useState<any>();
const [storedResponse, setStoredResponse] = useState<Record<string, any>>({});
const [previewMode, setPreviewMode] = useState("desktop");
const showBackButton = progress !== 0 && !finished;
const ContentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (product) {
@@ -108,6 +182,13 @@ export default function PreviewSurvey({
}, [autoClose]);
useEffect(() => {
if (ContentRef.current) {
// scroll to top whenever question changes
ContentRef.current.scrollTop = 0;
}
if (activeQuestionId !== "end") {
setFinished(false);
}
if (activeQuestionId) {
setLastActiveQuestionId(activeQuestionId);
setProgress(calculateProgress(questions, activeQuestionId));
@@ -268,105 +349,159 @@ export default function PreviewSurvey({
}
return (
<div className="flex h-full w-5/6 flex-1 flex-col rounded-lg border border-slate-300 bg-slate-200 ">
<div className="flex h-8 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>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<p>
<span className="ml-4 font-mono text-sm text-slate-400">
{previewType === "modal" ? "Your web app" : "Preview"}
</span>
</p>
<div className="ml-auto flex items-center">
<Button
variant="minimal"
className="mx-2 my-4 px-2 py-0.2 text-sm text-slate-500 bg-white"
onClick={resetQuestionProgress}>
Restart
<ArrowPathRoundedSquareIcon className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={product.placement}
highlightBorderColor={product.highlightBorderColor}>
{!countdownStop && autoClose !== null && autoClose > 0 && (
<Progress progress={countdownProgress} brandColor={brandColor} />
)}
<div
onClick={() => handleStopCountdown()}
onMouseOver={() => handleStopCountdown()}
className="px-4 py-6 sm:p-6">
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
<ThankYouCard
brandColor={brandColor}
headline={thankYouCard?.headline || "Thank you!"}
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
/>
) : (
questions.map((question, idx) =>
(activeQuestionId || lastActiveQuestionId) === question.id ? (
<QuestionConditional
key={question.id}
question={question}
<div className="flex h-full w-full flex-col items-center justify-items-center">
<div className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
{previewMode === "mobile" && (
<>
<div className="absolute right-0 top-0 m-2">
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
</div>
<div className="relative h-[90%] max-h-[40rem] w-80 overflow-hidden rounded-[3rem] border-8 border-slate-500 bg-slate-400">
{/* below element is use to create notch for the mobile device mockup */}
<div className="absolute left-1/2 right-1/2 top-0 z-20 h-4 w-1/2 -translate-x-1/2 transform rounded-b-md bg-slate-500"></div>
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={product.placement}
highlightBorderColor={product.highlightBorderColor}
previewMode="mobile">
{!countdownStop && autoClose !== null && autoClose > 0 && (
<Progress progress={countdownProgress} brandColor={brandColor} />
)}
<PreviewModalContent
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
showFormbricksSignature={showFormbricksSignature}
/>
) : null
)
)}
{showFormbricksSignature && <FormbricksSignature />}
</div>
<Progress progress={progress} brandColor={brandColor} />
</Modal>
) : (
<div className="flex flex-grow flex-col overflow-y-auto">
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="w-full max-w-md">
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
<ThankYouCard
brandColor={brandColor}
headline={thankYouCard?.headline || "Thank you!"}
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
/>
<Progress progress={progress} brandColor={brandColor} />
</Modal>
) : (
questions.map((question, idx) =>
(activeQuestionId || lastActiveQuestionId) === question.id ? (
<QuestionConditional
key={question.id}
question={question}
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
)
<div
className="absolute top-0 z-10 flex h-full w-full flex-grow flex-col overflow-y-auto"
ref={ContentRef}>
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="w-full max-w-md px-4">
<QuestionRenderer
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
/>
</div>
</div>
<div className="z-10 w-full rounded-b-lg bg-white">
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
<Progress progress={progress} brandColor={brandColor} />
{showFormbricksSignature && <FormbricksSignature />}
</div>
</div>
</div>
)}
</div>
</div>
<div className="z-10 w-full rounded-b-lg bg-white">
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
<Progress progress={progress} brandColor={brandColor} />
{showFormbricksSignature && <FormbricksSignature />}
</>
)}
{previewMode === "desktop" && (
<div className="flex h-full w-5/6 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>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<p className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
{previewType === "modal" ? "Your web app" : "Preview"}
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
</p>
</div>
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={product.placement}
highlightBorderColor={product.highlightBorderColor}
previewMode="desktop">
{!countdownStop && autoClose !== null && autoClose > 0 && (
<Progress progress={countdownProgress} brandColor={brandColor} />
)}
<PreviewModalContent
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
showFormbricksSignature={showFormbricksSignature}
/>
<Progress progress={progress} brandColor={brandColor} />
</Modal>
) : (
<div className="flex flex-grow flex-col overflow-y-auto" ref={ContentRef}>
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="w-full max-w-md">
<QuestionRenderer
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
/>
</div>
</div>
<div className="z-10 w-full rounded-b-lg bg-white">
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
<Progress progress={progress} brandColor={brandColor} />
{showFormbricksSignature && <FormbricksSignature />}
</div>
</div>
</div>
)}
</div>
</div>
)}
)}
</div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<TabOption
active={previewMode === "mobile"}
icon={<DevicePhoneMobileIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
onClick={() => setPreviewMode("mobile")}
/>
<TabOption
active={previewMode === "desktop"}
icon={<ComputerDesktopIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
onClick={() => setPreviewMode("desktop")}
/>
</div>
</div>
);
}
function ResetProgressButton({ resetQuestionProgress }) {
return (
<Button
variant="minimal"
className="py-0.2 bg-white px-2 text-sm text-slate-500"
onClick={resetQuestionProgress}>
Restart
<ArrowPathRoundedSquareIcon className="ml-2 h-4 w-4" />
</Button>
);
}

View File

@@ -1,20 +1,23 @@
import { getPlacementStyle } from "@/lib/preview";
import { cn } from "@formbricks/lib/cn";
import { PlacementType } from "@formbricks/types/js";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { ReactNode, useEffect, useMemo, useState, useRef } from "react";
export default function Modal({
children,
isOpen,
placement,
previewMode,
highlightBorderColor,
}: {
children: ReactNode;
isOpen: boolean;
placement: PlacementType;
previewMode: string;
highlightBorderColor: string | null | undefined;
}) {
const [show, setShow] = useState(false);
const modalRef = useRef<HTMLDivElement | null>(null);
const highlightBorderColorStyle = useMemo(() => {
if (!highlightBorderColor) return {};
@@ -29,15 +32,35 @@ export default function Modal({
setShow(isOpen);
}, [isOpen]);
// scroll to top whenever question in modal changes
useEffect(() => {
if (modalRef.current) {
modalRef.current.scrollTop = 0;
}
}, [children]);
const slidingAnimationClass =
previewMode === "desktop"
? show
? "translate-x-0 opacity-100"
: "translate-x-32 opacity-0"
: previewMode === "mobile"
? show
? "bottom-0"
: "-bottom-full"
: "";
return (
<div aria-live="assertive" className="relative h-full w-full">
<div aria-live="assertive" className="relative h-full w-full overflow-hidden">
<div
ref={modalRef}
style={highlightBorderColorStyle}
className={cn(
show ? "translate-x-0 opacity-100" : "translate-x-32 opacity-0",
"pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-hidden overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out",
getPlacementStyle(placement)
)}
style={highlightBorderColorStyle}>
"pointer-events-auto absolute max-h-[90%] w-full h-fit max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out",
previewMode === "desktop" ? getPlacementStyle(placement) : "max-w-full ",
slidingAnimationClass
)}>
{children}
</div>
</div>

View File

@@ -117,7 +117,7 @@ export default function MultipleChoiceMultiQuestion({
<div className="mt-4">
<fieldset>
<legend className="sr-only">Options</legend>
<div className="xs:max-h-[41vh] relative max-h-[60vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2">
<div className="relative space-y-2 rounded-md py-0.5">
{questionChoices.map((choice) => (
<div key={choice.id}>
<label

View File

@@ -102,7 +102,7 @@ export default function MultipleChoiceSingleQuestion({
<div className="mt-4">
<fieldset>
<legend className="sr-only">Options</legend>
<div className="xs:max-h-[41vh] relative max-h-[60vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2">
<div className="relative space-y-2 rounded-md py-0.5">
{questionChoices.map((choice, idx) => (
<label
key={choice.id}

View File

@@ -0,0 +1,17 @@
import { ReactNode } from "react";
export default function OptionButton({
active,
icon,
onClick,
}: {
active: boolean;
icon: ReactNode;
onClick: () => void;
}) {
return (
<div className={`${active ? "rounded-full bg-slate-200" : ""} cursor-pointer`} onClick={onClick}>
{icon}
</div>
);
}

View File

@@ -11,7 +11,7 @@ export const getPlacementStyle = (placement: PlacementType) => {
case "bottomLeft":
return "bottom-3 sm:left-3";
case "center":
return "top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2";
return "top-1/2 left-1/2 transform !-translate-x-1/2 -translate-y-1/2";
default:
return "bottom-3 sm:right-3";
}

View File

@@ -3,6 +3,12 @@ import { h, VNode } from "preact";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { cn } from "../lib/utils";
// CSS classes object
const mobileClasses = {
show: "fb--translate-y-full",
hide: "fb-translate-y-0",
};
export default function Modal({
children,
isOpen,
@@ -21,6 +27,7 @@ export default function Modal({
close: () => void;
}) {
const [show, setShow] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const isCenter = placement === "center";
const modalRef = useRef(null);
@@ -42,20 +49,36 @@ export default function Modal({
};
}, [show, clickOutside, close, isCenter]);
const handleMobileClasses = (isMobile, show) => {
return isMobile ? (show ? mobileClasses.show : mobileClasses.hide) : "";
};
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 640);
};
window.addEventListener("resize", handleResize);
handleResize();
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
// This classes will be applied only when screen size is greater than sm, hence sm is common prefix for all
const getPlacementStyle = (placement: PlacementType) => {
switch (placement) {
case "bottomRight":
return "fb-bottom-3 sm:fb-right-3";
return "sm:fb-bottom-3 sm:fb-right-3";
case "topRight":
return "sm:fb-top-3 sm:fb-right-3 fb-bottom-3";
return "sm:fb-top-3 sm:fb-right-3 sm:fb-bottom-3";
case "topLeft":
return "sm:fb-top-3 sm:fb-left-3 fb-bottom-3";
return "sm:fb-top-3 sm:fb-left-3 sm:fb-bottom-3";
case "bottomLeft":
return "fb-bottom-3 sm:fb-left-3";
return "sm:fb-bottom-3 sm:fb-left-3";
case "center":
return "fb-top-1/2 fb-left-1/2 fb-transform -fb-translate-x-1/2 -fb-translate-y-1/2";
return "sm:fb-top-1/2 sm:fb-left-1/2 sm:fb-transform sm:-fb-translate-x-1/2 sm:-fb-translate-y-1/2";
default:
return "fb-bottom-3 sm:fb-right-3";
return "sm:fb-bottom-3 sm:fb-right-3";
}
};
@@ -75,7 +98,7 @@ export default function Modal({
aria-live="assertive"
className={cn(
isCenter ? "fb-pointer-events-auto" : "fb-pointer-events-none",
"fb-fixed fb-inset-0 fb-flex fb-items-end fb-z-999999 fb-p-3 sm:fb-p-0"
"fb-fixed fb-inset-0 fb-flex fb-items-end fb-z-999999"
)}>
<div
className={cn(
@@ -91,7 +114,9 @@ export default function Modal({
className={cn(
getPlacementStyle(placement),
show ? "fb-opacity-100" : "fb-opacity-0",
"fb-h-fit fb-pointer-events-auto fb-absolute fb-w-full fb-max-w-sm fb-overflow-hidden fb-rounded-lg fb-bg-white fb-shadow-lg fb-ring-1 fb-ring-black fb-ring-opacity-5 fb-transition-all fb-duration-500 fb-ease-in-out sm:fb-m-4"
"fb-h-fit fb-pointer-events-auto fb-absolute fb-w-full sm:fb-max-w-sm fb-overflow-hidden fb-rounded-lg fb-bg-white fb-shadow-lg fb-ring-1 fb-ring-black fb-ring-opacity-5 fb-transition-all fb-duration-500 fb-ease-in-out sm:fb-m-4",
isMobile && "fb-top-full",
handleMobileClasses(isMobile, show)
)}>
<div class="fb-absolute fb-top-0 fb-right-0 fb-pt-4 fb-pr-4 fb-block">
<button

View File

@@ -118,7 +118,7 @@ export default function MultipleChoiceMultiQuestion({
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-relative fb-space-y-2 fb-rounded-md fb-bg-white fb-max-h-[42vh] fb-overflow-y-auto fb-pr-2 fb-py-0.5">
<div className="fb-relative fb-space-y-2 fb-rounded-md fb-bg-white">
{questionChoices.map((choice) => (
<label
key={choice.id}

View File

@@ -101,7 +101,7 @@ export default function MultipleChoiceSingleQuestion({
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-relative fb-space-y-2 fb-rounded-md fb-bg-white fb-max-h-[42vh] fb-overflow-y-auto fb-pr-2 fb-py-0.5">
<div className="fb-relative fb-space-y-2 fb-rounded-md fb-bg-white">
{questionChoices.map((choice, idx) => (
<label
key={choice.id}