feat: add new responses view in results; add csv download feature for responses

This commit is contained in:
Matthias Nannt
2022-06-14 21:36:25 +09:00
parent 8f0c2ce642
commit bae1aff064
16 changed files with 581 additions and 127 deletions
+79
View File
@@ -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>
</>
);
}
+142
View File
@@ -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>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function ResultsDashboard({}) {
return <p>Dashboard</p>;
}
+159
View File
@@ -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>
);
}
-74
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}