From 318f14c4b28a082c79d08e00523221e342002c02 Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Thu, 23 Feb 2023 19:26:24 +0100 Subject: [PATCH] Add tags (#208) * add tag functionality to responses --------- Co-authored-by: Matthias Nannt --- .../formbricks-com/components/docs/Layout.tsx | 2 +- .../web/src/components/forms/NewFormModal.tsx | 2 +- .../forms/custom/CustomTimeline.tsx | 2 + .../forms/feedback/FeedbackTimeline.tsx | 3 + .../src/components/forms/pmf/PMFTimeline.tsx | 14 +- .../forms/shared/FilterNavigation.tsx | 84 +++++++-- .../forms/shared/SubmissionCounter.tsx | 2 +- .../src/components/forms/shared/Tagging.tsx | 169 ++++++++++++++++++ apps/web/src/demo-data/pmf-submissions.ts | 3 + apps/web/src/lib/utils.ts | 3 +- apps/web/src/styles/globals.css | 6 + .../20230222084510_add_tags/migration.sql | 2 + packages/database/prisma/schema.prisma | 1 + 13 files changed, 264 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/components/forms/shared/Tagging.tsx create mode 100644 packages/database/prisma/migrations/20230222084510_add_tags/migration.sql diff --git a/apps/formbricks-com/components/docs/Layout.tsx b/apps/formbricks-com/components/docs/Layout.tsx index c8fa03d14d..39f1ac80d2 100644 --- a/apps/formbricks-com/components/docs/Layout.tsx +++ b/apps/formbricks-com/components/docs/Layout.tsx @@ -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" )}>
diff --git a/apps/web/src/components/forms/NewFormModal.tsx b/apps/web/src/components/forms/NewFormModal.tsx index 4b4bc9cfa8..96afb48349 100644 --- a/apps/web/src/components/forms/NewFormModal.tsx +++ b/apps/web/src/components/forms/NewFormModal.tsx @@ -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] diff --git a/apps/web/src/components/forms/custom/CustomTimeline.tsx b/apps/web/src/components/forms/custom/CustomTimeline.tsx index ad385dd308..e3375d8e40 100644 --- a/apps/web/src/components/forms/custom/CustomTimeline.tsx +++ b/apps/web/src/components/forms/custom/CustomTimeline.tsx @@ -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 }) {
+
diff --git a/apps/web/src/components/forms/feedback/FeedbackTimeline.tsx b/apps/web/src/components/forms/feedback/FeedbackTimeline.tsx index db7f375b14..6c6071a21c 100644 --- a/apps/web/src/components/forms/feedback/FeedbackTimeline.tsx +++ b/apps/web/src/components/forms/feedback/FeedbackTimeline.tsx @@ -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 }) {

+ +
diff --git a/apps/web/src/components/forms/pmf/PMFTimeline.tsx b/apps/web/src/components/forms/pmf/PMFTimeline.tsx index cbd71690cd..1ec2ba2f2e 100644 --- a/apps/web/src/components/forms/pmf/PMFTimeline.tsx +++ b/apps/web/src/components/forms/pmf/PMFTimeline.tsx @@ -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 ) : null} -
+ +
@@ -157,10 +159,12 @@ export default function PMFTimeline({ submissions }) { {parseUserAgent(submission.meta.userAgent)}

-
-

Page

-

{submission.data.pageUrl}

-
+ {submission.data.pageUrl && ( +
+

Page

+

{submission.data.pageUrl}

+
+ )}
diff --git a/apps/web/src/components/forms/shared/FilterNavigation.tsx b/apps/web/src/components/forms/shared/FilterNavigation.tsx index 029a3959cd..bb72f487f1 100644 --- a/apps/web/src/components/forms/shared/FilterNavigation.tsx +++ b/apps/web/src/components/forms/shared/FilterNavigation.tsx @@ -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 ; @@ -198,24 +244,24 @@ export default function FilterNavigation({

{camelToTitle(filter.name)}

{filter.options.map((option) => ( - {!["all", "inbox", "archived"].includes(option.value) && (option.active || option.pinned) && ( )} - +
))}
))} diff --git a/apps/web/src/components/forms/shared/SubmissionCounter.tsx b/apps/web/src/components/forms/shared/SubmissionCounter.tsx index 5b1da91470..01e4795981 100644 --- a/apps/web/src/components/forms/shared/SubmissionCounter.tsx +++ b/apps/web/src/components/forms/shared/SubmissionCounter.tsx @@ -3,7 +3,7 @@ import { RectangleStackIcon } from "@heroicons/react/24/solid"; export function SubmissionCounter({ numFilteredSubmissions, numTotalSubmissions }) { return (
-
+
{numFilteredSubmissions} responses {numFilteredSubmissions !== numTotalSubmissions && (
(out of {numTotalSubmissions})
diff --git a/apps/web/src/components/forms/shared/Tagging.tsx b/apps/web/src/components/forms/shared/Tagging.tsx new file mode 100644 index 0000000000..5e9743decd --- /dev/null +++ b/apps/web/src/components/forms/shared/Tagging.tsx @@ -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 ( +
+
    + {submission.tags.map((tag: string) => ( +
  • + {tag} + +
  • + ))} + +
  • + {isEditingTag && submission.id === isEditingTag ? ( + 0 && currentTag ? filteredTags[0] : currentTag} + onChange={(value) => { + addTag(submission, value); + }}> +
    +
    + { + setCurrentTag(event.target.value); + }} + /> + +
    + + + {[...filteredTags, currentTag].filter(onlyUnique).map((tag) => ( + + clsx( + "relative cursor-default select-none py-2 pl-3 pr-9", + active ? "bg-slate-500 text-white" : "text-gray-900" + ) + }> + {({ selected }) => ( + {tag} + )} + + ))} + +
    +
    + ) : ( + + )} +
  • +
+
+ ); +} diff --git a/apps/web/src/demo-data/pmf-submissions.ts b/apps/web/src/demo-data/pmf-submissions.ts index 8278ae09a5..7dc639e2fe 100644 --- a/apps/web/src/demo-data/pmf-submissions.ts +++ b/apps/web/src/demo-data/pmf-submissions.ts @@ -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; diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 1a5e144086..80bcde57bd 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -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); diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 1ffef5871a..2a2cbcfe9e 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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; +} diff --git a/packages/database/prisma/migrations/20230222084510_add_tags/migration.sql b/packages/database/prisma/migrations/20230222084510_add_tags/migration.sql new file mode 100644 index 0000000000..6dd8eb72d1 --- /dev/null +++ b/packages/database/prisma/migrations/20230222084510_add_tags/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Submission" ADD COLUMN "tags" TEXT[]; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 9cf11d6dbd..c5ac93e8f4 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -80,6 +80,7 @@ model Submission { customerOrganisationId String? data Json @default("{}") meta Json @default("{}") + tags String[] } enum Plan {