mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 02:43:06 -05:00
Update custom form layout (#204)
* add new layout for custom survey view * make filterNavigation work without schema
This commit is contained in:
@@ -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")}
|
||||||
|
|||||||
@@ -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‘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 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‘t have enough access rights</div>;
|
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">
|
<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‘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) => {
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
-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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+2
-1
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user