hq - update forms overview, add Nps Type, update charts lib, update react lib

This commit is contained in:
Matthias Nannt
2022-12-08 15:26:17 +01:00
parent 273aad391e
commit bcecdc0f3d
14 changed files with 478 additions and 119 deletions

View File

@@ -0,0 +1,6 @@
---
"@formbricks/charts": minor
"@formbricks/react": minor
---
Add Nps Type, Update missing styling classes, minor bug fixes

View File

@@ -4,50 +4,23 @@ import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { useTeam } from "@/lib/teams";
import { Button } from "@formbricks/ui";
import { UserIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import Link from "next/link";
import Prism from "prismjs";
import { useEffect, useMemo } from "react";
import { FaReact, FaVuejs } from "react-icons/fa";
import { AiFillHtml5 } from "react-icons/ai";
import { toast } from "react-toastify";
import { useRouter } from "next/router";
import Prism from "prismjs";
import { useEffect, useMemo, useState } from "react";
import { AiFillApi } from "react-icons/ai";
import { FaReact, FaVuejs } from "react-icons/fa";
import { toast } from "react-toastify";
require("prismjs/components/prism-javascript");
const getLibs = (formId: string) => [
{
id: "react",
name: "React",
href: `https://formbricks.com/docs/react-form-library/introduction`,
bgColor: "bg-brand-dark",
target: "_blank",
icon: FaReact,
},
{
id: "html",
name: "HTML",
href: `https://formbricks.com/docs/react-form-library/link-formbricks-hq`,
bgColor: "bg-brand-dark",
target: "_blank",
icon: AiFillHtml5,
},
{
id: "reactNative",
name: "React Native",
comingSoon: true,
href: "#",
disabled: true,
icon: FaReact,
},
{
id: "vue",
name: "Vue.js",
comingSoon: true,
href: "#",
disabled: true,
icon: FaVuejs,
},
const tabs = [
{ id: "overview", name: "Overview", icon: UserIcon },
{ id: "api", name: "API", icon: AiFillApi },
{ id: "react", name: "React", icon: FaReact },
{ id: "vue", name: "Vue", icon: FaVuejs },
];
export default function FormOverviewPage() {
@@ -57,11 +30,7 @@ export default function FormOverviewPage() {
router.query.teamId?.toString()
);
const { team, isLoadingTeam, isErrorTeam } = useTeam(router.query.teamId?.toString());
const libs = useMemo(() => {
if (form) {
return getLibs(form.id);
}
}, [form]);
const [activeTab, setActiveTab] = useState(tabs[0]);
useEffect(() => {
if (!isLoadingForm) {
@@ -91,47 +60,144 @@ export default function FormOverviewPage() {
</h1>
</header>
<div>
<div className="mx-auto mt-8">
<h1 className="text-xl font-bold leading-tight text-slate-900">Connect your form</h1>
<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-gray-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-gray-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-gray-500 hover:border-gray-300 hover:text-gray-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-gray-400 group-hover:text-gray-500",
"-ml-0.5 mr-2 h-5 w-5"
)}
aria-hidden="true"
/>
<span>{tab.name}</span>
</button>
))}
</nav>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-10">
{activeTab.id === "overview" ? (
<div>
<div className="mt-4 mb-12">
<p className="text-slate-700">
To get started post your submission to the Formbricks HQ capture endpoint. To enable the
summary feature also set the schema of this form.
<br />
If you are using the Formbricks React Library it&apos;s even easier to get started. All you
need is you formId and we take care of the rest (including the schema).
</p>
</div>
<div>
<label htmlFor="formId" className="block text-base 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-gray-300 shadow-sm disabled:bg-gray-100 sm:text-sm"
value={form.id}
disabled
/>
<div className="mt-4 mb-12 text-sm text-gray-600">
<p className="text-slate-700">
To get started post your submission to the Formbricks HQ capture endpoint. All submissions
are stored in Formbricks HQ and can be viewed here.
<br /> <br />
If you want to get notified when a submission is made you can also set up a webhook or email
notifications in{" "}
<Link
href={`/app/teams/${router.query.teamId}/forms/${router.query.formId}/pipelines`}
className="underline">
Pipelines
</Link>
.<br />
<br />
Optionally you can set a schema for your form. This schema tells Formbricks HQ more about
the structure of your form and enables better form evaluation, e.g. displays the correct
labels for your form fields in the Formbricks HQ UI insted of the fieldName or filters data
that doesn&apos;t match the schema. The easiest way to get started with a schema is to used
our react library because it handles schema creation and sending to Formbricks HQ
automatically.
<br /> To learn more about the schema please check out our{" "}
<Link href="https://formbricks.com/docs/formbricks-hq/schema">docs</Link>.
</p>
</div>
<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>
<label htmlFor="formId" className="block text-base 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-gray-300 shadow-sm disabled:bg-gray-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>
<hr className="my-6" />
<div className="max-w-2xl">
<label htmlFor="formId" className="block text-base 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-gray-300 bg-gray-200 px-3 text-gray-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-gray-300 bg-gray-100 shadow-sm sm:text-sm"
value={`${window.location.protocol}//${window.location.host}/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>
<hr className="my-6" />
</div>
) : activeTab.id === "api" ? (
<div className="mt-5">
<p className="my-3 text-sm text-gray-600">
You can send submissions directly to Formbricks HQ via our API. The API doesn&apos;t need any
authentication and can also called in the users browser.
</p>
<hr className="my-8" />
<div className="max-w-2xl">
<label htmlFor="formId" className="block text-base text-slate-800">
Capture POST Url:
@@ -161,38 +227,133 @@ export default function FormOverviewPage() {
</Button>
</div>
</div>
<hr className="my-8" />
<div className="rounded-md bg-black p-8 text-sm font-light text-gray-200">
<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="rounded-md bg-black p-8 font-light text-gray-200">
<pre>
<code className="language-js whitespace-pre-wrap">
{`import { Form, Text, Email, Checkbox, Submit, sendToHQ } from "@formbricks/react";
) : activeTab.id === "react" ? (
<div className="mt-5">
<p className="my-3 text-sm text-gray-600">
The best way to send submissions to Formbricks HQ in React is our simple to use{" "}
<Link target="_blank" href="https://www.npmjs.com/package/@formbricks/react">
React Library
</Link>
because it also creates and sends a schema to Formbricks HQ.
</p>
<div className="rounded-md bg-black p-8 text-sm font-light text-gray-200">
<pre>
<code className="language-js whitespace-pre-wrap">
{`import { Form, Text, Email, Checkbox, Submit, sendToHq } from "@formbricks/react";
import "@formbricks/react/styles.css";
export default function WaitlistForm() {
return (
<Form formId="${form.id}" hqUrl="${window.location.protocol}//${window.location.host}" onSubmit={sendToHQ}>
<Text name="name" label="What's your name?" validation="required" />
<Email
name="email"
label="What's your email address?"
placeholder="you@example.com"
validation="required|email"
/>
<Checkbox
name="terms"
label="Terms & Conditions"
help="To use our service, please accept."
validation="accepted"
/>
<Submit label="Let's go!" />
</Form>
);
return (
<Form formId="${form.id}" hqUrl="${window.location.protocol}//${window.location.host}" onSubmit={sendToHq}>
<Text name="name" label="What's your name?" validation="required" />
<Email
name="email"
label="What's your email address?"
placeholder="you@example.com"
validation="required|email"
/>
<Checkbox
name="terms"
label="Terms & Conditions"
help="To use our service, please accept."
validation="accepted"
/>
<Submit label="Let's go!" />
</Form>
);
}`}
</code>
</pre>
</code>
</pre>
</div>
<p className="my-3 text-sm text-gray-600">
But you can also use the default React Form functionality (or another form library) to send the
submissions to Formbricks HQ.
</p>
<div className="rounded-md bg-black p-8 text-sm font-light text-gray-200">
<pre>
<code className="language-js whitespace-pre-wrap">
{`<form
onSubmit={({ data }) => {
fetch("${window.location.protocol}//${window.location.host}/capture/forms/${form.id}/submissions", {
method: "POST",
body: JSON.stringify({data}),
headers: {
Accept: "application/json",
},
});
}}>
{/* YOUR FORM */}
</form>`}
</code>
</pre>
</div>
</div>
</div>
<div className="mt-16">
) : activeTab.id === "vue" ? (
<div className="mt-5">
{" "}
<p className="my-3 text-sm text-gray-600">
To send a submission in Vue.Js you can use the default form functionality.
</p>
<div className="rounded-md bg-black p-8 text-sm font-light text-gray-200">
<pre>
<code className="language-js whitespace-pre-wrap">
{`<template>
<form @submit.prevent="submitForm">
<label>
<span>Email</span>
<input type="email" name="email" v-model="email" />
</label>
<label>
<span>Message</span>
<textarea name="message" v-model="message"></textarea>
</label>
<button type="submit">Submit</button>
</form>
</template>
<script>
export default {
data() {
return {
email: '',
message: '',
endpoint: '${window.location.protocol}//${window.location.host}/capture/forms/${form.id}/submissions',
}
},
methods: {
async submitForm() {
const data = {
email: this.email,
message: this.message,
}
const response = await this.$axios.post(this.endpoint, {data})
},
},
}
</script>`}
</code>
</pre>
</div>
</div>
) : null}
{/* <div className="mt-16">
<h2 className="text-xl font-bold text-slate-800">Code your form</h2>
<div className="mt-4 mb-12">
<p className="text-slate-800">
@@ -251,7 +412,7 @@ export default function WaitlistForm() {
</Link>
</p>
</div>
</div>
</div> */}
</div>
</div>
);

View File

@@ -1,13 +1,13 @@
"use client";
import AnalyticsCard from "@/components/AnalyticsCard";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { useTeam } from "@/lib/teams";
import { ExclamationTriangleIcon, InformationCircleIcon } from "@heroicons/react/20/solid";
import Link from "next/link";
import { Bar, Table } from "@formbricks/charts";
import { useSubmissions } from "@/lib/submissions";
import AnalyticsCard from "@/components/AnalyticsCard";
import { useTeam } from "@/lib/teams";
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() {
@@ -107,6 +107,16 @@ export default function SummaryPage() {
</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-gray-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">
{elem.type}
</span>
</h2>
<Nps submissions={submissions} schema={form.schema} fieldName={elem.name} />
</div>
) : null}
</div>
))}

View File

@@ -5,7 +5,7 @@ import { hashString } from "./utils";
and we cannot trace anything back to you or your customers. If you still want to
disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */
export const captureTelemetryEvent = async (eventName: string, properties = {}) => {
export const captureTelemetry = async (eventName: string, properties = {}) => {
if (
process.env.TELEMETRY_DISABLED !== "1" &&
process.env.NODE_ENV === "production" &&

View File

@@ -1,5 +1,6 @@
import { runPipelines } from "@/lib/pipelinesHandler";
import { capturePosthogEvent } from "@/lib/posthog";
import { captureTelemetry } from "@/lib/telemetry";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
import NextCors from "nextjs-cors";
@@ -67,6 +68,3 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}
function captureTelemetry(arg0: string) {
throw new Error("Function not implemented.");
}

View File

@@ -18,12 +18,10 @@ export function FbBar({ color, submissions, schema, fieldName }: Props) {
}
// build data object by finding schema definition of field and scanning submissions for this key
const dataDict: any = {};
console.log(fieldName);
const schemaElem = schema.children.find((e: any) => e.name === fieldName);
if (typeof schemaElem === "undefined") {
throw Error("key not found in schema");
}
console.log(JSON.stringify(schemaElem, null, 2));
for (const option of schemaElem.options) {
dataDict[option.value] = { name: option.label, value: 0 };
}

View File

@@ -0,0 +1,59 @@
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, Legend, Tooltip, XAxis, YAxis } from "recharts";
interface Props {
color?: string;
submissions: any;
schema: any;
fieldName: string;
}
export function Nps({ color, submissions, schema, fieldName }: Props) {
const data = useMemo(() => {
if (!fieldName) {
throw Error("no field name provided");
}
if (!submissions || !schema || Object.keys(schema).length === 0) {
return [];
}
// build data object by finding schema definition of field and scanning submissions for this key
const dataDict: any = {};
const schemaElem = schema.children.find((e: any) => e.name === fieldName);
if (typeof schemaElem === "undefined") {
throw Error("key not found in schema");
}
for (const option of [...Array(11).keys()]) {
dataDict[option] = { name: option, value: 0 };
}
console.log(dataDict);
for (const submission of submissions) {
if (fieldName in submission.data) {
if (submission.data[fieldName] in dataDict) {
dataDict[submission.data[fieldName]] = {
...dataDict[submission.data[fieldName]],
value: dataDict[submission.data[fieldName]].value + 1,
};
}
}
}
// transform dataDict to desired form
const data = [];
for (const entry of Object.entries(dataDict)) {
data.push(entry[1]);
}
return data;
}, [submissions, schema]);
return (
<>
<BarChart width={730} height={250} data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis label={{ value: "# answers", angle: -90, position: "insideLeft" }} />
<Tooltip />
<Legend />
<Bar dataKey="value" fill={color || "#00C4B8"} />
</BarChart>
</>
);
}

View File

@@ -1,2 +1,3 @@
export * from "./charts/Bar";
export * from "./charts/Nps";
export * from "./charts/Table";

View File

@@ -36,6 +36,7 @@
"typescript": "^4.8.4"
},
"dependencies": {
"@headlessui/react": "^1.7.4",
"@hookform/error-message": "^2.0.1",
"clsx": "^1.2.1",
"react-hook-form": "^7.39.1"

View File

@@ -0,0 +1,92 @@
import { RadioGroup } from "@headlessui/react";
import { useMemo } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { getElementId } from "../../lib/element";
import { useEffectUpdateSchema } from "../../lib/schema";
import { NameRequired, UniversalInputProps } from "../../types";
import { Help } from "../shared/Help";
import { Inner } from "../shared/Inner";
import { Label } from "../shared/Label";
import { Messages } from "../shared/Messages";
import { Options } from "../shared/Options";
import { Outer } from "../shared/Outer";
import { Wrapper } from "../shared/Wrapper";
interface NpsInputUniqueProps {
optionsClassName?: string;
optionClassName?: string;
}
type NpsProps = NpsInputUniqueProps & UniversalInputProps & NameRequired;
const inputType = "nps";
export function Nps(props: NpsProps) {
const elemId = useMemo(() => getElementId(props.id, props.name), [props.id, props.name]);
useEffectUpdateSchema(props, inputType);
const { control } = useFormContext();
return (
<Outer inputType={inputType} outerClassName={props.outerClassName}>
<Wrapper wrapperClassName={props.wrapperClassName}>
<Label label={props.label} elemId={elemId} labelClassName={props.labelClassName} />
<Inner innerClassName={props.innerClassName}>
<Controller
name={props.name}
control={control}
rules={{ required: true }}
render={({ field }: any) => (
<RadioGroup {...field} id="test">
<RadioGroup.Label className="sr-only">Choose a number from 0 to 10</RadioGroup.Label>
<Options optionsClassName={props.optionsClassName}>
{[...Array(11).keys()].map((option) => (
<RadioGroup.Option
key={option}
value={option}
className={props.optionClassName || "formbricks-option"}>
<Wrapper wrapperClassName={props.wrapperClassName}>
<Inner innerClassName={props.innerClassName}>
<RadioGroup.Label as="span" className={props.inputClassName || "formbricks-input"}>
{option}
</RadioGroup.Label>
</Inner>
</Wrapper>
</RadioGroup.Option>
))}
</Options>
<div className="formbricks-input-addition">
<p className="formbricks-input-addition-item">not likely</p>
<p className="formbricks-input-addition-item">very likely</p>
</div>
</RadioGroup>
)}
/>
{/* <textarea
className={props.inputClassName || "formbricks-input"}
id={elemId}
placeholder={props.placeholder || ""}
cols={props.cols}
rows={props.rows}
aria-invalid={errors[props.name] ? "true" : "false"}
{...register(props.name, {
required: { value: "required" in validationRules, message: "This field is required" },
minLength: {
value: props.minLength || 0,
message: `Your answer must be at least ${props.minLength} characters long`,
},
maxLength: {
value: props.maxLength || 524288,
message: `Your answer musn't be longer than ${props.maxLength} characters`,
},
validate: validate(validationRules),
})}
/> */}
</Inner>
</Wrapper>
<Help help={props.help} elemId={elemId} helpClassName={props.helpClassName} />
<Messages {...props} />
</Outer>
);
}

View File

@@ -39,7 +39,7 @@ export function Textarea(props: TextareaProps) {
<Label label={props.label} elemId={elemId} labelClassName={props.labelClassName} />
<Inner innerClassName={props.innerClassName}>
<textarea
className="formbricks-input"
className={props.inputClassName || "formbricks-input"}
id={elemId}
placeholder={props.placeholder || ""}
cols={props.cols}

View File

@@ -4,6 +4,7 @@ export * from "./components/FormbricksSchema";
export * from "./components/inputs/Button";
export * from "./components/inputs/Checkbox";
export * from "./components/inputs/Email";
export * from "./components/inputs/Nps";
export * from "./components/inputs/Number";
export * from "./components/inputs/Password";
export * from "./components/inputs/Phone";

View File

@@ -55,3 +55,27 @@ textarea.formbricks-input {
.formbricks-fieldset {
@apply border-gray-50 rounded-lg max-w-md;
}
.formbricks-input-nps {
@apply flex cursor-pointer items-center justify-center rounded-md border py-3 px-3 text-sm font-medium uppercase focus:outline-none sm:flex-1;
}
[data-type="nps"] .formbricks-options {
@apply grid grid-cols-11 gap-1 sm:grid-cols-11 mt-2;
}
[data-type="nps"] .formbricks-option[aria-checked="true"] {
@apply border-transparent bg-slate-600 text-white hover:bg-slate-700;
}
[data-type="nps"] .formbricks-option {
@apply flex cursor-pointer items-center justify-center rounded-md border py-3 px-3 text-sm font-medium uppercase focus:outline-none sm:flex-1;
}
[data-type="nps"] .formbricks-input-addition {
@apply flex items-center justify-between mt-1;
}
[data-type="nps"] .formbricks-input-addition-item {
@apply text-xs text-gray-500;
}

14
pnpm-lock.yaml generated
View File

@@ -10,7 +10,7 @@ importers:
turbo: latest
devDependencies:
'@changesets/cli': 2.25.0
prettier: 2.8.0
prettier: 2.8.1
tsx: 3.9.0
turbo: 1.6.3
@@ -273,6 +273,7 @@ importers:
packages/react:
specifiers:
'@formbricks/tsconfig': workspace:*
'@headlessui/react': ^1.7.4
'@hookform/error-message': ^2.0.1
'@types/react': ^18.0.25
'@types/react-dom': ^18.0.8
@@ -286,6 +287,7 @@ importers:
tsup: ^6.4.0
typescript: ^4.8.4
dependencies:
'@headlessui/react': 1.7.4_biqbaboplfbrettd7655fr4n2y
'@hookform/error-message': 2.0.1_nrnvpvixg5xdweezd67llqjwze
clsx: 1.2.1
react-hook-form: 7.40.0_react@18.2.0
@@ -1938,7 +1940,7 @@ packages:
fs-extra: 7.0.1
lodash.startcase: 4.4.0
outdent: 0.5.0
prettier: 2.8.0
prettier: 2.8.1
resolve-from: 5.0.0
semver: 5.7.1
dev: true
@@ -2105,7 +2107,7 @@ packages:
'@changesets/types': 5.2.0
fs-extra: 7.0.1
human-id: 1.0.2
prettier: 2.8.0
prettier: 2.8.1
dev: true
/@cnakazawa/watch/1.0.4:
@@ -13295,6 +13297,12 @@ packages:
hasBin: true
dev: true
/prettier/2.8.1:
resolution: {integrity: sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==}
engines: {node: '>=10.13.0'}
hasBin: true
dev: true
/pretty-error/2.1.2:
resolution: {integrity: sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==}
dependencies: