Files
formbricks/packages/ui/SingleResponseCard/index.tsx
T
Anshuman Pandey 10aed2d9d8 fix: UI fixes (#2653)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-05-20 09:11:18 +00:00

503 lines
19 KiB
TypeScript

"use client";
import clsx from "clsx";
import { CheckCircle2Icon, LanguagesIcon, MailIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ReactNode, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { timeSince } from "@formbricks/lib/time";
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyMatrixQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyQuestionType,
} from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
import { getLanguageLabel } from "../../ee/multiLanguage/lib/isoLanguages";
import { AddressResponse } from "../AddressResponse";
import { PersonAvatar } from "../Avatars";
import { DeleteDialog } from "../DeleteDialog";
import { FileUploadResponse } from "../FileUploadResponse";
import { PictureSelectionResponse } from "../PictureSelectionResponse";
import { RatingResponse } from "../RatingResponse";
import { SurveyStatusIndicator } from "../SurveyStatusIndicator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../Tooltip";
import { deleteResponseAction, getResponseAction } from "./actions";
import { QuestionSkip } from "./components/QuestionSkip";
import { ResponseNotes } from "./components/ResponseNote";
import { ResponseTagsWrapper } from "./components/ResponseTagsWrapper";
const isSubmissionTimeMoreThan5Minutes = (submissionTimeISOString: Date) => {
const submissionTime: Date = new Date(submissionTimeISOString);
const currentTime: Date = new Date();
const timeDifference: number = (currentTime.getTime() - submissionTime.getTime()) / (1000 * 60); // Convert milliseconds to minutes
return timeDifference > 5;
};
export interface SingleResponseCardProps {
survey: TSurvey;
response: TResponse;
user?: TUser;
pageType: "people" | "response";
environmentTags: TTag[];
environment: TEnvironment;
updateResponse?: (responseId: string, responses: TResponse) => void;
deleteResponse?: (responseId: string) => void;
isViewer: boolean;
}
interface TooltipRendererProps {
shouldRender: boolean;
tooltipContent: ReactNode;
children: ReactNode;
}
const TooltipRenderer = (props: TooltipRendererProps) => {
const { children, shouldRender, tooltipContent } = props;
if (shouldRender) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <>{children}</>;
};
const DateResponse = ({ date }: { date?: string }) => {
if (!date) return null;
const formattedDateString = formatDateWithOrdinal(new Date(date));
return <p className="ph-no-capture my-1 font-semibold text-slate-700">{formattedDateString}</p>;
};
export const SingleResponseCard = ({
survey,
response,
user,
pageType,
environmentTags,
environment,
updateResponse,
deleteResponse,
isViewer,
}: SingleResponseCardProps) => {
const environmentId = survey.environmentId;
const router = useRouter();
const displayIdentifier = response.person
? getPersonIdentifier(response.person, response.personAttributes)
: null;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const canResponseBeDeleted = response.finished
? true
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
let skippedQuestions: string[][] = [];
let temp: string[] = [];
const isFirstQuestionAnswered = response.data[survey.questions[0].id] ? true : false;
const isValidValue = (value: any) => {
return (
(typeof value === "string" && value.trim() !== "") ||
(Array.isArray(value) && value.length > 0) ||
typeof value === "number" ||
(typeof value === "object" && Object.entries(value).length > 0)
);
};
if (response.finished) {
survey.questions.forEach((question) => {
if (!isValidValue(response.data[question.id])) {
temp.push(question.id);
} else {
if (temp.length > 0) {
skippedQuestions.push([...temp]);
temp = [];
}
}
});
} else {
for (let index = survey.questions.length - 1; index >= 0; index--) {
const question = survey.questions[index];
if (!response.data[question.id]) {
if (skippedQuestions.length === 0) {
temp.push(question.id);
} else if (skippedQuestions.length > 0 && !isValidValue(response.data[question.id])) {
temp.push(question.id);
}
} else {
if (temp.length > 0) {
temp.reverse();
skippedQuestions.push([...temp]);
temp = [];
}
}
}
}
// Handle the case where the last entries are empty
if (temp.length > 0) {
skippedQuestions.push(temp);
}
const handleArray = (data: string | number | string[]): string => {
if (Array.isArray(data)) {
return data.join(", ");
} else {
return String(data);
}
};
const handleDeleteResponse = async () => {
setIsDeleting(true);
try {
if (isViewer) {
throw new Error("You are not authorized to perform this action.");
}
await deleteResponseAction(response.id);
deleteResponse?.(response.id);
router.refresh();
toast.success("Response deleted successfully.");
setDeleteDialogOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message);
} finally {
setIsDeleting(false);
}
};
const renderTooltip = Boolean(
(response.personAttributes && Object.keys(response.personAttributes).length > 0) ||
(response.meta.userAgent && Object.keys(response.meta.userAgent).length > 0)
);
const tooltipContent = (
<>
{response.singleUseId && (
<div>
<p className="py-1 font-bold text-slate-700">SingleUse ID:</p>
<span>{response.singleUseId}</span>
</div>
)}
{response.personAttributes && Object.keys(response.personAttributes).length > 0 && (
<div>
<p className="py-1 font-bold text-slate-700">Person attributes:</p>
{Object.keys(response.personAttributes).map((key) => (
<p key={key}>
{key}:{" "}
<span className="font-bold">{response.personAttributes && response.personAttributes[key]}</span>
</p>
))}
</div>
)}
{response.meta.userAgent && Object.keys(response.meta.userAgent).length > 0 && (
<div className="text-slate-600">
{response.personAttributes && Object.keys(response.personAttributes).length > 0 && (
<hr className="my-2 border-slate-200" />
)}
<p className="py-1 font-bold text-slate-700">Device info:</p>
{response.meta.userAgent?.browser && <p>Browser: {response.meta.userAgent.browser}</p>}
{response.meta.userAgent?.os && <p>OS: {response.meta.userAgent.os}</p>}
{response.meta.userAgent && (
<p>
Device:{" "}
{response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}
</p>
)}
{response.meta.url && <p>URL: {response.meta.url}</p>}
{response.meta.action && <p>Action: {response.meta.action}</p>}
{response.meta.source && <p>Source: {response.meta.source}</p>}
{response.meta.country && <p>Country: {response.meta.country}</p>}
</div>
)}
</>
);
const deleteSubmissionToolTip = <>This response is in progress.</>;
const hasHiddenFieldsEnabled = survey.hiddenFields?.enabled;
const fieldIds = survey.hiddenFields?.fieldIds || [];
const hasFieldIds = !!fieldIds.length;
const updateFetchedResponses = async () => {
const updatedResponse = await getResponseAction(response.id);
if (updatedResponse !== null && updateResponse) {
updateResponse(response.id, updatedResponse);
}
};
const renderResponse = (
questionType: TSurveyQuestionType,
responseData: string | number | string[] | Record<string, string>,
question: TSurveyQuestion
) => {
switch (questionType) {
case TSurveyQuestionType.Rating:
if (typeof responseData === "number")
return <RatingResponse scale={question.scale} answer={responseData} range={question.range} />;
case TSurveyQuestionType.Date:
if (typeof responseData === "string") return <DateResponse date={responseData} />;
case TSurveyQuestionType.Cal:
if (typeof responseData === "string")
return <p className="ph-no-capture my-1 font-semibold capitalize text-slate-700">{responseData}</p>;
case TSurveyQuestionType.PictureSelection:
if (Array.isArray(responseData))
return (
<PictureSelectionResponse
choices={(question as TSurveyPictureSelectionQuestion).choices}
selected={responseData}
/>
);
case TSurveyQuestionType.FileUpload:
if (Array.isArray(responseData)) return <FileUploadResponse selected={responseData} />;
case TSurveyQuestionType.Matrix:
if (typeof responseData === "object" && !Array.isArray(responseData)) {
return (question as TSurveyMatrixQuestion).rows.map((row) => {
const languagCode = getLanguageCode(survey.languages, response.language);
const rowValueInSelectedLanguage = getLocalizedValue(row, languagCode);
if (!responseData[rowValueInSelectedLanguage]) return;
return (
<p className="ph-no-capture my-1 font-semibold capitalize text-slate-700">
{rowValueInSelectedLanguage}: {responseData[rowValueInSelectedLanguage]}
</p>
);
});
}
case TSurveyQuestionType.Address:
if (Array.isArray(responseData)) {
return <AddressResponse value={responseData} />;
}
default:
if (
typeof responseData === "string" ||
typeof responseData === "number" ||
Array.isArray(responseData)
)
return (
<p className="ph-no-capture my-1 whitespace-pre-line font-semibold text-slate-700">
{Array.isArray(responseData) ? handleArray(responseData) : responseData}
</p>
);
}
};
return (
<div className={clsx("group relative", isOpen && "min-h-[300px]")}>
<div
className={clsx(
"relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all",
pageType === "response" &&
(isOpen
? "w-3/4"
: user && response.notes.length
? "w-[96.5%]"
: cn("w-full", user ? "group-hover:w-[96.5%]" : ""))
)}>
<div className="space-y-2 border-b border-slate-200 px-6 pb-4 pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center justify-center space-x-4">
{pageType === "response" && (
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<div className="group">
{response.person?.id ? (
user ? (
<Link
className="flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<PersonAvatar personId={response.person.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
</Link>
) : (
<div className="flex items-center">
<PersonAvatar personId={response.person.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600">
{displayIdentifier}
</h3>
</div>
)
) : (
<div className="flex items-center">
<PersonAvatar personId="anonymous" />
<h3 className="ml-4 pb-1 font-semibold text-slate-600">Anonymous</h3>
</div>
)}
</div>
</TooltipRenderer>
)}
{pageType === "people" && (
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-100 p-1 px-2 text-sm text-slate-600">
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<Link
className="hover:underline"
href={`/environments/${environmentId}/surveys/${survey.id}/summary`}>
{survey.name}
</Link>
</div>
)}
{response.language && response.language !== "default" && (
<div className="flex space-x-2 rounded-full bg-slate-700 px-2 py-1 text-xs text-white">
<div>{getLanguageLabel(response.language)}</div>
<LanguagesIcon className="h-4 w-4" />
</div>
)}
</div>
<div className="flex items-center space-x-4 text-sm">
<time className="text-slate-500" dateTime={timeSince(response.updatedAt.toISOString())}>
{timeSince(response.updatedAt.toISOString())}
</time>
{user && !isViewer && (
<TooltipRenderer
shouldRender={!canResponseBeDeleted}
tooltipContent={deleteSubmissionToolTip}>
<TrashIcon
onClick={() => {
if (canResponseBeDeleted) {
setDeleteDialogOpen(true);
}
}}
className={`h-4 w-4 ${
canResponseBeDeleted
? "cursor-pointer text-slate-500 hover:text-red-700"
: "cursor-not-allowed text-slate-400"
} `}
/>
</TooltipRenderer>
)}
</div>
</div>
</div>
<div className="p-6">
{survey.welcomeCard.enabled && (
<QuestionSkip
skippedQuestions={[]}
questions={survey.questions}
status={"welcomeCard"}
isFirstQuestionAnswered={isFirstQuestionAnswered}
/>
)}
<div className="space-y-6">
{survey.verifyEmail && response.data["verifiedEmail"] && (
<div>
<p className="flex items-center space-x-2 text-sm text-slate-500">
<MailIcon className="h-4 w-4" />
<span>Verified Email</span>
</p>
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{typeof response.data["verifiedEmail"] === "string" ? response.data["verifiedEmail"] : ""}
</p>
</div>
)}
{survey.questions.map((question) => {
const skipped = skippedQuestions.find((skippedQuestionElement) =>
skippedQuestionElement.includes(question.id)
);
// If found, remove it from the list
if (skipped) {
skippedQuestions = skippedQuestions.filter((item) => item !== skipped);
}
return (
<div key={`${question.id}`}>
{isValidValue(response.data[question.id]) ? (
<div>
<p className="text-sm text-slate-500">
{getLocalizedValue(question.headline, "default")}
</p>
{renderResponse(question.type, response.data[question.id], question)}
</div>
) : (
<QuestionSkip
skippedQuestions={skipped}
questions={survey.questions}
status={
response.finished ||
(skippedQuestions.length > 0 &&
!skippedQuestions[skippedQuestions.length - 1].includes(question.id))
? "skipped"
: "aborted"
}
/>
)}
</div>
);
})}
</div>
{hasHiddenFieldsEnabled && hasFieldIds && (
<div className="mt-6 flex flex-col gap-6">
{fieldIds.map((field) => {
return (
<div key={field}>
<p className="text-sm text-slate-500">Hidden Field: {field}</p>
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{typeof response.data[field] === "string" ? (response.data[field] as string) : ""}
</p>
</div>
);
})}
</div>
)}
{response.finished && (
<div className="mt-4 flex items-center">
<CheckCircle2Icon className="h-6 w-6 text-slate-400" />
<p className="mx-2 rounded-lg bg-slate-100 px-2 text-sm font-medium text-slate-700">
Completed
</p>
</div>
)}
</div>
<ResponseTagsWrapper
environmentId={environmentId}
responseId={response.id}
tags={response.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
environmentTags={environmentTags}
updateFetchedResponses={updateFetchedResponses}
isViewer={isViewer}
/>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="response"
onDelete={handleDeleteResponse}
isDeleting={isDeleting}
/>
</div>
{user && pageType === "response" && (
<ResponseNotes
user={user}
responseId={response.id}
notes={response.notes}
isOpen={isOpen}
setIsOpen={setIsOpen}
updateFetchedResponses={updateFetchedResponses}
/>
)}
</div>
);
};