mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 05:17:49 -05:00
feat: add new responses view in results; add csv download feature for responses
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import Head from "next/head";
|
||||
import MenuBreadcrumbs from "./MenuBreadcrumbs";
|
||||
import MenuSteps from "./MenuSteps";
|
||||
import MenuProfile from "./MenuProfile";
|
||||
import { classNames } from "../../lib/utils";
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import Loading from "../Loading";
|
||||
|
||||
export default function LayoutFormResults({
|
||||
title,
|
||||
formId,
|
||||
resultMode,
|
||||
setResultMode,
|
||||
currentStep,
|
||||
children,
|
||||
}) {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === "loading") {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
signIn();
|
||||
return <div>You need to be authenticated to view this page.</div>;
|
||||
}
|
||||
|
||||
const resultModes = [
|
||||
{ name: "Dashboard", id: "dashboard" },
|
||||
{ name: "Responses", id: "responses" },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
<div className="flex min-h-screen overflow-hidden bg-gray-50">
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<header className="w-full">
|
||||
<div className="relative z-10 flex flex-shrink-0 h-16 bg-white border-b border-gray-200">
|
||||
<div className="flex flex-1 px-4 sm:px-6">
|
||||
<MenuBreadcrumbs formId={formId} />
|
||||
<MenuSteps formId={formId} currentStep={currentStep} />
|
||||
<div className="flex items-center justify-end flex-1 space-x-2 text-right sm:space-x-4">
|
||||
{/* Profile dropdown */}
|
||||
<MenuProfile />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-shrink-0 h-16 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-center flex-1 px-4">
|
||||
<nav className="flex space-x-4" aria-label="resultModes">
|
||||
{resultModes.map((mode) => (
|
||||
<button
|
||||
key={mode.name}
|
||||
onClick={() => setResultMode(mode.id)}
|
||||
className={classNames(
|
||||
mode.id === resultMode
|
||||
? "bg-gray-200 text-gray-800"
|
||||
: "text-gray-600 hover:text-gray-800",
|
||||
"px-3 py-2 font-medium text-sm rounded-md"
|
||||
)}
|
||||
aria-current={mode.id === resultMode ? "page" : undefined}
|
||||
>
|
||||
{mode.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { ChevronDownIcon } from "@heroicons/react/solid";
|
||||
import { parseAsync } from "json2csv";
|
||||
import { Fragment } from "react";
|
||||
import { useForm } from "../../lib/forms";
|
||||
import {
|
||||
getSubmission,
|
||||
useSubmissionSessions,
|
||||
} from "../../lib/submissionSessions";
|
||||
import { Submission } from "../../lib/types";
|
||||
import { slugify } from "../../lib/utils";
|
||||
import Loading from "../Loading";
|
||||
|
||||
export default function DownloadResponses({ formId }) {
|
||||
const { submissionSessions, isLoadingSubmissionSessions } =
|
||||
useSubmissionSessions(formId);
|
||||
const { form, isLoadingForm } = useForm(formId);
|
||||
|
||||
const download = async (format: "csv" | "excel") => {
|
||||
// build dict of answers in copy of answerSessions
|
||||
const submissions: Submission[] = submissionSessions.map((s) =>
|
||||
getSubmission(s, form.schema)
|
||||
);
|
||||
// build data fields for csv/excel file
|
||||
const data = [];
|
||||
for (const submission of submissions) {
|
||||
const dataEntry = { createdAt: submission.createdAt };
|
||||
for (const page of submission.pages) {
|
||||
if (page.elements) {
|
||||
for (const element of page.elements) {
|
||||
if (element.type !== "submit") {
|
||||
dataEntry[element.label] = element.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data.push(dataEntry);
|
||||
}
|
||||
|
||||
// get fields
|
||||
const fields: any = [
|
||||
{
|
||||
label: "Timestamp",
|
||||
value: "createdAt",
|
||||
},
|
||||
];
|
||||
|
||||
for (const page of submissions[0].pages) {
|
||||
for (const element of page.elements) {
|
||||
if (element.type !== "submit") {
|
||||
fields.push({
|
||||
label: element.label,
|
||||
value: element.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const opts: any = { fields };
|
||||
|
||||
if (format === "excel") {
|
||||
opts.excelStrings = true;
|
||||
}
|
||||
const fileTypes = {
|
||||
csv: { mimeType: "text/csv", fileExtension: "csv" },
|
||||
excel: { mimeType: "application/vnd.ms-excel", fileExtension: "csv" },
|
||||
};
|
||||
|
||||
try {
|
||||
const csv = await parseAsync(data, opts);
|
||||
// download
|
||||
var blob = new Blob([csv], { type: fileTypes[format].mimeType });
|
||||
const url = window.URL.createObjectURL(new Blob([blob]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`${slugify(form.name)}.${fileTypes[format].fileExtension}`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode.removeChild(link);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingSubmissionSessions || isLoadingForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative z-10 inline-block w-full text-left">
|
||||
<div>
|
||||
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium text-white bg-gray-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75">
|
||||
Download
|
||||
<ChevronDownIcon
|
||||
className="w-5 h-5 ml-2 -mr-1 text-white hover:text-gray-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white divide-y divide-gray-100 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="px-1 py-1 ">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => download("csv")}
|
||||
className={`${
|
||||
active ? "bg-red-500 text-white" : "text-gray-900"
|
||||
} group flex rounded-md items-center w-full px-2 py-2 text-sm`}
|
||||
>
|
||||
Download as CSV
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
{/* <Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => download("excel")}
|
||||
className={`${
|
||||
active ? "bg-red-500 text-white" : "text-gray-900"
|
||||
} group flex rounded-md items-center w-full px-2 py-2 text-sm`}
|
||||
>
|
||||
Download as Excel
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item> */}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function ResultsDashboard({}) {
|
||||
return <p>Dashboard</p>;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import { CheckIcon } from "@heroicons/react/solid";
|
||||
import { getEventName } from "../../lib/events";
|
||||
import { useSubmissionSessions } from "../../lib/submissionSessions";
|
||||
import { SubmissionSession } from "../../lib/types";
|
||||
import { convertDateTimeString, convertTimeString } from "../../lib/utils";
|
||||
import SubmissionDisplay from "./SubmissionDisplay";
|
||||
import DownloadResponses from "./DownloadResponses";
|
||||
import Loading from "../Loading";
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type ResultsResponseProps = {
|
||||
formId: string;
|
||||
};
|
||||
|
||||
export default function ResultsResponses({ formId }: ResultsResponseProps) {
|
||||
const { submissionSessions, isLoadingSubmissionSessions } =
|
||||
useSubmissionSessions(formId);
|
||||
const [activeSubmissionSession, setActiveSubmissionSession] =
|
||||
useState<SubmissionSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoadingSubmissionSessions && submissionSessions.length > 0) {
|
||||
setActiveSubmissionSession(submissionSessions[0]);
|
||||
}
|
||||
}, [isLoadingSubmissionSessions]);
|
||||
|
||||
if (isLoadingSubmissionSessions) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full mx-auto overflow-visible max-w-screen">
|
||||
<div className="relative z-0 flex flex-1 overflow-visible">
|
||||
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none xl:order-last">
|
||||
<div className="overflow-visible bg-white shadow sm:rounded-lg">
|
||||
{activeSubmissionSession && (
|
||||
<div className="px-4 py-5 sm:p-12">
|
||||
<div className="grid grid-cols-2 gap-8 divide-x">
|
||||
<div className="flow-root">
|
||||
<h1 className="mb-8 text-gray-700">
|
||||
{convertDateTimeString(activeSubmissionSession.createdAt)}
|
||||
</h1>
|
||||
<SubmissionDisplay
|
||||
key={activeSubmissionSession.id}
|
||||
submissionSession={activeSubmissionSession}
|
||||
formId={formId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flow-root pl-10">
|
||||
<h1 className="mb-8 text-gray-700">Session Activity</h1>
|
||||
<ul role="list" className="-mb-8">
|
||||
{activeSubmissionSession.events.map((event, eventIdx) => (
|
||||
<li key={event.id}>
|
||||
<div className="relative pb-8">
|
||||
{eventIdx !==
|
||||
activeSubmissionSession.events.length - 1 ? (
|
||||
<span
|
||||
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex space-x-3">
|
||||
<div>
|
||||
<span
|
||||
className={classNames(
|
||||
"bg-red-200",
|
||||
"h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white"
|
||||
)}
|
||||
>
|
||||
<CheckIcon
|
||||
className="w-5 h-5 text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-1.5 flex justify-between space-x-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{getEventName(event.type)}
|
||||
{/* <span className="font-medium text-gray-900">
|
||||
{event.data.pageName || ""}
|
||||
</span> */}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-right text-gray-500 whitespace-nowrap">
|
||||
<time dateTime={event.createdAt}>
|
||||
{convertTimeString(event.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<aside className="flex-shrink-0 hidden border-r border-gray-200 xl:order-first xl:flex xl:flex-col w-96">
|
||||
<DownloadResponses formId={formId} />
|
||||
<div className="pt-6 pb-1">
|
||||
<h2 className="px-5 text-lg font-medium text-gray-900">
|
||||
Responses
|
||||
</h2>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={activeSubmissionSession}
|
||||
onChange={setActiveSubmissionSession}
|
||||
className="flex-1 min-h-0 overflow-y-auto"
|
||||
>
|
||||
<div className="relative">
|
||||
<ul className="relative z-0 divide-y divide-gray-200">
|
||||
{submissionSessions.map((submissionSession) => (
|
||||
<RadioGroup.Option
|
||||
key={submissionSession.id}
|
||||
value={submissionSession}
|
||||
className={({ checked }) =>
|
||||
classNames(
|
||||
checked ? "bg-gray-100" : "",
|
||||
"relative flex items-center px-6 py-5 space-x-3 "
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() =>
|
||||
setActiveSubmissionSession(submissionSession)
|
||||
}
|
||||
className="w-full text-left focus:outline-none"
|
||||
>
|
||||
{/* Extend touch target to entire panel */}
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{convertDateTimeString(submissionSession.createdAt)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{submissionSession.events.length} events
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useForm } from "../../lib/forms";
|
||||
import { convertDateTimeString } from "../../lib/utils";
|
||||
import Loading from "../Loading";
|
||||
|
||||
export default function Submission({ formId, submissionSession }) {
|
||||
const { form, isLoadingForm } = useForm(formId);
|
||||
|
||||
// fill the schema with the values provided by the user
|
||||
const getSubmission = (submissionSession, schema) => {
|
||||
if (!schema) return {};
|
||||
const submission = JSON.parse(JSON.stringify(schema));
|
||||
submission.id = submissionSession.id;
|
||||
submission.createdAt = submissionSession.createdAt;
|
||||
if (submissionSession.events.length > 0) {
|
||||
for (const page of submission.pages) {
|
||||
if (page.type === "form") {
|
||||
const pageSubmission = submissionSession.events.find(
|
||||
(s) => s.type === "pageSubmission" && s.data?.pageName === page.name
|
||||
);
|
||||
if (typeof pageSubmission !== "undefined") {
|
||||
for (const element of page.elements) {
|
||||
if (element.type !== "submit") {
|
||||
if (element.name in pageSubmission.data?.submission) {
|
||||
element.value = pageSubmission.data.submission[element.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return submission;
|
||||
};
|
||||
|
||||
const submission = useMemo(() => {
|
||||
if (form && submissionSession) {
|
||||
return getSubmission(submissionSession, form.schema);
|
||||
}
|
||||
}, [form, submissionSession]);
|
||||
|
||||
if (isLoadingForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow sm:rounded-lg max-w-">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="text-gray-600">
|
||||
<p className="text-sm">
|
||||
{convertDateTimeString(submission.createdAt)}
|
||||
</p>
|
||||
{submission.pages.map((page) => (
|
||||
<div key={page.name}>
|
||||
{page.elements?.map(
|
||||
(element) =>
|
||||
element.type !== "submit" && (
|
||||
<div key={element.name}>
|
||||
<p className="font-semibold text-red-600">
|
||||
{element.label}
|
||||
</p>
|
||||
<p className="font-normal">
|
||||
{element.value || "[not provided]"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from "react";
|
||||
import { useForm } from "../../lib/forms";
|
||||
import { getSubmission } from "../../lib/submissionSessions";
|
||||
import { Submission } from "../../lib/types";
|
||||
import { classNames } from "../../lib/utils";
|
||||
import Loading from "../Loading";
|
||||
|
||||
export default function SubmissionDisplay({ formId, submissionSession }) {
|
||||
const { form, isLoadingForm } = useForm(formId);
|
||||
|
||||
const submission: Submission = useMemo(() => {
|
||||
if (form && submissionSession) {
|
||||
return getSubmission(submissionSession, form.schema);
|
||||
}
|
||||
}, [form, submissionSession]);
|
||||
|
||||
if (isLoadingForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flow-root mt-6">
|
||||
<ul role="list" className="-my-5 divide-y divide-gray-200">
|
||||
{submission.pages.map((page) =>
|
||||
page.elements?.map(
|
||||
(element) =>
|
||||
element.type !== "submit" && (
|
||||
<li key={element.name} className="py-5">
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{element.label}
|
||||
</p>
|
||||
|
||||
<p
|
||||
className={classNames(
|
||||
element.value ? "text-gray-600" : "text-gray-400",
|
||||
"mt-1 text-sm text-gray-600 line-clamp-2"
|
||||
)}
|
||||
>
|
||||
{element.value || "[not provided]"}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user