* add tag functionality to responses

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Johannes
2023-02-23 19:26:24 +01:00
committed by GitHub
parent f6f0d53435
commit 318f14c4b2
13 changed files with 264 additions and 29 deletions

View File

@@ -41,7 +41,7 @@ function Header({ navigation }: any) {
className={clsx(
"sticky top-0 z-50 flex flex-wrap items-center justify-between bg-slate-100 px-4 py-5 shadow-md shadow-slate-900/5 transition duration-500 dark:shadow-none sm:px-6 lg:px-8",
isScrolled
? "[@supports(backdrop-filter:blur(0))]:bg-slate-100/75 dark:[@supports(backdrop-filter:blur(0))]:bg-slate-900/75 bg-slate-100/90 backdrop-blur dark:bg-slate-900/90"
? "bg-slate-100/90 backdrop-blur dark:bg-slate-900/90 [@supports(backdrop-filter:blur(0))]:bg-slate-100/75 dark:[@supports(backdrop-filter:blur(0))]:bg-slate-900/75"
: "dark:bg-transparent"
)}>
<div className="mr-6 flex lg:hidden">

View File

@@ -46,7 +46,7 @@ export default function NewFormModal({ open, setOpen, organisationId }: FormOnbo
name: "Product-Market Fit Survey",
description: "Leverage the Superhuman PMF engine.",
icon: PMFIcon,
needsUpgrade: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD && organisation?.plan === "free",
needsUpgrade: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD === "1" && organisation?.plan === "free",
},
],
[organisation]

View File

@@ -9,6 +9,7 @@ import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "react-toastify";
import Tagging from "../shared/Tagging";
export default function PMFTimeline({ submissions }) {
const router = useRouter();
@@ -133,6 +134,7 @@ export default function PMFTimeline({ submissions }) {
</ul>
</div>
</div>
<Tagging submission={submission} />
<div className=" bg-gray-50 p-4 sm:p-6">
<div className="flex w-full justify-between gap-4">
<div>

View File

@@ -7,6 +7,7 @@ import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "react-toastify";
import Tagging from "../shared/Tagging";
export default function FeedbackTimeline({ submissions }) {
const router = useRouter();
@@ -106,6 +107,8 @@ export default function FeedbackTimeline({ submissions }) {
</p>
</div>
</div>
<Tagging submission={submission} />
<div className=" bg-slate-50 p-4 sm:p-6">
<div className="flex w-full justify-between gap-4">
<div>

View File

@@ -9,6 +9,7 @@ import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "react-toastify";
import Tagging from "../shared/Tagging";
export default function PMFTimeline({ submissions }) {
const router = useRouter();
@@ -103,7 +104,6 @@ export default function PMFTimeline({ submissions }) {
Somewhat disappointed
</span>
) : null}
<div className="text-sm text-slate-400">
<time dateTime={convertDateTimeString(submission.createdAt)}>
{
@@ -135,6 +135,8 @@ export default function PMFTimeline({ submissions }) {
</ul>
</div>
</div>
<Tagging submission={submission} />
<div className=" bg-slate-50 p-4 sm:p-6">
<div className="flex w-full justify-between gap-4">
<div>
@@ -157,10 +159,12 @@ export default function PMFTimeline({ submissions }) {
{parseUserAgent(submission.meta.userAgent)}
</p>
</div>
<div>
<p className="text-sm font-thin text-slate-500">Page</p>
<p className="text-sm text-slate-500">{submission.data.pageUrl}</p>
</div>
{submission.data.pageUrl && (
<div>
<p className="text-sm font-thin text-slate-500">Page</p>
<p className="text-sm text-slate-500">{submission.data.pageUrl}</p>
</div>
)}
</div>
<div className="mt-8 flex w-full justify-end">

View File

@@ -5,7 +5,7 @@ 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";
import { useEffect, useMemo, useState } from "react";
import { BsPin, BsPinFill } from "react-icons/bs";
interface Filter {
@@ -39,6 +39,19 @@ export default function FilterNavigation({
const { form, isLoadingForm, isErrorForm } = useForm(formId?.toString(), organisationId?.toString());
// get all the tags from the submissions
const tags = useMemo(() => {
const tags = [];
for (const submission of submissions) {
for (const tag of submission.tags) {
if (!tags.includes(tag)) {
tags.push(tag);
}
}
}
return tags;
}, [submissions]);
// filter submissions based on selected filters
useEffect(() => {
if (form) {
@@ -55,8 +68,32 @@ export default function FilterNavigation({
setNumTotalSubmissions([...submissions].filter((s) => !s.archived).length);
}
continue;
} else if (filter.type === "tags") {
const isAllActive = filter.options.find((option) => option.value === "all")?.active;
// no filter is all is selected, if not keep on filtering
if (!isAllActive) {
// filter for all other types
let listOfValidFilteredSubmissions = [];
for (const option of filter.options) {
if (option.active || option.pinned) {
listOfValidFilteredSubmissions.push(
newFilteredSubmissions.filter((submission) => {
if (submission.tags) {
return submission.tags.includes(option.value);
}
})
);
}
}
// add pinned submissions to the top
const flattenedListOfValidFilteredSubmissions = listOfValidFilteredSubmissions.flat();
newFilteredSubmissions = filterUniqueById(flattenedListOfValidFilteredSubmissions);
}
continue;
}
const isAllActive = filter.options.find((option) => option.value === "all")?.active;
// no filter is all is selected, if not keep on filtering
if (!isAllActive) {
@@ -178,9 +215,18 @@ export default function FilterNavigation({
{ value: "archived", label: "Archived", active: false },
],
});
// add tag selection to filters
filters.push({
name: "tags",
label: "Tags",
type: "tags",
options: [{ value: "all", label: "All", active: true, pinned: false }].concat([
...tags.map((tag) => ({ value: tag, label: tag, active: false, pinned: false })),
]),
});
setFilters(filters);
}
}, [form, limitFields]);
}, [form, limitFields, tags]);
if (isLoadingForm) {
return <LoadingSpinner />;
@@ -198,24 +244,24 @@ export default function FilterNavigation({
<h4 className="text-slate-600">{camelToTitle(filter.name)}</h4>
</div>
{filter.options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
chooseOptionFilter(filter.name, option.value, option.active);
}}
className={clsx(
option.active || option.pinned
? "bg-slate-200 text-slate-900"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
"group my-1 flex w-full items-center rounded-md px-3 py-1.5 text-sm font-medium"
)}
aria-current={option.active ? "page" : undefined}>
<div className={clsx("-ml-1 mr-3 h-2 w-2 flex-shrink-0 rounded-full")} />
<span className="truncate">{option.label}</span>
<div key={option.value} className="relative">
<button
type="button"
onClick={() => {
chooseOptionFilter(filter.name, option.value, option.active);
}}
className={clsx(
option.active || option.pinned
? "bg-slate-200 text-slate-900"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
"group my-1 flex w-full items-center rounded-md px-3 py-1.5 text-sm font-medium transition-all duration-200"
)}
aria-current={option.active ? "page" : undefined}>
<span className="truncate">{option.label}</span>
</button>
{!["all", "inbox", "archived"].includes(option.value) && (option.active || option.pinned) && (
<button
className="ml-auto"
className="absolute right-2 top-1 rounded px-2 py-1 transition-all duration-100 hover:bg-slate-100"
onClick={(e) => {
e.stopPropagation();
pinOptionFilter(filter.name, option.value, !option.pinned);
@@ -227,7 +273,7 @@ export default function FilterNavigation({
)}
</button>
)}
</button>
</div>
))}
</div>
))}

View File

@@ -3,7 +3,7 @@ import { RectangleStackIcon } from "@heroicons/react/24/solid";
export function SubmissionCounter({ numFilteredSubmissions, numTotalSubmissions }) {
return (
<div className="mb-4 rounded-lg border border-slate-200 px-4 py-2">
<div className="flex items-center text-base font-semibold text-slate-500">
<div className="flex flex-wrap items-center text-base font-semibold text-slate-500">
<RectangleStackIcon className="mr-2 h-5 w-5 text-slate-300" /> {numFilteredSubmissions} responses
{numFilteredSubmissions !== numTotalSubmissions && (
<div className="ml-2 text-sm font-medium text-slate-400">(out of {numTotalSubmissions})</div>

View File

@@ -0,0 +1,169 @@
import { persistSubmission, useSubmissions } from "@/lib/submissions";
import { onlyUnique } from "@/lib/utils";
import { Combobox } from "@headlessui/react";
import { PlusIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { useRouter } from "next/router";
import { useMemo, useState } from "react";
// add interface props for tags
interface TaggingProps {
submission: any;
}
export default function Tagging({ submission }: TaggingProps) {
const router = useRouter();
const [isEditingTag, setIsEditingTag] = useState("");
const [currentTag, setCurrentTag] = useState("");
const { submissions, mutateSubmissions } = useSubmissions(
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
// update submissions
const updateSubmissionsLocally = (updatedSubmission) => {
const updatedSubmissions = JSON.parse(JSON.stringify(submissions));
const submissionIdx = updatedSubmissions.findIndex((s) => s.id === updatedSubmission.id);
updatedSubmissions[submissionIdx] = updatedSubmission;
mutateSubmissions(updatedSubmissions, false);
};
// add tag to submission
const addTag = async (submission, tag) => {
if (!tag) return;
if (submission.tags.includes(tag)) {
setIsEditingTag("");
setCurrentTag("");
return;
}
const updatedSubmission = JSON.parse(JSON.stringify(submission));
updatedSubmission.tags = [...updatedSubmission.tags, tag];
updateSubmissionsLocally(updatedSubmission);
await persistSubmission(updatedSubmission, router.query.organisationId?.toString());
setIsEditingTag("");
setCurrentTag("");
};
//remove tag from submission
const removeTag = async (submission, tag) => {
const updatedSubmission = JSON.parse(JSON.stringify(submission));
updatedSubmission.tags = updatedSubmission.tags.filter((t) => t !== tag);
updateSubmissionsLocally(updatedSubmission);
await persistSubmission(updatedSubmission, router.query.organisationId?.toString());
};
// get all the tags from the submissions
const existingTags = useMemo(() => {
const tags = [];
for (const submission of submissions) {
for (const tag of submission.tags) {
if (!tags.includes(tag)) {
tags.push(tag);
}
}
}
return tags;
}, [submissions]);
const notYetAssignedTags = useMemo(
() => existingTags.filter((tag) => !submission.tags.includes(tag)),
[existingTags, submission.tags]
);
const filteredTags = useMemo(
() =>
currentTag === ""
? notYetAssignedTags
: notYetAssignedTags.filter((notYetAssignedTag) => {
return notYetAssignedTag.toLowerCase().includes(currentTag.toLowerCase());
}),
[currentTag, notYetAssignedTags]
);
return (
<div className="border-t border-slate-100 px-6 py-4">
<ul className="flex flex-wrap space-x-2">
{submission.tags.map((tag: string) => (
<li
key={tag}
className="my-1 inline-flex items-center rounded-full bg-slate-600 py-0.5 pl-2.5 pr-1 text-sm font-medium text-slate-100">
{tag}
<button
type="button"
onClick={() => removeTag(submission, tag)}
className="ml-0.5 inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full text-slate-400 hover:bg-slate-500 hover:text-slate-200 focus:bg-slate-500 focus:text-white focus:outline-none">
<span className="sr-only">Remove large option</span>
<svg className="h-2 w-2" stroke="currentColor" fill="none" viewBox="0 0 8 8">
<path strokeLinecap="round" strokeWidth="1.5" d="M1 1l6 6m0-6L1 7" />
</svg>
</button>
</li>
))}
<li>
{isEditingTag && submission.id === isEditingTag ? (
<Combobox
as="div"
value={filteredTags.length > 0 && currentTag ? filteredTags[0] : currentTag}
onChange={(value) => {
addTag(submission, value);
}}>
<div className="relative">
<div className="relative">
<Combobox.Input
className="h-8 w-40 rounded-full border border-slate-300 bg-slate-50 text-sm text-slate-400 outline-none hover:text-slate-600 focus:border-2 focus:border-slate-300 focus:text-slate-600"
autoFocus={true}
value={currentTag}
onChange={(event) => {
setCurrentTag(event.target.value);
}}
/>
<button
type="button"
onClick={() => {
setIsEditingTag("");
setCurrentTag("");
}}
className="absolute top-1/2 right-1.5 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded-full text-slate-400 hover:bg-slate-200 focus:bg-slate-500 focus:text-white focus:outline-none">
<span className="sr-only">Remove large option</span>
<svg className="h-2 w-2" stroke="currentColor" fill="none" viewBox="0 0 8 8">
<path strokeLinecap="round" strokeWidth="1.5" d="M1 1l6 6m0-6L1 7" />
</svg>
</button>
</div>
<Combobox.Options className="absolute z-10 mt-1 max-h-28 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{[...filteredTags, currentTag].filter(onlyUnique).map((tag) => (
<Combobox.Option
key={tag}
value={tag}
className={({ active }) =>
clsx(
"relative cursor-default select-none py-2 pl-3 pr-9",
active ? "bg-slate-500 text-white" : "text-gray-900"
)
}>
{({ selected }) => (
<span className={clsx("block truncate", selected && "font-semibold")}>{tag}</span>
)}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</Combobox>
) : (
<button
type="button"
className="rounded-full border border-dashed border-slate-300 py-1 px-3 text-sm text-slate-400 hover:text-slate-600"
onClick={() => {
setIsEditingTag(submission.id);
}}>
add tag <PlusIcon className="inline h-3 w-3" />
</button>
)}
</li>
</ul>
</div>
);
}

View File

@@ -182,6 +182,8 @@ const benefitingUsers = [
"People who want to protect their finances and personal information from fraud and theft",
];
const tags = ["helpful", "useful", "interesting", "funny", "inspiring", "motivating", "thought-provoking"];
export const getPmfSubmissions = () => {
const submissions = [];
for (let i = 0; i < 142; i++) {
@@ -205,6 +207,7 @@ export const getPmfSubmissions = () => {
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36",
},
tags: [getRandomItem(tags)],
});
}
return submissions;

View File

@@ -1,6 +1,6 @@
import crypto from "crypto";
import { formatDistance } from "date-fns";
import intlFormat from "date-fns/intlFormat";
import { formatDistance, formatDistanceStrict, formatDistanceToNow } from "date-fns";
import platform from "platform";
import { demoEndpoints } from "./demo";
@@ -8,7 +8,6 @@ export const fetcher = async (url) => {
if (url in demoEndpoints) {
const { file } = demoEndpoints[url];
const { getData } = await import(`../demo-data/${file}`);
console.log(url, getData);
return getData(url);
}
const res = await fetch(url);

View File

@@ -27,3 +27,9 @@
background-color: #cbd5e1;
border: 3px solid #cbd5e1;
}
[type="text"]:focus {
--tw-ring-color: none;
--tw-ring-offset-color: none;
box-shadow: none;
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Submission" ADD COLUMN "tags" TEXT[];

View File

@@ -80,6 +80,7 @@ model Submission {
customerOrganisationId String?
data Json @default("{}")
meta Json @default("{}")
tags String[]
}
enum Plan {