Smoothen Progressbar animations and minor survey editor improvements (#339)

* auto focus on sign up

* update PR template

* add updatedAt date to survey summary

* add animation to Progress, make timer smoother

* change button size in question card, auto focus

* add transition to js widget, fix auto focus in editor

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Johannes
2023-06-09 10:31:22 +02:00
committed by GitHub
parent d2aa9b5f04
commit 4bfaf68de2
19 changed files with 120 additions and 35 deletions

View File

@@ -37,4 +37,5 @@ Fixes # (issue)
- [ ] Checked for warnings, there are none
- [ ] Removed all `console.logs`
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
- [ ] My changes don't cause any responsiveness issues
- [ ] Updated the Formbricks Docs if changes were necessary

View File

@@ -78,7 +78,12 @@ export default function PreviewSurvey({
frameRef.current = requestAnimationFrame(frame);
} else {
handleStopCountdown();
// close modal
setIsModalOpen(false);
// reopen the modal after 1 second
setTimeout(() => {
setIsModalOpen(true);
setActiveQuestionId(questions[0]?.id || ""); // set first question as active
}, 1500);
}
};

View File

@@ -21,12 +21,14 @@ export default function CTAQuestionForm({
lastQuestion,
}: CTAQuestionFormProps): JSX.Element {
const [firstRender, setFirstRender] = useState(true);
return (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}

View File

@@ -317,10 +317,10 @@ export default function LogicEditor({
<div className="mt-2 flex items-center space-x-2">
<Button
id="logicJumps"
size="sm"
className="bg-slate-100 hover:bg-slate-50"
type="button"
name="logicJumps"
size="sm"
variant="secondary"
StartIcon={SplitIcon}
onClick={() => addLogic()}>

View File

@@ -4,6 +4,7 @@ import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
interface OpenQuestionFormProps {
localSurvey: Survey;
@@ -19,6 +20,10 @@ export default function MultipleChoiceMultiForm({
updateQuestion,
lastQuestion,
}: OpenQuestionFormProps): JSX.Element {
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
const questionRef = useRef<HTMLInputElement>(null);
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
const newChoices = !question.choices
? []
@@ -32,6 +37,7 @@ export default function MultipleChoiceMultiForm({
};
const addChoice = () => {
setIsNew(false); // This question is no longer new.
let newChoices = !question.choices ? [] : question.choices;
const otherChoice = newChoices.find((choice) => choice.id === "other");
if (otherChoice) {
@@ -70,12 +76,26 @@ export default function MultipleChoiceMultiForm({
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
};
useEffect(() => {
if (lastChoiceRef.current) {
lastChoiceRef.current?.focus();
}
}, [question.choices?.length]);
// This effect will run once on initial render, setting focus to the question input.
useEffect(() => {
if (isNew && questionRef.current) {
questionRef.current.focus();
}
}, [isNew]);
return (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
ref={questionRef}
id="headline"
name="headline"
value={question.headline}
@@ -103,6 +123,7 @@ export default function MultipleChoiceMultiForm({
question.choices.map((choice, choiceIdx) => (
<div key={choiceIdx} className="inline-flex w-full items-center">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
@@ -119,7 +140,7 @@ export default function MultipleChoiceMultiForm({
</div>
))}
<div className="flex items-center space-x-2">
<Button variant="secondary" type="button" onClick={() => addChoice()}>
<Button variant="secondary" size="sm" type="button" onClick={() => addChoice()}>
Add Option
</Button>
{question.choices.filter((c) => c.id === "other").length === 0 && (

View File

@@ -4,6 +4,7 @@ import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
interface OpenQuestionFormProps {
localSurvey: Survey;
@@ -19,6 +20,10 @@ export default function MultipleChoiceSingleForm({
updateQuestion,
lastQuestion,
}: OpenQuestionFormProps): JSX.Element {
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
const questionRef = useRef<HTMLInputElement>(null);
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
const newChoices = !question.choices
? []
@@ -32,6 +37,7 @@ export default function MultipleChoiceSingleForm({
};
const addChoice = () => {
setIsNew(false); // This question is no longer new.
let newChoices = !question.choices ? [] : question.choices;
const otherChoice = newChoices.find((choice) => choice.id === "other");
if (otherChoice) {
@@ -70,12 +76,26 @@ export default function MultipleChoiceSingleForm({
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
};
useEffect(() => {
if (lastChoiceRef.current) {
lastChoiceRef.current?.focus();
}
}, [question.choices?.length]);
// This effect will run once on initial render, setting focus to the question input.
useEffect(() => {
if (isNew && questionRef.current) {
questionRef.current.focus();
}
}, [isNew]);
return (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
ref={questionRef}
id="headline"
name="headline"
value={question.headline}
@@ -103,6 +123,7 @@ export default function MultipleChoiceSingleForm({
question.choices.map((choice, choiceIdx) => (
<div key={choiceIdx} className="inline-flex w-full items-center">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
@@ -119,7 +140,7 @@ export default function MultipleChoiceSingleForm({
</div>
))}
<div className="flex items-center space-x-2">
<Button variant="secondary" type="button" onClick={() => addChoice()}>
<Button variant="secondary" size="sm" type="button" onClick={() => addChoice()}>
Add Option
</Button>
{question.choices.filter((c) => c.id === "other").length === 0 && (

View File

@@ -22,6 +22,7 @@ export default function NPSQuestionForm({
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}

View File

@@ -22,6 +22,7 @@ export default function OpenQuestionForm({
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}

View File

@@ -24,6 +24,7 @@ export default function RatingQuestionForm({
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}

View File

@@ -36,7 +36,7 @@ export default function MultipleChoiceSummary({
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
console.log(questionSummary.responses);
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (const choice of questionSummary.question.choices) {

View File

@@ -20,6 +20,7 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import LinkSurveyModal from "./LinkSurveyModal";
import { timeSinceConditionally } from "@formbricks/lib/time";
export default function SummaryMetadata({ surveyId, environmentId }) {
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
@@ -128,7 +129,9 @@ export default function SummaryMetadata({ surveyId, environmentId }) {
</TooltipProvider>
</div>
<div className="flex flex-col justify-between lg:col-span-1">
<div className=""></div>
<div className="text-right text-xs text-slate-400">
Last updated: {timeSinceConditionally(survey.updatedAt)}
</div>
<div className="flex justify-end gap-x-1.5">
{survey.type === "link" && (
<Button

View File

@@ -11,6 +11,7 @@ import { GithubButton } from "./GithubButton";
export const SigninForm = () => {
const searchParams = useSearchParams();
const emailRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (e) => {
setLoggingIn(true);
@@ -48,6 +49,7 @@ export const SigninForm = () => {
Email address
</label>
<input
ref={emailRef}
id="email"
name="email"
type="email"
@@ -90,6 +92,8 @@ export const SigninForm = () => {
if (!showLogin) {
setShowLogin(true);
setButtonEnabled(false);
// Add a slight delay before focusing the input field to ensure it's visible
setTimeout(() => emailRef.current?.focus(), 100);
} else if (formRef.current) {
formRef.current.requestSubmit();
}

View File

@@ -14,6 +14,7 @@ export const SignupForm = () => {
const router = useRouter();
const [error, setError] = useState<string>("");
const [signingUp, setSigningUp] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (e: any) => {
setSigningUp(true);
@@ -78,6 +79,7 @@ export const SignupForm = () => {
</label>
<div className="mt-1">
<input
ref={nameRef}
id="name"
name="name"
type="text"
@@ -136,6 +138,8 @@ export const SignupForm = () => {
if (!showLogin) {
setShowLogin(true);
setButtonEnabled(false);
// Add a slight delay before focusing the input field to ensure it's visible
setTimeout(() => nameRef.current?.focus(), 100);
} else if (formRef.current) {
formRef.current.requestSubmit();
}

View File

@@ -1,10 +1,10 @@
import { useState, useEffect } from "react";
import { Input } from "@/../../packages/ui";
import SubmitButton from "@/components/preview/SubmitButton";
import { cn } from "@formbricks/lib/cn";
import type { MultipleChoiceMultiQuestion } from "@formbricks/types/questions";
import { useEffect, useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
import { Input } from "@/../../packages/ui";
interface MultipleChoiceMultiProps {
question: MultipleChoiceMultiQuestion;
@@ -46,7 +46,7 @@ export default function MultipleChoiceMultiQuestion({
};
onSubmit(data);
// console.log(data);
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
@@ -103,10 +103,9 @@ export default function MultipleChoiceMultiQuestion({
</span>
{choice.id === "other" && showOther && (
<Input
type="text"
id={`${choice.id}-label`}
name={question.id}
className="mt-2 bg-white"
className="mt-2 bg-white focus:border-slate-300"
placeholder="Please specify"
onChange={(e) => setOtherSpecified(e.currentTarget.value)}
aria-labelledby={`${choice.id}-label`}

View File

@@ -1,11 +1,10 @@
import { Input } from "@/../../packages/ui";
import SubmitButton from "@/components/preview/SubmitButton";
import { cn } from "@formbricks/lib/cn";
import type { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import { useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
import { Input } from "@/../../packages/ui";
import { useRef } from "react";
interface MultipleChoiceSingleProps {
question: MultipleChoiceSingleQuestion;
@@ -21,19 +20,15 @@ export default function MultipleChoiceSingleQuestion({
brandColor,
}: MultipleChoiceSingleProps) {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const otherSpecify = useRef<HTMLInputElement>(null);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
const value = e.currentTarget[question.id].value;
const data = {
[question.id]: value,
};
// console.log(data);
onSubmit(data);
setSelectedChoice(null); // reset form
}}>
@@ -70,12 +65,12 @@ export default function MultipleChoiceSingleQuestion({
</span>
{choice.id === "other" && selectedChoice === "other" && (
<Input
ref={otherSpecify}
id="other-specify"
name="other-specify"
id={`${choice.id}-label`}
name={question.id}
placeholder="Please specify"
className="mt-3 bg-white"
className="mt-3 bg-white focus:border-slate-300"
required={question.required}
aria-labelledby={`${choice.id}-label`}
autoFocus
/>
)}

View File

@@ -1,9 +1,20 @@
export default function Progress({ progress, brandColor }: { progress: number; brandColor: string }) {
import React from "react";
const ProgressComponent = ({ progress, brandColor }) => {
return (
<div className="h-1 w-full rounded-full bg-slate-200">
<div
className="h-1 rounded-full bg-slate-700"
className="transition-width h-1 rounded-full duration-500"
style={{ backgroundColor: brandColor, width: `${Math.floor(progress * 100)}%` }}></div>
</div>
);
}
};
ProgressComponent.displayName = "Progress";
const Progress = React.memo(ProgressComponent, (prevProps, nextProps) => {
// Only re-render if progress or brandColor changes
return prevProps.progress === nextProps.progress && prevProps.brandColor === nextProps.brandColor;
});
export default Progress;

View File

@@ -1,6 +1,6 @@
import type { MultipleChoiceMultiQuestion } from "../../../types/questions";
import { h } from "preact";
import { useState } from "preact/hooks";
import { useState, useRef, useEffect } from "preact/hooks";
import { cn } from "../lib/utils";
import Headline from "./Headline";
import Subheader from "./Subheader";
@@ -22,11 +22,18 @@ export default function MultipleChoiceMultiQuestion({
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [showOther, setShowOther] = useState(false);
const [otherSpecified, setOtherSpecified] = useState("");
const otherInputRef = useRef(null);
const isAtLeastOneChecked = () => {
return selectedChoices.length > 0 || otherSpecified.length > 0;
};
useEffect(() => {
if (showOther && otherInputRef.current) {
otherInputRef.current.focus();
}
}, [showOther]);
return (
<form
onSubmit={(e) => {
@@ -97,11 +104,13 @@ export default function MultipleChoiceMultiQuestion({
</span>
{choice.id === "other" && showOther && (
<input
type="text"
ref={otherInputRef}
id={`${choice.id}-label`}
name={question.id}
placeholder="Please specify"
className="fb-mt-3 fb-flex fb-h-10 fb-w-full fb-rounded-md fb-border fb-bg-white fb-border-slate-300 fb-bg-transparent fb-px-3 fb-py-2 fb-text-sm fb-text-slate-800 placeholder:fb-text-slate-400 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-400 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300"
className={cn(
"fb-mt-3 fb-flex fb-h-10 fb-w-full fb-rounded-md fb-border fb-bg-white fb-border-slate-300 fb-bg-transparent fb-px-3 fb-py-2 fb-text-sm fb-text-slate-800 placeholder:fb-text-slate-400 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-400 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300"
)}
onChange={(e) => setOtherSpecified(e.currentTarget.value)}
aria-labelledby={`${choice.id}-label`}
required={question.required}

View File

@@ -1,5 +1,5 @@
import { h } from "preact";
import { useRef, useState } from "preact/hooks";
import { useRef, useState, useEffect } from "preact/hooks";
import { cn } from "../lib/utils";
import type { MultipleChoiceSingleQuestion } from "../../../types/questions";
import Headline from "./Headline";
@@ -22,6 +22,12 @@ export default function MultipleChoiceSingleQuestion({
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const otherSpecify = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectedChoice === "other") {
otherSpecify.current?.focus();
}
}, [selectedChoice]);
return (
<form
onSubmit={(e) => {
@@ -73,11 +79,12 @@ export default function MultipleChoiceSingleQuestion({
{choice.id === "other" && selectedChoice === "other" && (
<input
ref={otherSpecify}
id="other-specify"
name="other-specify"
id={`${choice.id}-label`}
name={question.id}
placeholder="Please specify"
className="fb-mt-3 fb-flex fb-h-10 fb-w-full fb-rounded-md fb-border fb-bg-white fb-border-slate-300 fb-bg-transparent fb-px-3 fb-py-2 fb-text-sm fb-text-slate-800 placeholder:fb-text-slate-400 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-400 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300"
required={question.required}
aria-labelledby={`${choice.id}-label`}
/>
)}
</label>

View File

@@ -2,9 +2,9 @@ import { h } from "preact";
export default function Progress({ progress, brandColor }: { progress: number; brandColor: string }) {
return (
<div className="fb-h-1 fb-w-full fb-rounded-full bg-slate-200">
<div className="fb-h-1 fb-w-full fb-rounded-full fb-bg-slate-200">
<div
className="fb-h-1 fb-rounded-full"
className="fb-h-1 fb-rounded-full fb-transition-width fb-duration-500"
style={{ backgroundColor: brandColor, width: `${Math.floor(progress * 100)}%` }}></div>
</div>
);