mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
Add tags (#208)
* add tag functionality to responses --------- Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
169
apps/web/src/components/forms/shared/Tagging.tsx
Normal file
169
apps/web/src/components/forms/shared/Tagging.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Submission" ADD COLUMN "tags" TEXT[];
|
||||
@@ -80,6 +80,7 @@ model Submission {
|
||||
customerOrganisationId String?
|
||||
data Json @default("{}")
|
||||
meta Json @default("{}")
|
||||
tags String[]
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
|
||||
Reference in New Issue
Block a user