Update custom form layout (#204)

* add new layout for custom survey view

* make filterNavigation work without schema
This commit is contained in:
Matti Nannt
2023-02-14 14:30:34 +01:00
committed by GitHub
parent 0d82a21b8f
commit 5dc5f968d1
22 changed files with 635 additions and 701 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ const WaitlistPage = () => {
formId={ formId={
process.env.NODE_ENV === "production" process.env.NODE_ENV === "production"
? "cld37mt2i0000ld08p9q572bc" ? "cld37mt2i0000ld08p9q572bc"
: "cldonm4ra000019axa4oc440z" : "cldufl8uh000019mzr7fdotyu"
} }
onPageSubmit={({ page }) => plausible(`waitlistSubmitPage-${page.id}`)} onPageSubmit={({ page }) => plausible(`waitlistSubmitPage-${page.id}`)}
onFinished={() => plausible("waitlistFinished")} onFinished={() => plausible("waitlistFinished")}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Button } from "@formbricks/ui";
import React from "react"; import React from "react";
interface Props { interface Props {
children: React.ReactNode; children?: React.ReactNode;
onClick?: () => void; onClick?: () => void;
alertText: string; alertText: string;
hintText: string; hintText: string;
@@ -0,0 +1,65 @@
"use client";
import LoadingSpinner from "@/components/LoadingSpinner";
import TabNavigation from "@/components/TabNavigation";
import { useForm } from "@/lib/forms";
import {
ChartPieIcon,
InformationCircleIcon,
RectangleStackIcon,
ShareIcon,
} from "@heroicons/react/20/solid";
import { useRouter } from "next/router";
import { useState } from "react";
import PipelinesOverview from "../pipelines/PipelinesOverview";
import OverviewResults from "./OverviewResults";
import CustomResults from "./CustomResults";
import SetupInstructions from "./SetupInstructions";
const tabs = [
{ name: "Results", icon: RectangleStackIcon },
{ name: "Overview", icon: ChartPieIcon },
{ name: "Data Pipelines", icon: ShareIcon },
{ name: "Setup Instructions", icon: InformationCircleIcon },
];
export default function CustomPage() {
const router = useRouter();
const [currentTab, setCurrentTab] = useState("Results");
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.organisationId?.toString()
);
if (isLoadingForm) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorForm) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights.</div>;
}
return (
<div>
<main className="mx-auto px-4 sm:px-6 lg:px-8">
<div className="border-b border-gray-200 pt-8">
<h1 className="pb-6 text-4xl font-bold tracking-tight text-gray-900">{form.label}</h1>
<TabNavigation tabs={tabs} currentTab={currentTab} setCurrentTab={setCurrentTab} />
</div>
{currentTab === "Results" ? (
<CustomResults />
) : currentTab === "Overview" ? (
<OverviewResults />
) : currentTab === "Data Pipelines" ? (
<PipelinesOverview />
) : currentTab === "Setup Instructions" ? (
<SetupInstructions />
) : null}
</main>
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import EmptyPageFiller from "@/components/EmptyPageFiller";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useSubmissions } from "@/lib/submissions";
import { InboxIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/router";
import { useState } from "react";
import FilterNavigation from "../shared/FilterNavigation";
import { SubmissionCounter } from "../shared/SubmissionCounter";
import CustomTimeline from "./CustomTimeline";
export default function PMFResults() {
const router = useRouter();
const { submissions, isLoadingSubmissions, isErrorSubmissions } = useSubmissions(
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
const [filteredSubmissions, setFilteredSubmissions] = useState([]);
if (isLoadingSubmissions) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorSubmissions) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div>
<div>
<section aria-labelledby="filters" className="pt-6 pb-24">
<div className="grid grid-cols-1 gap-x-16 gap-y-10 lg:grid-cols-4">
<div>
<SubmissionCounter
numFilteredSubmissions={filteredSubmissions.length}
numTotalSubmissions={submissions.length}
/>
<FilterNavigation submissions={submissions} setFilteredSubmissions={setFilteredSubmissions} />
</div>
{/* Submission grid */}
<div className="max-w-3xl md:col-span-3">
{submissions.length === 0 ? (
<EmptyPageFiller
alertText="You haven't received any submissions yet."
hintText="Embed the PMF survey on your website to start gathering insights."
borderStyles="border-4 border-dotted border-red">
<InboxIcon className="stroke-thin mx-auto h-24 w-24 text-slate-300" />
</EmptyPageFiller>
) : (
<CustomTimeline submissions={filteredSubmissions} />
)}
</div>
</div>
</section>
</div>
</div>
);
}
@@ -0,0 +1,188 @@
import EmptyPageFiller from "@/components/EmptyPageFiller";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { MergeWithSchema, persistSubmission, useSubmissions } from "@/lib/submissions";
import { convertDateTimeString, parseUserAgent } from "@/lib/utils";
import { Button, CheckMarkIcon, ClockIcon } from "@formbricks/ui";
import { InboxIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "react-toastify";
export default function PMFTimeline({ submissions }) {
const router = useRouter();
const {
submissions: allSubmissions,
mutateSubmissions,
isLoadingSubmissions,
isErrorSubmissions,
} = useSubmissions(router.query.organisationId?.toString(), router.query.formId?.toString());
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.organisationId?.toString()
);
const toggleArchiveSubmission = (submission) => {
const updatedSubmission = JSON.parse(JSON.stringify(submission));
updatedSubmission.archived = !updatedSubmission.archived;
// save submission without customer
const submissionWoCustomer = { ...updatedSubmission };
delete submissionWoCustomer.customer;
persistSubmission(submissionWoCustomer, router.query.organisationId?.toString());
// update all submissions
const submissionIdx = allSubmissions.findIndex((s) => s.id === submission.id);
const updatedSubmissions = JSON.parse(JSON.stringify(allSubmissions));
updatedSubmissions[submissionIdx] = updatedSubmission;
mutateSubmissions(updatedSubmissions, false);
if (updatedSubmission.archived) {
toast.success("Submission archived");
} else {
toast.success("Submission restored");
}
};
if (isLoadingForm || isLoadingSubmissions) return <LoadingSpinner />;
if (isErrorForm || isErrorSubmissions) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div className="flow-root">
<ul role="list" className="-mb-8">
{submissions.length === 0 ? (
<EmptyPageFiller
alertText="There are no submission matching this filter."
hintText="Try changing the filter or wait for new submissions."
borderStyles="border-4 border-dotted border-red">
<InboxIcon className="stroke-thin mx-auto h-24 w-24 text-slate-300" />
</EmptyPageFiller>
) : (
<>
{submissions.map((submission, submissionIdx) => (
<li key={submission.id}>
<div className="relative pb-8">
{submissionIdx !== submissions.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={clsx(
"bg-white",
"flex h-8 w-8 items-center justify-center rounded-full ring ring-gray-50"
)}>
{submission.finished ? (
<CheckMarkIcon className="h-7 w-7" aria-hidden="true" />
) : (
<ClockIcon className="h-7 w-7" aria-hidden="true" />
)}
</span>
</div>
<div className="w-full overflow-hidden rounded-lg bg-white shadow">
<div className="px-4 py-5 sm:p-6">
<div className="flex w-full justify-between">
{submission.data.disappointment === "veryDisappointed" ? (
<span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
Very disappointed
</span>
) : submission.data.disappointment === "notDisappointed" ? (
<span className="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800">
Not disappointed
</span>
) : submission.data.disappointment === "somewhatDisappointed" ? (
<span className="inline-flex items-center rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800">
Somewhat disappointed
</span>
) : null}
<div className="text-sm text-gray-400">
<time dateTime={convertDateTimeString(submission.createdAt)}>
{convertDateTimeString(submission.createdAt)}
</time>
</div>
</div>
<div className="mt-3">
<ul className="whitespace-pre-wrap text-sm text-gray-500">
{Object.entries(MergeWithSchema(submission.data, form.schema)).map(
([key, value]) => (
<li key={key} className="py-5">
<p className="text-sm font-semibold text-gray-800">{key}</p>
<p
className={clsx(
value ? "text-gray-600" : "text-gray-400",
"whitespace-pre-line pt-1 text-sm text-gray-600"
)}>
{value.toString()}
</p>
</li>
)
)}
</ul>
</div>
</div>
<div className=" bg-gray-50 p-4 sm:p-6">
<div className="flex w-full justify-between gap-4">
<div>
<p className="text-sm font-thin text-gray-500">User</p>
{submission.customerEmail ? (
<Link
className="text-sm font-medium text-gray-700"
href={`${form.id.startsWith("demo") ? "/demo" : ""}/organisations/${
router.query.organisationId
}/customers/${submission.customerEmail}`}>
{submission.customerEmail}
</Link>
) : (
<p className="text-sm text-gray-500">Anonymous</p>
)}
</div>
<div>
<p className="text-sm font-thin text-gray-500">Device</p>
<p className="text-sm text-gray-500">
{parseUserAgent(submission.meta.userAgent)}
</p>
</div>
</div>
<div className="mt-8 flex w-full justify-end">
{!submission.archived ? (
<button
className="text-base text-gray-500 underline"
onClick={() => toggleArchiveSubmission(submission)}>
Archive
</button>
) : (
<button
className="text-base text-gray-500 underline"
onClick={() => toggleArchiveSubmission(submission)}>
Restore
</button>
)}
{submission.customerEmail && (
<Button
variant="primary"
href={`mailto:${submission.customerEmail}`}
className="ml-4">
Send Email
</Button>
)}
</div>
</div>
</div>
</div>
</div>
</li>
))}
</>
)}
</ul>
</div>
);
}
@@ -1,278 +0,0 @@
"use client";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { useOrganisation } from "@/lib/organisations";
import { Button } from "@formbricks/ui";
import { UserIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import Prism from "prismjs";
import { useEffect, useMemo, useState } from "react";
import { AiFillApi } from "react-icons/ai";
import { toast } from "react-toastify";
require("prismjs/components/prism-javascript");
const tabs = [
{ id: "overview", name: "Overview", icon: UserIcon },
{ id: "api", name: "API", icon: AiFillApi },
];
export default function FormOverviewPage() {
const router = useRouter();
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.organisationId?.toString()
);
const { organisation, isLoadingOrganisation, isErrorOrganisation } = useOrganisation(
router.query.organisationId?.toString()
);
const [activeTab, setActiveTab] = useState(tabs[0]);
const capturePostUrl = useMemo(() => {
if (form) {
return `${window.location.protocol}//${window.location.host}/api/capture/forms/${form.id}/submissions`;
}
}, [form]);
useEffect(() => {
if (!isLoadingForm) {
Prism.highlightAll();
}
}, [isLoadingForm]);
if (isLoadingForm || isLoadingOrganisation) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorForm || isErrorOrganisation) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div className="mx-auto py-8 sm:px-6 lg:px-8">
<header className="mb-8">
<h1 className="text-3xl font-bold leading-tight tracking-tight text-slate-900">
{form.label}
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{organisation.name}
</span>
</h1>
</header>
<div>
<div>
<div className="sm:hidden">
<label htmlFor="tabs" className="sr-only">
Select a tab
</label>
{/* Use an "onChange" listener to redirect the user to the selected tab URL. */}
<select
id="tabs"
name="tabs"
className="block w-full rounded-md border-slate-300 focus:border-teal-500 focus:ring-teal-500"
defaultValue={activeTab.name}>
{tabs.map((tab) => (
<option key={tab.name}>{tab.name}</option>
))}
</select>
</div>
<div className="hidden sm:block">
<div className="border-b border-slate-200">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.name}
onClick={() => setActiveTab(tab)}
className={clsx(
activeTab.name === tab.name
? "border-teal-500 text-teal-600"
: "border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700",
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
)}
aria-current={activeTab.name === tab.name ? "page" : undefined}>
<tab.icon
className={clsx(
activeTab.name === tab.name
? "text-teal-500"
: "text-slate-400 group-hover:text-slate-500",
"-ml-0.5 mr-2 h-5 w-5"
)}
aria-hidden="true"
/>
<span>{tab.name}</span>
</button>
))}
</nav>
</div>
</div>
</div>
{activeTab.id === "overview" ? (
<div>
<div className="grid grid-cols-5 gap-8 py-4">
<div className="col-span-3">
<div>
<label htmlFor="formId" className="block text-lg font-semibold text-slate-800">
Your form ID
</label>
<div className="mt-3 w-96">
<input
id="formId"
type="text"
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm disabled:bg-slate-100 sm:text-sm"
value={form.id}
disabled
/>
<Button
variant="secondary"
className="mt-2 w-full justify-center"
onClick={() => {
navigator.clipboard.writeText(form.id);
toast("Copied form ID to clipboard");
}}>
copy
</Button>
</div>
</div>
<div className="max-w-2xl py-6">
<label htmlFor="formId" className="block text-lg font-semibold text-slate-800">
Capture POST Url:
</label>
<div className="mt-3">
<div className="mt-1 flex rounded-md shadow-sm">
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-200 px-3 text-slate-500 sm:text-sm">
POST
</span>
<input
id="captureUrl"
type="text"
className="focus:border-brand focus:ring-brand block w-full rounded-r-md border-slate-300 bg-slate-100 shadow-sm sm:text-sm"
value={capturePostUrl}
disabled
/>
</div>
<Button
variant="secondary"
className="mt-2 w-full justify-center"
onClick={() => {
navigator.clipboard.writeText(capturePostUrl);
toast("Copied form url to clipboard");
}}>
copy
</Button>
</div>
</div>
</div>
<div className="col-span-2 text-sm text-slate-600">
<h3 className="block text-lg font-semibold text-slate-800">How to get started</h3>
<ol className="list-decimal leading-8 text-slate-700">
<li>POST a submission to the capture endpoint.</li>
<li>
View submission under{" "}
<Link
href={`/organisations/${router.query.organisationId}/forms/${router.query.formId}/submissions`}
className="underline">
Submissions
</Link>{" "}
tab.
</li>
<li>
Get notified or pipe submission data to a different tool in the{" "}
<Link
href={`/organisations/${router.query.organisationId}/forms/${router.query.formId}/pipelines`}
className="underline">
Pipelines
</Link>{" "}
tab.
</li>
<li>
For a summary of form data a schema is required. Learn all about schemas in our{" "}
<Link
target="_blank"
href="https://formbricks.com/docs/formbricks-hq/schema"
className="underline">
docs
</Link>
.
</li>
</ol>
</div>
</div>
</div>
) : activeTab.id === "api" ? (
<div>
<div className="grid grid-cols-5 gap-8 py-4">
<div className="col-span-3">
<div>
<label htmlFor="formId" className="block text-lg font-semibold text-slate-800">
Capture POST Url:
</label>
<div className="mt-3">
<div className="mt-1 flex rounded-md shadow-sm">
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-200 px-3 text-slate-500 sm:text-sm">
POST
</span>
<input
id="captureUrl"
type="text"
className="focus:border-brand focus:ring-brand block w-full rounded-r-md border-slate-300 bg-slate-100 shadow-sm sm:text-sm"
value={`${window.location.protocol}//${window.location.host}/api/capture/forms/${form.id}/submissions`}
disabled
/>
</div>
<Button
variant="secondary"
className="mt-2 w-full justify-center"
onClick={() => {
navigator.clipboard.writeText(form.id);
toast("Copied form url to clipboard");
}}>
copy
</Button>
</div>
</div>
<div className="mt-4 rounded-md bg-black p-4 font-light text-slate-200 first-letter:text-sm">
<pre>
<code className="language-js whitespace-pre-wrap">
{`{
"customerId": "user@example.com", /* optional */
"data": {
"firstname": "John",
"lastname": "Doe",
"feedback": "I like the app very much"
}
}`}
</code>
</pre>
</div>
</div>
<div className="col-span-2 text-sm text-slate-600">
<h3 className="block pb-4 text-lg font-semibold text-slate-800">Quick Tips</h3>
<p className="font-bold">Authentication</p>
<p className="my-3 text-sm text-slate-600">
Via the API you can send submissions directly to Formbricks HQ. The API doesn&apos;t need
any authentication and can also be called in the users browser.
</p>
<p className="pt-3 font-bold">CustomerId</p>
<p className="my-3 text-sm text-slate-600">
You can pass along a customer ID to identify the respondent. This allows you to attribute
submissions of several surveys to the same respondent.
</p>
</div>
</div>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import EmptyPageFiller from "@/components/EmptyPageFiller";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { useSubmissions } from "@/lib/submissions";
import { capitalizeFirstLetter } from "@/lib/utils";
import { Bar, Nps, Table } from "@formbricks/charts";
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/router";
import { useState } from "react";
import FilterNavigation from "../shared/FilterNavigation";
import { SubmissionCounter } from "../shared/SubmissionCounter";
export default function OverviewResults() {
const router = useRouter();
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.organisationId?.toString()
);
const { submissions, isLoadingSubmissions, isErrorSubmissions } = useSubmissions(
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
const [filteredSubmissions, setFilteredSubmissions] = useState([]);
if (isLoadingSubmissions || isLoadingForm) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorSubmissions || isErrorForm) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div>
<div>
<section aria-labelledby="filters" className="pt-6 pb-24">
<div className="grid grid-cols-1 gap-x-16 gap-y-10 lg:grid-cols-4">
<div>
<SubmissionCounter
numFilteredSubmissions={filteredSubmissions.length}
numTotalSubmissions={submissions.length}
/>
<FilterNavigation submissions={submissions} setFilteredSubmissions={setFilteredSubmissions} />
</div>
{/* Submission grid */}
<div className="max-w-7xl lg:col-span-3">
{form && form.schema && Object.keys(form.schema).length > 0 ? (
<>
<div className="4xl:grid-cols-2 grid grid-cols-1 gap-6">
{form.schema.pages.map((page) =>
page.elements
.filter((e) =>
[
"checkbox",
"email",
"number",
"nps",
"phone",
"radio",
"search",
"text",
"textarea",
"url",
].includes(e.type)
)
.map((elem) => (
<div className="rounded-lg bg-white px-4 py-5 shadow-lg sm:p-6">
<h2 className="mb-6 text-lg font-bold text-slate-800">
{elem.label}
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{capitalizeFirstLetter(elem.type)}
</span>
</h2>
{filteredSubmissions.filter((s) => elem.name in s.data).length === 0 ? (
<EmptyPageFiller
alertText="No responses for that question yet"
hintText="Share your form to get more responses"
borderStyles="border-4 border-dotted border-red"></EmptyPageFiller>
) : (
<>
{["email", "number", "phone", "search", "text", "textarea", "url"].includes(
elem.type
) ? (
<div>
<Table
submissions={filteredSubmissions}
schema={form.schema}
fieldName={elem.name}
/>
</div>
) : ["checkbox", "radio"].includes(elem.type) ? (
<div>
<Bar
submissions={filteredSubmissions}
schema={form.schema}
fieldName={elem.name}
/>
</div>
) : ["nps"].includes(elem.type) ? (
<div>
<Nps
submissions={filteredSubmissions}
schema={form.schema}
fieldName={elem.name}
/>
</div>
) : null}
</>
)}
</div>
))
)}
{}
</div>
</>
) : (
<EmptyPageFiller
alertText="No schema found"
hintText="Please add a schema to your form to use the overview page"
borderStyles="border-4 border-dotted border-red">
<RectangleGroupIcon className="stroke-thin mx-auto h-24 w-24 text-slate-300" />
</EmptyPageFiller>
)}
</div>
</div>
</section>
</div>
</div>
);
}
@@ -0,0 +1,135 @@
import { Button } from "@formbricks/ui";
import { useRouter } from "next/router";
import Prism from "prismjs";
import { useEffect } from "react";
import { toast } from "react-toastify";
export default function SetupInstructions({}) {
const router = useRouter();
const formId = router.query.formId.toString();
useEffect(() => {
Prism.highlightAll();
}, []);
return (
<div>
<div className="max-w-8xl mx-auto grid grid-cols-5 gap-16 py-8">
<div className="col-span-3">
<div>
<div className="grid grid-cols-1 gap-8 py-4">
<div>
<div>
<label htmlFor="formId" className="block text-lg font-semibold text-slate-800">
Capture POST Url:
</label>
<div className="mt-3">
<div className="mt-1 flex rounded-md shadow-sm">
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-200 px-3 text-slate-500 sm:text-sm">
POST
</span>
<input
id="captureUrl"
type="text"
className="focus:border-brand focus:ring-brand block w-full rounded-r-md border-slate-300 bg-slate-100 shadow-sm sm:text-sm"
value={`${window.location.protocol}//${window.location.host}/api/capture/forms/${formId}/submissions`}
disabled
/>
</div>
<Button
variant="secondary"
className="mt-2 w-full justify-center"
onClick={() => {
navigator.clipboard.writeText(formId);
toast("Copied form url to clipboard");
}}>
copy
</Button>
</div>
</div>
<div className="mt-4 rounded-md bg-black p-4 font-light text-slate-200 first-letter:text-sm">
<pre>
<code className="language-js whitespace-pre-wrap">
{`{
"customer": {
"email": "user@example.com",
},
"data": {
"firstname": "John",
"lastname": "Doe",
"feedback": "I like the app very much"
}
}`}
</code>
</pre>
</div>
</div>
<div className="text-sm text-slate-600">
<h3 className="block pb-4 text-lg font-semibold text-slate-800">Quick Tips</h3>
<p className="font-bold">Authentication</p>
<p className="my-3 text-sm text-slate-600">
Via the API you can send submissions directly to Formbricks HQ. The API doesn&apos;t need
any authentication and can also be called in the users browser.
</p>
<p className="pt-3 font-bold">customer</p>
<p className="my-3 text-sm text-slate-600">
You can pass along a customer object to identify the respondent. This allows you to
attribute submissions of several surveys to the same respondent. This is optional.
</p>
</div>
</div>
</div>
</div>
<div className="col-span-2">
<div className="rounded-lg bg-slate-100 p-8">
<label htmlFor="formId" className="block text-xl font-bold text-slate-800">
Your Survey ID
</label>
<div className="mt-3">
<input
id="formId"
type="text"
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm disabled:bg-slate-100 sm:text-sm"
value={formId}
disabled
/>
<Button
variant="primary"
className="mt-2 w-full justify-center"
onClick={() => {
navigator.clipboard.writeText(formId);
toast("Copied form ID to clipboard");
}}>
Copy to clipboard
</Button>
</div>
<div className="mt-10">
<h4 className="my-2 block text-xl font-semibold text-slate-800">Custom Survey Docs</h4>
<p>Get detailed instructions in our Docs:</p>
<Button
variant="secondary"
target="_blank"
className="mt-2 w-full justify-center"
href="https://formbricks.com/docs/best-practices/custom-survey">
Documentation
</Button>
</div>
<div className="mt-10">
<h4 className="my-2 block text-xl font-semibold text-slate-800">Need help? Join Discord!</h4>
<p>Got a question? We&apos;re happy to help:</p>
<Button
variant="secondary"
target="_blank"
className="bg-purple mt-2 w-full justify-center"
href="https://formbricks.com/discord">
Join Discord
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,6 +1,8 @@
import EmptyPageFiller from "@/components/EmptyPageFiller";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms"; import { useForm } from "@/lib/forms";
import { camelToTitle, filterUniqueById } from "@/lib/utils"; import { camelToTitle, filterUniqueById } from "@/lib/utils";
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
import clsx from "clsx"; import clsx from "clsx";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -156,7 +158,7 @@ export default function FilterNavigation({
useEffect(() => { useEffect(() => {
// build filters based on form schema // build filters based on form schema
if (form && form.schema) { if (form && form.schema && Object.keys(form.schema).length > 0) {
const filters = []; const filters = [];
for (const page of form.schema.pages) { for (const page of form.schema.pages) {
for (const element of page.elements) { for (const element of page.elements) {
@@ -197,7 +199,7 @@ export default function FilterNavigation({
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>; return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
} }
return ( return form.schema && Object.keys(form.schema).length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{filters.map((filter) => ( {filters.map((filter) => (
<div key={filter.name}> <div key={filter.name}>
@@ -239,5 +241,12 @@ export default function FilterNavigation({
</div> </div>
))} ))}
</div> </div>
) : (
<EmptyPageFiller
alertText="No schema found"
hintText="Please add a schema to your form to use the filter navigation."
borderStyles="border-4 border-dotted border-red">
<RectangleGroupIcon className="stroke-thin mx-auto h-24 w-24 text-slate-300" />
</EmptyPageFiller>
); );
} }
@@ -1,6 +1,6 @@
export function SubmissionCounter({ numFilteredSubmissions, numTotalSubmissions }) { export function SubmissionCounter({ numFilteredSubmissions, numTotalSubmissions }) {
return ( return (
<div className="mb-4 rounded bg-white p-3"> <div className="mb-4 rounded bg-white p-3 shadow-md">
<div className="inline-block text-base font-bold text-slate-600"> <div className="inline-block text-base font-bold text-slate-600">
{numFilteredSubmissions} responses {numFilteredSubmissions} responses
</div> </div>
@@ -1,25 +0,0 @@
"use client";
import { MergeWithSchema } from "@/lib/submissions";
import clsx from "clsx";
export default function SubmissionDisplay({ schema, submission }) {
return (
<div className="flow-root">
<ul role="list" className="divide-ui-slate-light divide-y">
{Object.entries(MergeWithSchema(submission.data, schema)).map(([key, value]) => (
<li key={key} className="py-5">
<p className="text-sm font-semibold text-slate-800">{key}</p>
<p
className={clsx(
value ? "text-slate-600" : "text-slate-400",
"whitespace-pre-line pt-1 text-sm text-slate-600"
)}>
{value.toString()}
</p>
</li>
))}
</ul>
</div>
);
}
@@ -1,176 +0,0 @@
"use client";
import SubmissionDisplay from "@/components/forms/submissions/SubmissionDisplay";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { deleteSubmission, useSubmissions } from "@/lib/submissions";
import { convertDateTimeString } from "@/lib/utils";
import { RadioGroup, Switch } from "@headlessui/react";
import { TrashIcon } from "@heroicons/react/24/outline";
import type { Submission } from "@prisma/client";
import clsx from "clsx";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
export default function SubmissionsPage() {
const router = useRouter();
const [finishedOnly, setFinishedOnly] = useState(false);
const [filteredSubmissions, setFileredSubmission] = useState([]);
const { submissions, isLoadingSubmissions, mutateSubmissions, isErrorSubmissions } = useSubmissions(
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.organisationId?.toString()
);
const [activeSubmission, setActiveSubmission] = useState<Submission | null>(null);
useEffect(() => {
if (submissions) {
if (finishedOnly) {
setFileredSubmission(submissions.filter((submission) => submission.finished));
} else {
setFileredSubmission(submissions);
}
}
}, [finishedOnly, submissions]);
const handleDelete = async (submission: Submission) => {
try {
await deleteSubmission(
router.query.organisationId?.toString(),
router.query.formId?.toString(),
submission.id
);
await mutateSubmissions();
setActiveSubmission(null);
toast("Successfully deleted");
} catch (error) {
toast(error);
}
};
useEffect(() => {
if (!isLoadingSubmissions && submissions.length > 0) {
setActiveSubmission(submissions[0]);
}
}, [isLoadingSubmissions, submissions]);
if (isLoadingSubmissions || isLoadingForm) {
return <LoadingSpinner />;
}
if (isErrorForm || isErrorSubmissions) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div className="max-w-screen mx-auto flex h-full w-full flex-1 flex-col overflow-visible">
<div className="relative z-0 flex h-full flex-1 overflow-visible">
<main className="relative z-0 mb-32 flex-1 overflow-y-auto focus:outline-none xl:order-last">
<div className="overflow-visible sm:rounded-lg">
{!activeSubmission ? (
<button
type="button"
className="relative mx-auto mt-8 block w-96 rounded-lg border-2 border-dashed border-slate-300 p-12 text-center hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2">
<span className="mt-2 block text-sm font-medium text-slate-500">
Select a response on the left to see the details here
</span>
</button>
) : (
<>
<div className="bg-white px-4 py-5 shadow sm:px-12 sm:pb-4 sm:pt-12">
<div className="grid gap-8 divide-x">
<div className="flow-root">
<h1 className="mb-8 text-slate-700">
{convertDateTimeString(activeSubmission.createdAt.toString())}
</h1>
<SubmissionDisplay submission={activeSubmission} schema={form.schema} />
</div>
</div>
</div>
<div className="w-full">
<button
className="flex w-full items-center justify-center gap-2 border border-transparent bg-slate-300 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-500 focus:outline-none"
onClick={() => {
if (
confirm("Are you sure you want to delete this submission? It will be gone forever!")
) {
handleDelete(activeSubmission);
}
}}>
<TrashIcon className="h-4 w-4" />
Delete Submission
</button>
</div>
</>
)}
</div>
</main>
<aside className="border-ui-slate-light order-first flex h-full flex-1 flex-shrink-0 flex-col border-r md:w-96 md:flex-none">
{/* <DownloadResponses formId={params.formId} /> */}
<div className="flex justify-between pt-4 pb-2">
<h2 className="px-5 text-lg font-medium text-slate-900">Responses</h2>
<Switch.Group as="div" className="mr-3 flex items-center">
<Switch
checked={finishedOnly}
onChange={setFinishedOnly}
className={clsx(
finishedOnly ? "bg-teal-600" : "bg-slate-200",
"relative inline-flex h-5 w-8 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2"
)}>
<span
aria-hidden="true"
className={clsx(
finishedOnly ? "translate-x-3" : "translate-x-0",
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm text-slate-800">Finished only</span>
</Switch.Label>
</Switch.Group>
</div>
{filteredSubmissions.length === 0 ? (
<p className="mt-3 px-5 text-sm text-slate-500">No responses yet</p>
) : (
<RadioGroup
value={activeSubmission}
onChange={setActiveSubmission}
className="mb-32 min-h-0 flex-1 overflow-y-auto shadow-inner"
as="div">
<div className="relative h-screen overflow-y-scroll">
<ul className="divide-ui-slate-light relative z-0 divide-y">
{filteredSubmissions.map((submission) => (
<RadioGroup.Option
key={submission.id}
value={submission}
className={({ checked }) =>
clsx(checked ? "bg-slate-100" : "", "relative flex items-center space-x-3 px-6 py-5 ")
}>
<div className="min-w-0 flex-1">
<button
onClick={() => setActiveSubmission(submission)}
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-slate-900">
{submission.finished ? "✅" : "💬"} {convertDateTimeString(submission.createdAt)}
</p>
</button>
</div>
</RadioGroup.Option>
))}
</ul>
</div>
</RadioGroup>
)}
</aside>
</div>
</div>
);
}
@@ -1,144 +0,0 @@
"use client";
import AnalyticsCard from "@/components/AnalyticsCard";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { useSubmissions } from "@/lib/submissions";
import { capitalizeFirstLetter } from "@/lib/utils";
import { useOrganisation } from "@/lib/organisations";
import { Bar, Nps, Table } from "@formbricks/charts";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import Link from "next/link";
import { useRouter } from "next/router";
export default function SummaryPage() {
const router = useRouter();
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.organisationId?.toString()
);
const { organisation, isLoadingOrganisation, isErrorOrganisation } = useOrganisation(
router.query.organisationId?.toString()
);
const { submissions, isLoadingSubmissions } = useSubmissions(
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
if (isLoadingForm || isLoadingOrganisation || isLoadingSubmissions) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorForm || isErrorOrganisation) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div className="mx-auto py-8 sm:px-6 lg:px-8">
<header className="mb-8">
<h1 className="text-3xl font-bold leading-tight tracking-tight text-slate-900">
Summary - {form.label}
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{organisation.name}
</span>
</h1>
</header>
<div className="mt-10 grid grid-cols-2 lg:grid-cols-4 2xl:grid-cols-8">
<AnalyticsCard value={submissions.length} label={"Total submissions"} toolTipText={""} />
</div>
<div className="relative my-10">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-slate-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-slate-50 px-3 text-lg font-medium text-slate-900">Questions &amp; Answers</span>
</div>
</div>
{Object.keys(form.schema).length === 0 ? (
<div className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">No schema detected for this form.</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>To summarize your data Formbricks HQ needs a schema of your form.</p>
</div>
<div className="mt-4">
<div className="-mx-2 -my-1.5 flex">
<Link
target="_blank"
href="https://formbricks.com/docs/formbricks-hq/schema"
className="rounded-md bg-yellow-100 px-2 py-1.5 text-sm font-medium text-yellow-800 hover:bg-yellow-200 focus:outline-none focus:ring-2 focus:ring-yellow-600 focus:ring-offset-2 focus:ring-offset-yellow-50">
Setup schema
</Link>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="grid grid-cols-1 divide-y">
{form.schema.pages.map((page) =>
page.elements
.filter((e) =>
[
"checkbox",
"email",
"number",
"nps",
"phone",
"radio",
"search",
"text",
"textarea",
"url",
].includes(e.type)
)
.map((elem) => (
<div className="py-12">
{["email", "number", "phone", "search", "text", "textarea", "url"].includes(elem.type) ? (
<div>
<h2 className="mb-6 text-xl font-bold leading-tight tracking-tight text-slate-900">
{elem.label}
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{capitalizeFirstLetter(elem.type)}
</span>
</h2>
<Table submissions={submissions} schema={form.schema} fieldName={elem.name} />
</div>
) : ["checkbox", "radio"].includes(elem.type) ? (
<div>
<h2 className="mb-6 text-xl font-bold leading-tight tracking-tight text-slate-900">
{elem.label}
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{capitalizeFirstLetter(elem.type)}
</span>
</h2>
<Bar submissions={submissions} schema={form.schema} fieldName={elem.name} />
</div>
) : ["nps"].includes(elem.type) ? (
<div>
<h2 className="mb-6 text-xl font-bold leading-tight tracking-tight text-slate-900">
{elem.label}
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{capitalizeFirstLetter(elem.type)}
</span>
</h2>
<Nps submissions={submissions} schema={form.schema} fieldName={elem.name} />
</div>
) : null}
</div>
))
)}
{}
</div>
)}
</div>
);
}
+3
View File
@@ -91,6 +91,9 @@ export const MergeWithSchema = (submissionData, schema) => {
}; };
export const getOptionLabelMap = (schema) => { export const getOptionLabelMap = (schema) => {
if (!schema || !schema.pages) {
return {};
}
const optionLabelMap = {}; const optionLabelMap = {};
for (const page of schema.pages) { for (const page of schema.pages) {
for (const elem of page.elements) { for (const elem of page.elements) {
@@ -1,15 +1,14 @@
import FormOverviewPage from "@/components/forms/custom/FormOverviewPage"; "use client";
import CustomPage from "@/components/forms/custom/CustomPage";
import LayoutApp from "@/components/layout/LayoutApp"; import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation"; import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function FormOverview({}) { export default function OrganisationFormsPage({}) {
return ( return (
<LayoutApp> <LayoutApp>
<LayoutWrapperOrganisation> <LayoutWrapperOrganisation>
<LayoutWrapperForm> <CustomPage />
<FormOverviewPage />
</LayoutWrapperForm>
</LayoutWrapperOrganisation> </LayoutWrapperOrganisation>
</LayoutApp> </LayoutApp>
); );
@@ -1,18 +0,0 @@
import PipelinesPage from "@/components/forms/pipelines/PipelinesOverview";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperCustomForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function Pipeline({}) {
return (
<LayoutApp>
<LayoutWrapperOrganisation>
<LayoutWrapperCustomForm>
<div className="p-5">
<PipelinesPage />
</div>
</LayoutWrapperCustomForm>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -1,16 +0,0 @@
import SubmissionsPage from "@/components/forms/submissions/SubmissionsPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function Submissions({}) {
return (
<LayoutApp>
<LayoutWrapperOrganisation>
<LayoutWrapperForm>
<SubmissionsPage />
</LayoutWrapperForm>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -1,16 +0,0 @@
import SummaryPage from "@/components/forms/summary/SummaryPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function Submissions({}) {
return (
<LayoutApp>
<LayoutWrapperOrganisation>
<LayoutWrapperForm>
<SummaryPage />
</LayoutWrapperForm>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -1,16 +0,0 @@
import FormOverviewPage from "@/components/forms/custom/FormOverviewPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function FormOverview({}) {
return (
<LayoutApp>
<LayoutWrapperOrganisation>
<LayoutWrapperForm>
<FormOverviewPage />
</LayoutWrapperForm>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
+2 -1
View File
@@ -18,7 +18,8 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\"", "format": "prettier --write \"**/*.{ts,tsx,md}\"",
"generate": "turbo run generate", "generate": "turbo run generate",
"lint": "turbo run lint", "lint": "turbo run lint",
"release": "turbo run build --filter=react^... && changeset publish" "release": "turbo run build --filter=react^... && changeset publish",
"nuke": "rm -r node_modules; for d in **/node_modules; do echo $d; rm -r $d; done"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.22.0", "@changesets/cli": "^2.22.0",
+19
View File
@@ -0,0 +1,19 @@
export function ClockIcon(props: any) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
<defs />
<circle cx="{11.999}" cy="{12.001}" r="{11.5}" fill="#00e6ca" />
<path d="M3.867,20.133A11.5,11.5,0,0,1,20.131,3.869Z" fill="#c4f0eb" />
<circle
cx="{11.999}"
cy="{12.001}"
r="{11.5}"
fill="none"
stroke="#0f172a"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline points="12 6.501 12 12.001 18 17.501" fill="none" stroke="#0f172a" strokeLinejoin="round" />
</svg>
);
}
+1
View File
@@ -11,6 +11,7 @@ export * from "./icons/FormIcon";
export * from "./icons/CodeFileIcon"; export * from "./icons/CodeFileIcon";
export * from "./icons/TabletTouchIcon"; export * from "./icons/TabletTouchIcon";
export * from "./icons/PMFIcon"; export * from "./icons/PMFIcon";
export * from "./icons/ClockIcon";
export * from "./icons/IdeaIcon"; export * from "./icons/IdeaIcon";
export * from "./icons/AngryBirdRageIcon"; export * from "./icons/AngryBirdRageIcon";
export * from "./icons/AngryBirdRage2Icon"; export * from "./icons/AngryBirdRage2Icon";