mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-22 11:39:35 -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const getEventName = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case "pageSubmission":
|
||||
return "Page Submission";
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
};
|
||||
@@ -14,3 +14,45 @@ export const useSubmissionSessions = (formId: string) => {
|
||||
mutateSubmissionSessions: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
// fill the schema with the values provided by the user
|
||||
export const getSubmission = (submissionSession, schema) => {
|
||||
if (!schema) return {};
|
||||
// create new submission
|
||||
const submission = {
|
||||
id: submissionSession.id,
|
||||
createdAt: submissionSession.createdAt,
|
||||
pages: [],
|
||||
};
|
||||
if (submissionSession.events.length > 0) {
|
||||
// iterate through schema pages to fill submission
|
||||
for (const page of schema.pages) {
|
||||
// new submission page
|
||||
const submissionPage = {
|
||||
name: page.name,
|
||||
type: page.type,
|
||||
elements: page.elements
|
||||
? JSON.parse(JSON.stringify(page.elements))
|
||||
: [],
|
||||
};
|
||||
// search for elements in schema pages of type "form" and fill their value into the submission
|
||||
if (page.type === "form") {
|
||||
const pageSubmission = submissionSession.events.find(
|
||||
(s) => s.type === "pageSubmission" && s.data?.pageName === page.name
|
||||
);
|
||||
if (typeof pageSubmission !== "undefined") {
|
||||
for (const [elementIdx, element] of page.elements.entries()) {
|
||||
if (element.type !== "submit") {
|
||||
if (element.name in pageSubmission.data?.submission) {
|
||||
submissionPage.elements[elementIdx].value =
|
||||
pageSubmission.data.submission[element.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
submission.pages.push(submissionPage);
|
||||
}
|
||||
}
|
||||
return submission;
|
||||
};
|
||||
|
||||
+38
-3
@@ -52,7 +52,10 @@ export type SchemaOption = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type pageSubmissionData = {
|
||||
export type pageSubmissionEvent = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
type: "pageSubmission";
|
||||
data: {
|
||||
submissionSessionId: string;
|
||||
@@ -62,15 +65,47 @@ export type pageSubmissionData = {
|
||||
};
|
||||
|
||||
export type submissionCompletedEvent = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
type: "submissionCompleted";
|
||||
data: { [key: string]: string };
|
||||
};
|
||||
|
||||
export type updateSchemaEvent = { type: "updateSchema"; data: Schema };
|
||||
export type updateSchemaEvent = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
type: "updateSchema";
|
||||
data: Schema;
|
||||
};
|
||||
|
||||
export type ApiEvent =
|
||||
| pageSubmissionData
|
||||
| pageSubmissionEvent
|
||||
| submissionCompletedEvent
|
||||
| updateSchemaEvent;
|
||||
|
||||
export type WebhookEvent = Event & { formId: string; timestamp: string };
|
||||
|
||||
export type SubmissionSession = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
form?: any;
|
||||
userFingerprint: string;
|
||||
events: ApiEvent[];
|
||||
};
|
||||
|
||||
export type Submission = {
|
||||
id?: string;
|
||||
createdAt?: string;
|
||||
pages?: SubmissionPage[];
|
||||
};
|
||||
|
||||
type SubmissionPage = {
|
||||
name: string;
|
||||
type: string;
|
||||
elements: SubmissionPageElement[];
|
||||
};
|
||||
|
||||
type SubmissionPageElement = SchemaElement & { value: string };
|
||||
|
||||
+16
-1
@@ -62,7 +62,7 @@ export const convertDateString = (dateString: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const convertDateTimeString = (dateString) => {
|
||||
export const convertDateTimeString = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
@@ -79,3 +79,18 @@ export const convertDateTimeString = (dateString) => {
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const convertTimeString = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"babel-plugin-superjson-next": "^0.4.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"date-fns": "^2.28.0",
|
||||
"json2csv": "^5.0.7",
|
||||
"next": "12.1.6",
|
||||
"next-auth": "^4.3.4",
|
||||
"nextjs-cors": "^2.1.1",
|
||||
|
||||
@@ -24,7 +24,6 @@ export default async function handle(
|
||||
const { events } = req.body;
|
||||
const error = validateEvents(events);
|
||||
if (error) {
|
||||
console.log(JSON.stringify(error, null, 2));
|
||||
const { status, message } = error;
|
||||
return res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ export default async function handle(
|
||||
where: {
|
||||
form: { id: formId },
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
include: {
|
||||
events: true,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSubmissionSessions } from "../../../lib/submissionSessions";
|
||||
|
||||
export default function FormIndex() {
|
||||
const router = useRouter();
|
||||
console.log(router.query);
|
||||
const formId = router.query.id;
|
||||
const { submissionSessions, isLoadingSubmissionSessions } =
|
||||
useSubmissionSessions(formId?.toString());
|
||||
|
||||
@@ -1,48 +1,39 @@
|
||||
import { GetServerSideProps } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import LayoutFormBasics from "../../../components/layout/LayoutFormBasic";
|
||||
import { useState } from "react";
|
||||
import LayoutFormResults from "../../../components/layout/LayoutFormResults";
|
||||
import Loading from "../../../components/Loading";
|
||||
import Submission from "../../../components/results/Submission";
|
||||
import ResultsDashboard from "../../../components/results/ResultsDashboard";
|
||||
import ResultsResponses from "../../../components/results/ResultsResponses";
|
||||
import { useForm } from "../../../lib/forms";
|
||||
import { useSubmissionSessions } from "../../../lib/submissionSessions";
|
||||
|
||||
export default function Share() {
|
||||
const router = useRouter();
|
||||
const formId = router.query.id.toString();
|
||||
const { form, isLoadingForm } = useForm(router.query.id);
|
||||
const { submissionSessions, isLoadingSubmissionSessions } =
|
||||
useSubmissionSessions(form?.id);
|
||||
const [resultMode, setResultMode] = useState<string>("dashboard");
|
||||
|
||||
if (isLoadingForm || isLoadingSubmissionSessions) {
|
||||
if (isLoadingForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayoutFormBasics
|
||||
<LayoutFormResults
|
||||
title={form.title}
|
||||
formId={formId}
|
||||
currentStep="results"
|
||||
resultMode={resultMode}
|
||||
setResultMode={setResultMode}
|
||||
>
|
||||
<div className="bg-white shadow sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
Submissions for your form
|
||||
</h3>
|
||||
<p>Number of submissions: {submissionSessions.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-8 mt-8">
|
||||
{submissionSessions.map((submissionSession) => (
|
||||
<Submission
|
||||
key={submissionSession.id}
|
||||
submissionSession={submissionSession}
|
||||
formId={formId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</LayoutFormBasics>
|
||||
{resultMode === "dashboard" && <ResultsDashboard />}
|
||||
{resultMode === "responses" && (
|
||||
<>
|
||||
<ResultsResponses formId={formId} />
|
||||
</>
|
||||
)}
|
||||
</LayoutFormResults>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -603,6 +603,11 @@ color-name@^1.1.4, color-name@~1.1.4:
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
commander@^6.1.0:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
|
||||
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
|
||||
|
||||
concat-map@0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
@@ -1243,16 +1248,6 @@ has@^1.0.3:
|
||||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
|
||||
highlight.js@^10.5.0:
|
||||
version "10.7.3"
|
||||
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
|
||||
integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
|
||||
|
||||
highlight.js@^11.5.1:
|
||||
version "11.5.1"
|
||||
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.5.1.tgz#027c24e4509e2f4dcd00b4a6dda542ce0a1f7aea"
|
||||
integrity sha512-LKzHqnxr4CrD2YsNoIf/o5nJ09j4yi/GcH5BnYz9UnVpZdS4ucMgvP61TDty5xJcFGRjnH4DpujkS9bHT3hq0Q==
|
||||
|
||||
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||
@@ -1443,6 +1438,15 @@ json-stable-stringify-without-jsonify@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
|
||||
|
||||
json2csv@^5.0.7:
|
||||
version "5.0.7"
|
||||
resolved "https://registry.yarnpkg.com/json2csv/-/json2csv-5.0.7.tgz#f3a583c25abd9804be873e495d1e65ad8d1b54ae"
|
||||
integrity sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA==
|
||||
dependencies:
|
||||
commander "^6.1.0"
|
||||
jsonparse "^1.3.1"
|
||||
lodash.get "^4.4.2"
|
||||
|
||||
json5@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
|
||||
@@ -1450,6 +1454,11 @@ json5@^1.0.1:
|
||||
dependencies:
|
||||
minimist "^1.2.0"
|
||||
|
||||
jsonparse@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
|
||||
integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==
|
||||
|
||||
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.2.1:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.0.tgz#e624f259143b9062c92b6413ff92a164c80d3ccb"
|
||||
@@ -1501,6 +1510,11 @@ lodash.castarray@^4.4.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
|
||||
integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==
|
||||
|
||||
lodash.get@^4.4.2:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
|
||||
integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
|
||||
|
||||
lodash.isplainobject@^4.0.6:
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
|
||||
@@ -1917,11 +1931,6 @@ prisma@^3.15.1:
|
||||
dependencies:
|
||||
"@prisma/engines" "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e"
|
||||
|
||||
prismjs@^1.28.0:
|
||||
version "1.28.0"
|
||||
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.28.0.tgz#0d8f561fa0f7cf6ebca901747828b149147044b6"
|
||||
integrity sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw==
|
||||
|
||||
prop-types@^15.5.10, prop-types@^15.7.1, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
@@ -2000,13 +2009,6 @@ react-feather@^2.0.9:
|
||||
dependencies:
|
||||
prop-types "^15.7.2"
|
||||
|
||||
react-highlight@^0.14.0:
|
||||
version "0.14.0"
|
||||
resolved "https://registry.yarnpkg.com/react-highlight/-/react-highlight-0.14.0.tgz#5aefa5518baa580f96b68d48129d7a5d2dc0c9ef"
|
||||
integrity sha512-kWE+KXOXidS7SABhVopOgMnowbI3RAfeGZbnrduLNlWrYAED8sycL9l/Fvw3w0PFpIIawB7mRDnyhDcM/cIIGA==
|
||||
dependencies:
|
||||
highlight.js "^10.5.0"
|
||||
|
||||
react-icons@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.4.0.tgz#a13a8a20c254854e1ec9aecef28a95cdf24ef703"
|
||||
|
||||
Reference in New Issue
Block a user