mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-07 19:30:07 -05:00
Update custom form layout (#204)
* add new layout for custom survey view * make filterNavigation work without schema
This commit is contained in:
@@ -4,7 +4,7 @@ import { Button } from "@formbricks/ui";
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
alertText: 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‘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‘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‘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‘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'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‘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'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'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 { useForm } from "@/lib/forms";
|
||||
import { camelToTitle, filterUniqueById } from "@/lib/utils";
|
||||
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -156,7 +158,7 @@ export default function FilterNavigation({
|
||||
|
||||
useEffect(() => {
|
||||
// build filters based on form schema
|
||||
if (form && form.schema) {
|
||||
if (form && form.schema && Object.keys(form.schema).length > 0) {
|
||||
const filters = [];
|
||||
for (const page of form.schema.pages) {
|
||||
for (const element of page.elements) {
|
||||
@@ -197,7 +199,7 @@ export default function FilterNavigation({
|
||||
return <div>Error loading ressources. Maybe you don‘t have enough access rights</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
return form.schema && Object.keys(form.schema).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.name}>
|
||||
@@ -239,5 +241,12 @@ export default function FilterNavigation({
|
||||
</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 }) {
|
||||
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">
|
||||
{numFilteredSubmissions} responses
|
||||
</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‘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‘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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,9 @@ export const MergeWithSchema = (submissionData, schema) => {
|
||||
};
|
||||
|
||||
export const getOptionLabelMap = (schema) => {
|
||||
if (!schema || !schema.pages) {
|
||||
return {};
|
||||
}
|
||||
const optionLabelMap = {};
|
||||
for (const page of schema.pages) {
|
||||
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 LayoutWrapperForm from "@/components/layout/LayoutWrapperCustomForm";
|
||||
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
|
||||
|
||||
export default function FormOverview({}) {
|
||||
export default function OrganisationFormsPage({}) {
|
||||
return (
|
||||
<LayoutApp>
|
||||
<LayoutWrapperOrganisation>
|
||||
<LayoutWrapperForm>
|
||||
<FormOverviewPage />
|
||||
</LayoutWrapperForm>
|
||||
<CustomPage />
|
||||
</LayoutWrapperOrganisation>
|
||||
</LayoutApp>
|
||||
);
|
||||
|
||||
-18
@@ -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>
|
||||
);
|
||||
}
|
||||
-16
@@ -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>
|
||||
);
|
||||
}
|
||||
-16
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user