Skip to content

Commit

Permalink
perf: ⚡️ Added empty string validation in answer input form
Browse files Browse the repository at this point in the history
  • Loading branch information
growupanand committed Apr 7, 2024
1 parent 1f4ab76 commit ad3acec
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 117 deletions.
5 changes: 4 additions & 1 deletion apps/web/src/components/formViewer/endScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ type Props = {
};

export const EndScreen = (props: Props) => {
const message = props.endScreenMessage || "Thank you for filling the form!";
const message =
props.endScreenMessage !== ""
? props.endScreenMessage
: "Thank you for filling the form!";
return (
<div className="flex flex-col items-center justify-center">
<h1 className="text-center text-2xl font-bold">{message}</h1>
Expand Down
182 changes: 111 additions & 71 deletions apps/web/src/components/formViewer/formFields.tsx
Original file line number Diff line number Diff line change
@@ -1,92 +1,132 @@
import { ChangeEvent } from "react";
"use client";

import { Button } from "@convoform/ui/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@convoform/ui/components/ui/form";
import { Textarea } from "@convoform/ui/components/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft, CornerDownLeft, Tally1 } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { montserrat } from "@/app/fonts";
import { cn } from "@/lib/utils";

type Props = {
isFormBusy: boolean;
// eslint-disable-next-line no-unused-vars
handleFormSubmit: (event: any) => void;
handleInputChange: (
// eslint-disable-next-line no-unused-vars
e: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement>,
) => void;
input: string;
currentQuestion: string;
handleShowPreviousQuestion: () => void;
isFirstQuestion: boolean;
handleGoToPrevQuestion: () => string;
showPrevQuestionButton: boolean;
submitAnswer: (answer: string) => Promise<void>;
};

const formSchema = z.object({
answer: z.string().min(1).max(255),
});

export const FormFieldsViewer = ({
isFormBusy,
handleFormSubmit,
handleInputChange,
input,
currentQuestion,
handleShowPreviousQuestion,
isFirstQuestion,
handleGoToPrevQuestion,
showPrevQuestionButton,
submitAnswer,
}: Props) => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
answer: "",
},
});

async function onSubmit(values: z.infer<typeof formSchema>) {
const { answer } = values;
await submitAnswer(answer);
form.reset();
form.setFocus("answer");
}

function goToPrevQuestion() {
const prevAnswer = handleGoToPrevQuestion();
form.setValue("answer", prevAnswer);
form.setFocus("answer");
}

return (
<form onSubmit={handleFormSubmit}>
<div className={cn("py-3", isFirstQuestion && "hidden")}>
<Button
type="button"
variant="ghost"
className={cn("rounded-full", montserrat.className)}
size="sm"
onClick={handleShowPreviousQuestion}
disabled={isFormBusy}
>
<ChevronLeft className="mr-2" size={20} />
<span>Back to Previous</span>
</Button>
</div>
<div className="flex min-h-full w-full flex-col items-center justify-center px-3 ">
<h1 className="mb-10 w-full text-4xl font-medium ">
<span>
{currentQuestion}
{isFormBusy && <Tally1 className="ml-2 inline animate-ping" />}
</span>
</h1>
{!isFormBusy && (
<div className="w-full">
<Textarea
autoFocus
className="w-full rounded-none border-0 border-b bg-transparent text-xl focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0"
placeholder="Type answer here..."
value={input}
disabled={isFormBusy}
onChange={handleInputChange}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleFormSubmit(event);
}
}}
/>
<div className="text-muted-foreground flex items-center justify-end pt-1 text-sm font-light max-lg:hidden">
Press
<span className="mx-1 flex items-center font-bold">
Shift + Enter <CornerDownLeft className="h-3 w-3 " />
</span>
for new line
</div>
<div className="py-3 ">
<div className="lg:hidden">
<Button
type="submit"
className="w-full rounded-md px-6 py-3 font-medium "
>
Answer
</Button>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className={cn("py-3", showPrevQuestionButton && "hidden")}>
<Button
type="button"
variant="ghost"
className={cn("rounded-full", montserrat.className)}
size="sm"
onClick={goToPrevQuestion}
disabled={isFormBusy}
>
<ChevronLeft className="mr-2" size={20} />
<span>Back to Previous</span>
</Button>
</div>
<div className="flex min-h-full w-full flex-col items-center justify-center px-3 ">
<h1 className="mb-10 w-full text-4xl font-medium ">
<span>
{currentQuestion}
{isFormBusy && <Tally1 className="ml-2 inline animate-ping" />}
</span>
</h1>

{!isFormBusy && (
<div className="w-full">
<FormField
control={form.control}
name="answer"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
autoFocus
className="w-full rounded-none border-0 border-b bg-transparent text-xl focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0"
placeholder="Type answer here..."
disabled={isFormBusy}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
form.handleSubmit(onSubmit)();
}
}}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="text-muted-foreground flex items-center justify-end pt-1 text-sm font-light max-lg:hidden">
Press{" "}
<span className="mx-1 flex items-center font-bold">
Shift + Enter <CornerDownLeft className="h-3 w-3 " />
</span>{" "}
for new line
</div>
<div className="py-3 ">
<div className="lg:hidden">
<Button
type="submit"
className="w-full rounded-md px-6 py-3 font-medium "
>
Answer
</Button>
</div>
</div>
</div>
</div>
)}
</div>
</form>
)}
</div>
</form>
</Form>
);
};
72 changes: 27 additions & 45 deletions apps/web/src/components/formViewer/formViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { ChangeEvent, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { Form } from "@convoform/db";
import { showErrorResponseToast } from "@convoform/ui/components/ui/use-toast";
import { useChat } from "ai/react";
Expand All @@ -9,6 +9,7 @@ import { CONVERSATION_START_MESSAGE } from "@/lib/constants";
import { isRateLimitErrorResponse } from "@/lib/errorHandlers";
import { EndScreen } from "./endScreen";
import { FormFieldsViewer } from "./formFields";
import { getCurrentQuestion, isFirstQuestion } from "./utils";
import { WelcomeScreen } from "./welcomeScreen";

type Props = {
Expand All @@ -24,7 +25,7 @@ type State = {

export type FormStage = "welcomeScreen" | "conversationFlow" | "endScreen";

export function FormViewer({ form, refresh, isPreview }: Props) {
export function FormViewer({ form, refresh, isPreview }: Readonly<Props>) {
const apiEndpoint = `/api/form/${form.id}/conversation`;

const { showCustomEndScreenMessage, customEndScreenMessage } = form;
Expand All @@ -36,16 +37,7 @@ export function FormViewer({ form, refresh, isPreview }: Props) {

const { formStage: currentStage, endScreenMessage } = state;

const {
messages,
input,
handleInputChange,
handleSubmit,
append,
data,
setMessages,
isLoading,
} = useChat({
const { messages, append, data, setMessages, isLoading, setInput } = useChat({
api: apiEndpoint,
body: { isPreview },
onError(error) {
Expand All @@ -63,26 +55,16 @@ export function FormViewer({ form, refresh, isPreview }: Props) {
},
});

const isFirstQuestion =
messages.filter((message) => message.role === "assistant").length <= 1;
const currentQuestion = getCurrentQuestion(messages) ?? "";
const showPrevQuestionButton = isFirstQuestion(messages);

const getCurrentQuestion = () => {
const lastMessage = messages[messages.length - 1];
const currentQuestion =
lastMessage?.role === "assistant" && lastMessage.content;
if (currentQuestion && currentQuestion !== "") {
return currentQuestion;
}
return "";
};

const currentQuestion = getCurrentQuestion();

const handleFormSubmit = (event: any) => {
event.preventDefault();
const submitAnswer = async (answer: string) => {
if (!isLoading) {
setState((s) => ({ ...s, isFormBusy: true }));
handleSubmit(event);
await append({
content: answer,
role: "user",
});
}
};

Expand All @@ -99,31 +81,33 @@ export function FormViewer({ form, refresh, isPreview }: Props) {
gotoStage("conversationFlow");
};

const handleShowPreviousQuestion = () => {
// This will remove the current question and previous answer from the messages list of useChat hook
// And return the previous answer string
const handleGoToPrevQuestion = (): string => {
// Remove previous question message from messages list
messages.pop();
// Remove previous answer message from messages list
const previousAnswerMessage = messages.pop();
const previousAnswerString = previousAnswerMessage?.content ?? "";
// Update messages list in useChat hook
setMessages(messages);
// Set previous answer in text input
const event = {
target: {
value: previousAnswerMessage?.content || "",
},
} as ChangeEvent<HTMLTextAreaElement>;
handleInputChange(event);
// Calling this for only rerendering the current question,
// because setMessages(messages) will not rerender the current question
setInput(previousAnswerString);
return previousAnswerString;
};

useEffect(() => {
if (data?.includes("conversationFinished")) {
const currentEndScreenMessage =
const isConversationFinished = data?.includes("conversationFinished");
if (isConversationFinished) {
const endScreenMessage =
showCustomEndScreenMessage && customEndScreenMessage
? customEndScreenMessage
: currentQuestion;

setState((cs) => ({
...cs,
endScreenMessage: currentEndScreenMessage,
endScreenMessage,
formStage: "endScreen",
}));
}
Expand All @@ -143,12 +127,10 @@ export function FormViewer({ form, refresh, isPreview }: Props) {
{currentStage === "conversationFlow" && (
<FormFieldsViewer
currentQuestion={currentQuestion}
handleFormSubmit={handleFormSubmit}
handleInputChange={handleInputChange}
input={input}
isFormBusy={isLoading}
handleShowPreviousQuestion={handleShowPreviousQuestion}
isFirstQuestion={isFirstQuestion}
handleGoToPrevQuestion={handleGoToPrevQuestion}
showPrevQuestionButton={showPrevQuestionButton}
submitAnswer={submitAnswer}
/>
)}
{currentStage === "endScreen" && (
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/components/formViewer/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Message } from "ai";

/**
* Extract last assistant message from messages
* @param messages
* @returns
*/
export const getCurrentQuestion = (messages: Message[]): string | undefined => {
const lastChatMessage = messages[messages.length - 1];
return lastChatMessage?.role === "assistant"
? lastChatMessage.content
: undefined;
};

/**
* Check if there is only one assistant message in the messages
* @param messages
* @returns
*/
export const isFirstQuestion = (messages: Message[]): boolean => {
let questionsCount = 0;
for (const element of messages) {
const message = element;
const isQuestion = message?.role === "assistant";
if (isQuestion) {
questionsCount++;
}
if (questionsCount > 1) {
return false;
}
}
return true;
};

0 comments on commit ad3acec

Please sign in to comment.