Add tagging functionality to responses (#426)

* fat: added prisma model for Tag

* feat: adds api route for tags

* fat: added prisma model for Tag

* feat: adds mutation hook for creating a tag

* feat: adds apis for creating and retrieving tags

* feat: adds sample UI for creating and retrieving tags

* feat: adds UI components for Combobox

* feat: adds api router for fetching all tags for a product

* feat: adds combobox and api for appending tag to a response

* feat: adds api call for removing a tag from a response

* fix: relaced normal post with swr mutations

* fix: mutations for adding and deleting tags

* feat: integrated the create and delete tags apis and combobox

* fix: fixes api routes and db queries for tags apis

* fix: fixes api routes and headers

* feat: adds tag delete functionality

* feat: adds update tag api and UI

* feat: adds tags count api and integration

* feat: inital UI for tags table

* fix: UI for autosave name component

* fix: fixes api response

* fix: fixes errors on merge tags

* fat: added prisma model for Tag

* fix: replaces lodash.debounce with lodash

* fix: fixes capital letter tags not getting added

* fix: changed tag table to relate to environment

* fix: migrated tag apis from product to environment

* fix: formatting with prettier

* fix: fixes tags interface in single response

* fix: fixes UI bugs

* fix: fixes text on no tags

* fix: deleted local migrations

* fix: synced migrations with main

* fix: fixes combobox bugs

* fix: fixes placeholder

* update migrations

* fix build issues

* fix tag adding functionality

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2023-06-25 19:13:54 +05:30
committed by GitHub
parent 7ed24c9f49
commit 2e662f98b9
38 changed files with 2046 additions and 41 deletions

View File

@@ -1,4 +1,4 @@
'use client'
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";

View File

@@ -14,6 +14,7 @@ import {
KeyIcon,
LinkIcon,
PaintBrushIcon,
HashtagIcon,
UserCircleIcon,
UsersIcon,
} from "@heroicons/react/24/solid";
@@ -26,7 +27,22 @@ export default function SettingsNavbar({ environmentId }: { environmentId: strin
const pathname = usePathname();
const { team } = useTeam(environmentId);
const { product } = useProduct(environmentId);
const navigation = useMemo(
interface NavigationLink {
name: string;
href: string;
icon: React.ComponentType<any>;
current?: boolean;
hidden: boolean;
target?: string;
}
interface NavigationSection {
title: string;
links: NavigationLink[];
}
// Then, specify the type of the navigation array
const navigation: NavigationSection[] = useMemo(
() => [
{
title: "Account",
@@ -71,6 +87,13 @@ export default function SettingsNavbar({ environmentId }: { environmentId: strin
current: pathname?.includes("/api-keys"),
hidden: false,
},
{
name: "Tags",
href: `/environments/${environmentId}/settings/tags`,
icon: HashtagIcon,
current: pathname?.includes("/tags"),
hidden: false,
},
],
},
{
@@ -83,13 +106,6 @@ export default function SettingsNavbar({ environmentId }: { environmentId: strin
current: pathname?.includes("/members"),
hidden: false,
},
/*
{
name: "Tags",
href: `/environments/${environmentId}/settings/tags`,
icon: PlusCircleIcon,
current: pathname?.includes("/tags"),
}, */
{
name: "Billing & Plan",
href: `/environments/${environmentId}/settings/billing`,

View File

@@ -0,0 +1,174 @@
"use client";
import MergeTagsCombobox from "@/app/environments/[environmentId]/settings/tags/MergeTagsCombobox";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useDeleteTag, useMergeTags, useUpdateTag } from "@/lib/tags/mutateTags";
import { useTagsCountForEnvironment, useTagsForEnvironment } from "@/lib/tags/tags";
import { cn } from "@formbricks/lib/cn";
import { Button, Input } from "@formbricks/ui";
import React from "react";
import { toast } from "react-hot-toast";
interface IEditTagsWrapperProps {
environmentId: string;
}
const SingleTag: React.FC<{
tagId: string;
tagName: string;
environmentId: string;
tagCount?: number;
tagCountLoading?: boolean;
updateTagsCount?: () => void;
}> = ({
environmentId,
tagId,
tagName,
tagCount = 0,
tagCountLoading = false,
updateTagsCount = () => {},
}) => {
const { mutate: refetchEnvironmentTags, data: environmentTags } = useTagsForEnvironment(environmentId);
const { deleteTag, isDeletingTag } = useDeleteTag(environmentId, tagId);
const { updateTag, updateTagError } = useUpdateTag(environmentId, tagId);
const { mergeTags, isMergingTags } = useMergeTags(environmentId);
return (
<div className="w-full" key={tagId}>
<div className="m-2 grid h-16 grid-cols-4 content-center rounded-lg">
<div className="col-span-2 flex items-center text-sm">
<div className="w-full text-left">
<Input
className={cn(
"w-full border font-medium text-slate-900",
updateTagError
? "border-red-500 focus:border-red-500"
: "border-slate-200 focus:border-slate-500"
)}
defaultValue={tagName}
onBlur={(e) => {
updateTag(
{ name: e.target.value.trim() },
{
onSuccess: () => {
toast.success("Tag updated");
refetchEnvironmentTags();
},
onError: (error) => {
toast.error(error?.message ?? "Failed to update tag");
},
}
);
}}
/>
</div>
</div>
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
</div>
<div className="col-span-1 my-auto flex items-center justify-end gap-4 whitespace-nowrap text-center text-sm text-slate-500">
<div>
{isMergingTags ? (
<div className="w-24">
<LoadingSpinner />
</div>
) : (
<MergeTagsCombobox
tags={
environmentTags
?.filter((tag) => tag.id !== tagId)
?.map((tag) => ({ label: tag.name, value: tag.id })) ?? []
}
onSelect={(newTagId) => {
mergeTags(
{
originalTagId: tagId,
newTagId,
},
{
onSuccess: () => {
toast.success("Tags merged");
refetchEnvironmentTags();
updateTagsCount();
},
}
);
}}
/>
)}
</div>
<div>
<Button
variant="alert"
size="sm"
loading={isDeletingTag}
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
onClick={() => {
if (confirm("Are you sure you want to delete this tag?")) {
deleteTag(null, {
onSuccess: () => {
toast.success("Tag deleted");
refetchEnvironmentTags();
updateTagsCount();
},
});
}
}}>
Delete
</Button>
</div>
</div>
</div>
</div>
);
};
const EditTagsWrapper: React.FC<IEditTagsWrapperProps> = (props) => {
const { environmentId } = props;
const { data: environmentTags, isLoading: isLoadingEnvironmentTags } = useTagsForEnvironment(environmentId);
const { tagsCount, isLoadingTagsCount, mutateTagsCount } = useTagsCountForEnvironment(environmentId);
if (isLoadingEnvironmentTags) {
return (
<div className="text-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="flex w-full flex-col gap-4">
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-4 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 pl-6">Name</div>
<div className="col-span-1 text-center">Count</div>
<div className="col-span-1 mr-4 flex justify-center text-center">Actions</div>
</div>
{!environmentTags?.length ? (
<EmptySpaceFiller environmentId={environmentId} type="tag" noWidgetRequired />
) : null}
{environmentTags?.map((tag) => (
<SingleTag
key={tag.id}
environmentId={environmentId}
tagId={tag.id}
tagName={tag.name}
tagCount={tagsCount?.find((count) => count.tagId === tag.id)?.count ?? 0}
tagCountLoading={isLoadingTagsCount}
updateTagsCount={mutateTagsCount}
/>
))}
</div>
</div>
);
};
export default EditTagsWrapper;

View File

@@ -0,0 +1,70 @@
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
Popover,
PopoverContent,
PopoverTrigger,
} from "@formbricks/ui";
import { useState } from "react";
interface IMergeTagsComboboxProps {
tags: Tag[];
onSelect: (tagId: string) => void;
}
type Tag = {
label: string;
value: string;
};
const MergeTagsCombobox: React.FC<IMergeTagsComboboxProps> = ({ tags, onSelect }) => {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="sm"
className="font-medium text-slate-900 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent">
Merge
</Button>
</PopoverTrigger>
<PopoverContent className="max-h-60 w-[200px] overflow-y-auto p-0">
<Command>
<div className="p-1">
<CommandInput
placeholder="Search Tags..."
className="border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
</div>
<CommandEmpty>
<div className="p-2 text-sm text-slate-500">No tag found</div>
</CommandEmpty>
<CommandGroup>
{tags?.length === 0 ? <CommandItem>No tags found</CommandItem> : null}
{tags?.map((tag) => (
<CommandItem
key={tag.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
onSelect(tag.value);
}}>
{tag.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};
export default MergeTagsCombobox;

View File

@@ -0,0 +1,11 @@
import EditTagsWrapper from "./EditTagsWrapper";
import SettingsTitle from "../SettingsTitle";
export default function MembersSettingsPage({ params }) {
return (
<div>
<SettingsTitle title="Tags" />
<EditTagsWrapper environmentId={params.environmentId} />
</div>
);
}

View File

@@ -37,7 +37,6 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
}
}, [survey]);
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
return <LoadingSpinner />;
}

View File

@@ -12,7 +12,7 @@ import { ArrowLeftIcon, Cog8ToothIcon, ExclamationTriangleIcon } from "@heroicon
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { isEqual } from 'lodash';
import { isEqual } from "lodash";
interface SurveyMenuBarProps {
localSurvey: Survey;
@@ -46,10 +46,10 @@ export default function SurveyMenuBar({
useEffect(() => {
const warningText = "You have unsaved changes - are you sure you wish to leave this page?";
const handleWindowClose = (e: BeforeUnloadEvent) => {
if(!isEqual(localSurvey, survey)){
const handleWindowClose = (e: BeforeUnloadEvent) => {
if (!isEqual(localSurvey, survey)) {
e.preventDefault();
return e.returnValue = warningText
return (e.returnValue = warningText);
}
};

View File

@@ -0,0 +1,201 @@
import { useResponses } from "@/lib/responses/responses";
import { removeTagFromResponse, useAddTagToResponse } from "@/lib/tags/mutateTags";
import { useCreateTag } from "@/lib/tags/mutateTags";
import { useTagsForEnvironment } from "@/lib/tags/tags";
import React from "react";
import { useState } from "react";
import { XCircleIcon } from "@heroicons/react/24/solid";
import TagsCombobox from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/TagsCombobox";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { useEffect } from "react";
interface ResponseTagsWrapperProps {
tags: {
tagId: string;
tagName: string;
}[];
environmentId: string;
surveyId: string;
productId: string;
responseId: string;
}
export function Tag({
tagId,
tagName,
onDelete,
tags,
setTagsState,
highlight,
}: {
tagId: string;
tagName: string;
onDelete: (tagId: string) => void;
tags: ResponseTagsWrapperProps["tags"];
setTagsState: (tags: ResponseTagsWrapperProps["tags"]) => void;
highlight?: boolean;
}) {
return (
<div
key={tagId}
className={cn(
"relative flex items-center justify-between gap-2 rounded-full border bg-slate-600 px-2 py-1 text-slate-100",
highlight && "border-2 border-green-600"
)}>
<div className="flex items-center gap-2">
<span className="text-sm">{tagName}</span>
</div>
<span
className="cursor-pointer text-sm"
onClick={() => {
setTagsState(tags.filter((tag) => tag.tagId !== tagId));
onDelete(tagId);
}}>
<XCircleIcon fontSize={24} className="h-4 w-4 text-slate-100 hover:text-slate-200" />
</span>
</div>
);
}
const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
tags,
environmentId,
responseId,
surveyId,
}) => {
const [searchValue, setSearchValue] = useState("");
const [open, setOpen] = React.useState(false);
const [tagsState, setTagsState] = useState(tags);
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
const { createTag } = useCreateTag(environmentId);
const { mutateResponses } = useResponses(environmentId, surveyId);
const { data: environmentTags, mutate: refetchEnvironmentTags } = useTagsForEnvironment(environmentId);
const { addTagToRespone } = useAddTagToResponse(environmentId, surveyId, responseId);
const onDelete = async (tagId: string) => {
try {
await removeTagFromResponse(environmentId, surveyId, responseId, tagId);
mutateResponses();
} catch (e) {
console.log(e);
}
};
useEffect(() => {
const timeoutId = setTimeout(() => {
if (tagIdToHighlight) {
setTagIdToHighlight("");
}
}, 2000);
return () => clearTimeout(timeoutId);
}, [tagIdToHighlight]);
return (
<div className="flex items-start gap-3 p-6">
<div className="flex flex-wrap items-center gap-2">
{tagsState?.map((tag) => (
<Tag
key={tag.tagId}
onDelete={onDelete}
tagId={tag.tagId}
tagName={tag.tagName}
tags={tagsState}
setTagsState={setTagsState}
highlight={tagIdToHighlight === tag.tagId}
/>
))}
<TagsCombobox
open={open}
setOpen={setOpen}
searchValue={searchValue}
setSearchValue={setSearchValue}
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
currentTags={tags.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={(tagName) => {
createTag(
{
name: tagName?.trim() ?? "",
},
{
onSuccess: (data) => {
setTagsState((prevTags) => [
...prevTags,
{
tagId: data.id,
tagName: data.name,
},
]);
addTagToRespone(
{
tagIdToAdd: data.id,
},
{
onSuccess: () => {
setSearchValue("");
setOpen(false);
mutateResponses();
refetchEnvironmentTags();
},
}
);
},
onError: (err) => {
toast.error(err?.message ?? "Something went wrong", {
duration: 2000,
});
setSearchValue("");
setOpen(false);
mutateResponses();
const tag = tags.find((tag) => tag.tagName === tagName?.trim() ?? "");
setTagIdToHighlight(tag?.tagId ?? "");
refetchEnvironmentTags();
},
}
);
}}
addTag={(tagId) => {
setTagsState((prevTags) => [
...prevTags,
{
tagId,
tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
},
]);
addTagToRespone(
{
tagIdToAdd: tagId,
},
{
onSuccess: () => {
setSearchValue("");
setOpen(false);
mutateResponses();
refetchEnvironmentTags();
},
}
);
}}
/>
</div>
</div>
);
};
export default ResponseTagsWrapper;

View File

@@ -13,10 +13,12 @@ import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { getTodaysDateFormatted } from "@formbricks/lib/time";
import { useProduct } from "@/lib/products/products";
export default function ResponseTimeline({ environmentId, surveyId }) {
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
const { product } = useProduct(environmentId);
const responses = responsesData?.responses;
@@ -56,7 +58,13 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
}
return { ...response, responses: updatedResponse, person: response.person };
});
return updatedResponses;
const updatedResponsesWithTags = updatedResponses.map((response) => ({
...response,
tags: response.tags?.map((tag) => tag.tag),
}));
return updatedResponsesWithTags;
}
return [];
}, [survey, responses]);
@@ -183,6 +191,7 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
data={updatedResponse}
surveyId={surveyId}
environmentId={environmentId}
productId={product?.id ?? ""}
/>
);
})}

View File

@@ -12,6 +12,8 @@ import { RatingResponse } from "../RatingResponse";
import { deleteSubmission, useResponses } from "@/lib/responses/responses";
import clsx from "clsx";
import ResponseNote from "./ResponseNote";
import ResponseTagsWrapper from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTagsWrapper";
import { TTag } from "@formbricks/types/v1/tags";
import { QuestionType } from "@formbricks/types/questions";
export interface OpenTextSummaryProps {
@@ -38,6 +40,7 @@ export interface OpenTextSummaryProps {
name: string;
};
}[];
tags: TTag[];
value: string;
updatedAt: string;
finished: boolean;
@@ -52,6 +55,7 @@ export interface OpenTextSummaryProps {
};
environmentId: string;
surveyId: string;
productId: string;
}
function findEmail(person) {
@@ -59,7 +63,7 @@ function findEmail(person) {
return emailAttribute ? emailAttribute.value : null;
}
export default function SingleResponse({ data, environmentId, surveyId }: OpenTextSummaryProps) {
export default function SingleResponse({ data, environmentId, surveyId, productId }: OpenTextSummaryProps) {
const email = data.person && findEmail(data.person);
const displayIdentifier = email || data.personId;
const responseNotes = data?.responseNotes;
@@ -159,6 +163,16 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
</div>
))}
</div>
<ResponseTagsWrapper
environmentId={environmentId}
surveyId={surveyId}
productId={productId}
responseId={data.id}
tags={data.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
key={data.tags.map((tag) => tag.id).join("-")}
/>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
@@ -173,6 +187,7 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
surveyId={surveyId}
isOpen={isOpen}
setIsOpen={setIsOpen}
productId={productId}
/>
</div>
);

View File

@@ -0,0 +1,134 @@
"use client";
import {
Button,
Command,
CommandGroup,
CommandInput,
CommandItem,
Popover,
PopoverContent,
PopoverTrigger,
} from "@formbricks/ui";
import { useEffect, useMemo } from "react";
interface ITagsComboboxProps {
tags: Tag[];
currentTags: Tag[];
addTag: (tagId: string) => void;
createTag?: (tagName: string) => void;
searchValue: string;
setSearchValue: React.Dispatch<React.SetStateAction<string>>;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
type Tag = {
label: string;
value: string;
};
const TagsCombobox: React.FC<ITagsComboboxProps> = ({
tags,
currentTags,
addTag,
createTag,
searchValue,
setSearchValue,
open,
setOpen,
}) => {
const tagsToSearch = useMemo(
() =>
tags.filter((tag) => {
const found = currentTags.findIndex(
(currentTag) => currentTag.value.toLowerCase() === tag.value.toLowerCase()
);
return found === -1;
}),
[currentTags, tags]
);
useEffect(() => {
// reset search value and value when closing the combobox
if (!open) {
setSearchValue("");
}
}, [open, setSearchValue]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="darkCTA" size="sm" aria-expanded={open}>
Add Tag
</Button>
</PopoverTrigger>
<PopoverContent className="max-h-60 w-[200px] overflow-y-auto p-0">
<Command
filter={(value, search) => {
if (value === "_create") {
return 1;
}
const foundLabel = tagsToSearch.find((tag) => tag.value.toLowerCase() === value)?.label ?? "";
if (foundLabel.toLowerCase().includes(search.toLowerCase())) {
return 1;
}
return 0;
}}>
<div className="p-1">
<CommandInput
placeholder={tagsToSearch?.length === 0 ? "Add tag..." : "Search or add tags..."}
className="border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
value={searchValue}
onValueChange={(search) => setSearchValue(search)}
onKeyDown={(e) => {
if (e.key === "Enter" && searchValue !== "") {
if (
!tagsToSearch?.find((tag) =>
tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
)
) {
createTag?.(searchValue);
}
}
}}
/>
</div>
<CommandGroup>
{tagsToSearch?.map((tag) => {
return (
<CommandItem
key={tag.value}
value={tag.value}
onSelect={(currentValue) => {
setOpen(false);
addTag(currentValue);
}}
className="hover:cursor-pointer hover:bg-slate-50">
{tag.label}
</CommandItem>
);
})}
{searchValue !== "" &&
!currentTags.find((tag) => tag.label === searchValue) &&
!tagsToSearch.find((tag) => tag.label === searchValue) && (
<CommandItem value="_create">
<button
onClick={() => createTag?.(searchValue)}
className="h-8 w-full text-left hover:cursor-pointer hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!!currentTags.find((tag) => tag.label === searchValue)}>
+ Add {searchValue}
</button>
</CommandItem>
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};
export default TagsCombobox;

View File

@@ -31,5 +31,6 @@
input:focus {
--tw-ring-color: none;
--tw-ring-offset-color: none;
--tw-ring-shadow: 0 0 #000 !important;
box-shadow: none;
}

View File

@@ -6,7 +6,7 @@ import { useEnvironment } from "@/lib/environments/environments";
import LoadingSpinner from "./LoadingSpinner";
type EmptySpaceFillerProps = {
type: "table" | "response" | "event" | "linkResponse";
type: "table" | "response" | "event" | "linkResponse" | "tag";
environmentId: string;
noWidgetRequired?: boolean;
};
@@ -43,6 +43,7 @@ const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({ type, environmentId
</div>
);
}
if (type === "response") {
return (
<div className="group space-y-4 rounded-lg bg-white p-6 ">
@@ -73,6 +74,36 @@ const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({ type, environmentId
</div>
);
}
if (type === "tag") {
return (
<div className="group space-y-4 rounded-lg bg-white p-6 ">
<div className="flex items-center space-x-4">
<div className="h-12 w-12 flex-shrink-0 rounded-full bg-slate-100"></div>
<div className=" h-6 w-full rounded-full bg-slate-100"></div>
</div>
<div className="space-y-4">
<div className="h-12 w-full rounded-full bg-slate-100"></div>
<div className=" flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100">
{!environment.widgetSetupCompleted && !noWidgetRequired && (
<Link
className="flex h-full w-full items-center justify-center"
href={`/environments/${environmentId}/settings/setup`}>
<span className="decoration-brand-dark underline transition-all duration-300 ease-in-out">
Install Formbricks Widget. <strong>Go to Setup Checklist 👉</strong>
</span>
</Link>
)}
{(environment.widgetSetupCompleted || noWidgetRequired) && (
<span className="text-center">Tag a submission to find your list of tags here.</span>
)}
</div>
<div className="h-12 w-full rounded-full bg-slate-50/50"></div>
</div>
</div>
);
}
return (
<div className="group space-y-4 rounded-lg bg-white p-6 ">
<div className="flex items-center space-x-4">
@@ -101,8 +132,6 @@ const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({ type, environmentId
</div>
</div>
);
return null;
};
export default EmptySpaceFiller;

View File

@@ -7,7 +7,11 @@ import { getServerSession } from "next-auth";
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
export const hasEnvironmentAccess = async (req, res, environmentId) => {
export const hasEnvironmentAccess = async (
req: NextApiRequest,
res: NextApiResponse,
environmentId: string
) => {
if (req.headers["x-api-key"]) {
const ownership = await hasApiEnvironmentAccess(req.headers["x-api-key"].toString(), environmentId);
if (!ownership) {

View File

@@ -0,0 +1,147 @@
import { TTag } from "@formbricks/types/v1/tags";
import useSWRMutation from "swr/mutation";
export const useCreateTag = (environmentId: string) => {
const { trigger: createTag, isMutating: isCreatingTag } = useSWRMutation(
`/api/v1/environments/${environmentId}/tags`,
async (url, { arg }: { arg: { name: string } }): Promise<TTag> => {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: arg.name }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message);
}
return response.json();
}
);
return {
createTag,
isCreatingTag,
};
};
export const useAddTagToResponse = (environmentId: string, surveyId: string, responseId: string) => {
const { trigger: addTagToRespone, isMutating: isLoadingAddTagToResponse } = useSWRMutation(
`/api/v1/environments/${environmentId}/surveys/${surveyId}/responses/${responseId}/tags`,
async (url, { arg }: { arg: { tagIdToAdd: string } }): Promise<{ success: boolean; message: string }> => {
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ tagId: arg.tagIdToAdd }),
}).then((res) => res.json());
}
);
return {
addTagToRespone,
isLoadingAddTagToResponse,
};
};
export const removeTagFromResponse = async (
environmentId: string,
surveyId: string,
responseId: string,
tagId: string
) => {
const response = await fetch(
`/api/v1/environments/${environmentId}/surveys/${surveyId}/responses/${responseId}/tags/${tagId}`,
{
method: "DELETE",
}
);
return response.json();
};
export const useDeleteTag = (environmentId: string, tagId: string) => {
const { trigger: deleteTag, isMutating: isDeletingTag } = useSWRMutation(
`/api/v1/environments/${environmentId}/tags/${tagId}`,
async (url): Promise<TTag> => {
return fetch(url, {
method: "DELETE",
}).then((res) => res.json());
}
);
return {
deleteTag,
isDeletingTag,
};
};
export const useUpdateTag = (environmentId: string, tagId: string) => {
const {
trigger: updateTag,
isMutating: isUpdatingTag,
data: updateTagData,
error: updateTagError,
} = useSWRMutation(
`/api/v1/environments/${environmentId}/tags/${tagId}`,
async (url, { arg }: { arg: { name: string } }): Promise<TTag> => {
const res = await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: arg.name }),
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message);
}
return res.json();
}
);
return {
updateTag,
isUpdatingTag,
updateTagData,
updateTagError,
};
};
export const useMergeTags = (environmentId: string) => {
const { trigger: mergeTags, isMutating: isMergingTags } = useSWRMutation(
`/api/v1/environments/${environmentId}/tags/merge`,
async (
url,
{ arg }: { arg: { originalTagId: string; newTagId: string } }
): Promise<{ status: boolean; message: string }> => {
const response = await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ originalTagId: arg.originalTagId, newTagId: arg.newTagId }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message);
}
return response.json();
}
);
return {
mergeTags,
isMergingTags,
};
};

30
apps/web/lib/tags/tags.ts Normal file
View File

@@ -0,0 +1,30 @@
import { fetcher } from "@formbricks/lib/fetcher";
import useSWR from "swr";
import { TTag, TTagsCount } from "@formbricks/types/v1/tags";
import { useMemo } from "react";
export const useTagsForEnvironment = (environmentId: string) => {
const tagsForProducts = useSWR<TTag[]>(`/api/v1/environments/${environmentId}/tags`, fetcher);
return tagsForProducts;
};
export const useTagsCountForEnvironment = (environmentId: string) => {
const {
data: tagsCount,
isLoading: isLoadingTagsCount,
mutate: mutateTagsCount,
} = useSWR<TTagsCount>(`/api/v1/environments/${environmentId}/tags/count`, fetcher);
const transformedTagsCount = useMemo(() => {
if (!tagsCount) return [];
return tagsCount.map((tagCount) => ({ tagId: tagCount.tagId, count: tagCount._count._all }));
}, [tagsCount]);
return {
tagsCount: transformedTagsCount,
isLoadingTagsCount,
mutateTagsCount,
};
};

View File

@@ -5,6 +5,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query?.environmentId?.toString();
if (!environmentId) {
return res.status(400).json({ message: "Missing environmentId" });
}
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "Not authorized" });

View File

@@ -5,6 +5,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query?.environmentId?.toString();
if (!environmentId) {
return res.status(400).json({ message: "Missing environmentId" });
}
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "Not authorized" });

View File

@@ -6,6 +6,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query?.environmentId?.toString();
if (!environmentId) {
return res.status(400).json({ message: "Missing environmentId" });
}
const currentUser: any = await getSessionUser(req, res);
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);

View File

@@ -1,11 +1,21 @@
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import { prisma } from "@formbricks/database/src/client";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
const surveyId = req.query.surveyId?.toString();
const responseId = req.query.responseId?.toString();
const responseId = req.query.submissionId?.toString();
if (!environmentId) {
return res.status(400).json({ message: "Missing environmentId" });
}
if (!surveyId) {
return res.status(400).json({ message: "Missing surveyId" });
}
if (!responseId) {
return res.status(400).json({ message: "Missing responseId" });
}
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {

View File

@@ -0,0 +1,67 @@
import { hasEnvironmentAccess, getSessionUser } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database/src/client";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
const responseId = req.query.submissionId?.toString();
const surveyId = req.query.surveyId?.toString();
const tagId = req.query.tagId?.toString();
// Check Authentication
const currentUser = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
// Check environmentId
if (!environmentId) {
return res.status(400).json({ message: "Invalid environmentId" });
}
// Check responseId
if (!responseId) {
return res.status(400).json({ message: "Invalid responseId" });
}
// Check surveyId
if (!surveyId) {
return res.status(400).json({ message: "Invalid surveyId" });
}
// Check tagId
if (!tagId) {
return res.status(400).json({ message: "Invalid tagId" });
}
// Check whether user has access to the environment
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "You are not authorized to access this environment! " });
}
if (req.method === "DELETE") {
let deletedTag;
try {
deletedTag = await prisma.tagsOnResponses.delete({
where: {
responseId_tagId: {
responseId,
tagId,
},
},
});
} catch (e) {
return res.status(500).json({ message: "Internal Server Error" });
}
return res.json(deletedTag);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,122 @@
import { captureTelemetry } from "@/../../packages/lib/telemetry";
import { hasEnvironmentAccess, getSessionUser } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
import { responses } from "@/lib/api/response";
import { Prisma } from "@prisma/client";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
const responseId = req.query.submissionId?.toString();
const surveyId = req.query.surveyId?.toString();
// Check Authentication
const currentUser = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
// Check environmentId
if (!environmentId) {
return res.status(400).json({ message: "Invalid environmentId" });
}
// Check responseId
if (!responseId) {
return res.status(400).json({ message: "Invalid responseId" });
}
// Check surveyId
if (!surveyId) {
return res.status(400).json({ message: "Invalid surveyId" });
}
// Check whether user has access to the environment
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "You are not authorized to access this environment! " });
}
const currentResponse = await prisma.response.findUnique({
where: {
id: responseId,
},
select: {
data: true,
survey: {
select: {
environmentId: true,
},
},
},
});
if (!currentResponse) {
return responses.notFoundResponse("Response", responseId, true);
}
// GET /api/environments[environmentId]/survey[surveyId]/responses/[submissionId]/tags
// Get all tags for a response
if (req.method === "GET") {
let tags;
try {
tags = await prisma.tagsOnResponses.findMany({
where: {
responseId,
},
include: {
response: true,
tag: true,
},
});
} catch (e) {
return res.status(500).json({ message: "Internal Server Error" });
}
captureTelemetry(`tags retrieved for response ${responseId}`);
return res.json(tags);
}
// POST /api/environments[environmentId]/survey[surveyId]/responses/[submissionId]/tags
// Create a tag for a response
if (req.method === "POST") {
const tagId = req.body.tagId;
if (!tagId) {
return res.status(400).json({ message: "Invalid tag Id" });
}
try {
await prisma.tagsOnResponses.create({
data: {
responseId,
tagId,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
return res.status(400).json({ message: "Tag already exists" });
}
}
return res.status(500).json({ message: "Internal Server Error" });
}
return res.json({
success: true,
message: `Tag ${tagId} created for response ${responseId}`,
});
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -55,6 +55,19 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
user: true,
},
},
tags: {
select: {
tag: {
select: {
name: true,
createdAt: true,
environmentId: true,
id: true,
updatedAt: true,
},
},
},
},
},
});

View File

@@ -0,0 +1,94 @@
import { hasEnvironmentAccess, getSessionUser } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database/src/client";
import { DatabaseError } from "@formbricks/errors";
import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
const tagId = req.query.tagId?.toString();
// Check Authentication
const currentUser = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
// Check environmentId
if (!environmentId) {
return res.status(400).json({ message: "Invalid environmentId" });
}
// Check tagId
if (!tagId) {
return res.status(400).json({ message: "Invalid tagId" });
}
// Check whether user has access to the environment
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "You are not authorized to access this environment! " });
}
// PATCH /api/environments/[environmentId]/product/[productId]/tags/[tagId]
// Update a tag for a product
if (req.method === "PATCH") {
const { name } = req.body;
if (!name) {
return res.status(400).json({ message: "Invalid name" });
}
let tag: TTag;
try {
tag = await prisma.tag.update({
where: {
id: tagId,
},
data: {
name: name,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
res.status(400).send({ message: "Tag already exists" });
}
throw new DatabaseError("Database operation failed");
}
throw error;
}
return res.json(tag);
}
// DELETE /api/environments/[environmentId]/tags/[tagId]
// Delete a tag for a product
if (req.method === "DELETE") {
let tag: TTag;
try {
tag = await prisma.tag.delete({
where: {
id: tagId,
},
});
} catch (e) {
return res.status(500).json({ message: "Internal Server Error" });
}
return res.json(tag);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,85 @@
import { hasEnvironmentAccess, getSessionUser } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database/src/client";
import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
// Check Authentication
const currentUser = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
// Check environmentId
if (!environmentId) {
return res.status(400).json({ message: "Invalid environmentId" });
}
// Check whether user has access to the environment
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "You are not authorized to access this environment! " });
}
// GET /api/environments/[environmentId]/tags
// Get all tags for an environment
if (req.method === "GET") {
let tagsCounts;
try {
tagsCounts = await prisma.tagsOnResponses.groupBy({
by: ["tagId"],
_count: {
_all: true,
},
});
} catch (e) {
return res.status(500).json({ message: "Internal Server Error" });
}
return res.json(tagsCounts);
}
// POST /api/environments/[environmentId]/tags
// Create a new tag for a product
if (req.method === "POST") {
const name = req.body.name;
if (!name) {
return res.status(400).json({ message: "Invalid name" });
}
let tag: TTag;
try {
tag = await prisma.tag.create({
data: {
name,
environmentId,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
return res.status(400).json({ message: "Tag already exists" });
}
}
return res.status(500).json({ message: "Internal Server Error" });
}
return res.json(tag);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,86 @@
import { captureTelemetry } from "@/../../packages/lib/telemetry";
import { hasEnvironmentAccess, getSessionUser } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database/src/client";
import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
// Check Authentication
const currentUser = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
// Check environmentId
if (!environmentId) {
return res.status(400).json({ message: "Invalid environmentId" });
}
// Check whether user has access to the environment
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "You are not authorized to access this environment! " });
}
// GET /api/environments/[environmentId]/tags
// Get all tags for an environment
if (req.method === "GET") {
let tags;
try {
tags = await prisma.tag.findMany({
where: {
environmentId,
},
});
} catch (e) {
return res.status(500).json({ message: "Internal Server Error" });
}
captureTelemetry(`tags retrived for ${environmentId}`);
return res.json(tags);
}
// POST /api/environments/[environmentId]/product/[productId]/tags
// Create a new tag for an environment
if (req.method === "POST") {
const name = req.body.name;
if (!name) {
return res.status(400).json({ message: "Invalid name" });
}
let tag: TTag;
try {
tag = await prisma.tag.create({
data: {
name,
environmentId,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
return res.status(400).json({ message: "Tag already exists" });
}
}
return res.status(500).json({ message: "Internal Server Error" });
}
return res.json(tag);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,161 @@
import { hasEnvironmentAccess, getSessionUser } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database/src/client";
import { TTag } from "@formbricks/types/v1/tags";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
// Check Authentication
const currentUser = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
// Check environmentId
if (!environmentId) {
return res.status(400).json({ message: "Invalid environmentId" });
}
// Check whether user has access to the environment
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "You are not authorized to access this environment! " });
}
// POST /api/environments/[environmentId]/tags/merge
// Merge tags together
if (req.method === "PATCH") {
const { originalTagId, newTagId } = req.body;
if (!originalTagId) {
return res.status(400).json({ message: "Invalid Tag Id" });
}
if (!newTagId) {
return res.status(400).json({ message: "Invalid Tag Id" });
}
let originalTag: TTag | null;
originalTag = await prisma.tag.findUnique({
where: {
id: originalTagId,
},
});
if (!originalTag) {
return res.status(404).json({ message: "Tag not found" });
}
let newTag: TTag | null;
newTag = await prisma.tag.findUnique({
where: {
id: newTagId,
},
});
if (!newTag) {
return res.status(404).json({ message: "Tag not found" });
}
// finds all the responses that have both the tags
let responsesWithBothTags = await prisma.response.findMany({
where: {
AND: [
{
tags: {
some: {
tagId: {
in: [originalTagId],
},
},
},
},
{
tags: {
some: {
tagId: {
in: [newTagId],
},
},
},
},
],
},
});
if (!!responsesWithBothTags?.length) {
try {
responsesWithBothTags.map(async (response) => {
await prisma.$transaction([
prisma.tagsOnResponses.deleteMany({
where: {
responseId: response.id,
tagId: {
in: [originalTagId, newTagId],
},
},
}),
prisma.tagsOnResponses.create({
data: {
responseId: response.id,
tagId: newTagId,
},
}),
]);
});
await prisma.tag.delete({
where: {
id: originalTagId,
},
});
return res.json({
success: true,
message: "Tag merged successfully",
});
} catch (err) {
return res.status(500).json({ message: "Internal Server Error" });
}
}
try {
await prisma.$transaction([
prisma.tagsOnResponses.updateMany({
where: {
tagId: originalTagId,
},
data: {
tagId: newTagId,
},
}),
prisma.tag.delete({
where: {
id: originalTagId,
},
}),
]);
} catch (e) {
return res.status(500).json({ message: "Internal Server Error" });
}
return res.json({
success: true,
message: "Tag merged successfully",
});
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -5,6 +5,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query?.environmentId?.toString();
if (!environmentId) {
return res.status(400).json({ message: "Missing environmentId" });
}
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
return res.status(403).json({ message: "Not authorized" });

View File

@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"environmentId" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TagsOnResponses" (
"responseId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
CONSTRAINT "TagsOnResponses_pkey" PRIMARY KEY ("responseId","tagId")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tag_environmentId_name_key" ON "Tag"("environmentId", "name");
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagsOnResponses" ADD CONSTRAINT "TagsOnResponses_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "Response"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagsOnResponses" ADD CONSTRAINT "TagsOnResponses_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -87,24 +87,25 @@ model Person {
}
model Response {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
personId String?
responseNotes ResponseNote[]
/// @zod.custom(imports.ZResponseData)
/// [ResponseData]
data Json @default("{}")
/// @zod.custom(imports.ZResponseMeta)
/// [ResponseMeta]
meta Json @default("{}")
tags TagsOnResponses[]
/// @zod.custom(imports.ZResponsePersonAttributes)
/// [ResponsePersonAttributes]
personAttributes Json?
/// @zod.custom(imports.ZResponseData)
/// [ResponseData]
data Json @default("{}")
/// @zod.custom(imports.ZResponseMeta)
/// [ResponseMeta]
meta Json @default("{}")
}
model ResponseNote {
@@ -118,6 +119,27 @@ model ResponseNote {
text String
}
model Tag {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
responses TagsOnResponses[]
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
@@unique([environmentId, name])
}
model TagsOnResponses {
responseId String
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
tagId String
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([responseId, tagId])
}
enum SurveyStatus {
draft
inProgress
@@ -278,6 +300,7 @@ model Environment {
attributeClasses AttributeClass[]
apiKeys ApiKey[]
webhooks Webhook[]
tags Tag[]
}
model Product {

View File

@@ -1,5 +1,4 @@
import { PrismaClient } from "@prisma/client";
import "../jsonTypes";
declare global {
var prisma: PrismaClient | undefined;

View File

@@ -1 +1,2 @@
import "../jsonTypes";
export * from "./client";

22
packages/types/v1/tags.ts Normal file
View File

@@ -0,0 +1,22 @@
import { z } from "zod";
export type TTag = z.infer<typeof ZTag>;
export const ZTag = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string(),
environmentId: z.string(),
});
export type TTagsCount = z.infer<typeof ZTagsCount>;
export const ZTagsCount = z.array(
z.object({
tagId: z.string().cuid2(),
_count: z.object({
_all: z.number(),
}),
})
);

View File

@@ -0,0 +1,136 @@
"use client";
import * as React from "react";
import { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@formbricks/lib/cn";
import { Dialog, DialogContent } from "./Dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-2xl">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3">
<CommandPrimitive.Input
ref={ref}
className={cn(
"placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("bg-border -mx-1 h-px", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
type SafeHTMLAttributes<T> = Omit<React.HTMLAttributes<T>, "dangerouslySetInnerHTML">;
const CommandShortcut = ({ className, ...props }: SafeHTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props} />
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,108 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({ className, children, ...props }: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal className={cn(className)} {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">{children}</div>
</DialogPrimitive.Portal>
);
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"bg-background/80 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 backdrop-blur-sm transition-all duration-100",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-background animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
type DialogHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerouslySetInnerHTML"> & {
dangerouslySetInnerHTML?: {
__html: string;
};
};
const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
type DialogFooterProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerouslySetInnerHTML"> & {
dangerouslySetInnerHTML?: {
__html: string;
};
};
const DialogFooter = ({ className, ...props }: DialogFooterProps) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };

View File

@@ -46,6 +46,17 @@ export {
export { Switch } from "./components/Switch";
export { TabBar } from "./components/TabBar";
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./components/Tooltip";
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "./components/Command";
/* Icons */
export { AngryBirdRageIcon } from "./components/icons/AngryBirdRageIcon";

View File

@@ -38,6 +38,7 @@
"@lexical/rich-text": "^0.11.1",
"@lexical/table": "^0.11.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.6",
@@ -47,6 +48,7 @@
"@radix-ui/react-tooltip": "^1.0.6",
"boring-avatars": "^1.7.0",
"clsx": "^1.2.1",
"cmdk": "^0.2.0",
"lucide-react": "^0.233.0",
"next": "^13.4.4",
"react-colorful": "^5.6.1",

190
pnpm-lock.yaml generated
View File

@@ -499,7 +499,7 @@ importers:
version: 8.8.0(eslint@8.41.0)
eslint-config-turbo:
specifier: latest
version: 1.10.3(eslint@8.41.0)
version: 1.8.8(eslint@8.41.0)
eslint-plugin-react:
specifier: 7.32.2
version: 7.32.2(eslint@8.41.0)
@@ -666,6 +666,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.0.4
version: 1.0.4(@types/react-dom@18.2.4)(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-dialog':
specifier: ^1.0.4
version: 1.0.4(@types/react-dom@18.2.4)(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-dropdown-menu':
specifier: ^2.0.5
version: 2.0.5(@types/react-dom@18.2.4)(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
@@ -693,6 +696,9 @@ importers:
clsx:
specifier: ^1.2.1
version: 1.2.1
cmdk:
specifier: ^0.2.0
version: 0.2.0(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
lucide-react:
specifier: ^0.233.0
version: 0.233.0(react@18.2.0)
@@ -4606,6 +4612,67 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-dialog@1.0.0(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Yn9YU+QlHYLWwV1XfKiqnGVpWYWk6MeBVM6x/bcoyPvxgjQGoeT35482viLPctTMWoMw0PoHgqfSox7Ig+957Q==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-context': 1.0.0(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-focus-guards': 1.0.0(react@18.2.0)
'@radix-ui/react-focus-scope': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-id': 1.0.0(react@18.2.0)
'@radix-ui/react-portal': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.0(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.5.4(@types/react@18.2.7)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
/@radix-ui/react-dialog@1.0.4(@types/react-dom@18.2.4)(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-hJtRy/jPULGQZceSAP2Re6/4NpKo8im6V8P2hUqZsdFiSL8l35kYsw3qbRI6Ay5mQd2+wlLqje770eq+RJ3yZg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.7)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.7)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.2.4)(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.7)(react@18.2.0)
'@radix-ui/react-focus-scope': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.7)(react@18.2.0)
'@radix-ui/react-portal': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.4)(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.7)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.7)(react@18.2.0)
'@types/react': 18.2.7
'@types/react-dom': 18.2.4
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.5.5(@types/react@18.2.7)(react@18.2.0)
dev: false
/@radix-ui/react-direction@1.0.0(react@18.2.0):
resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==}
peerDependencies:
@@ -4629,6 +4696,22 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-dismissable-layer@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-n7kDRfx+LB1zLueRDvZ1Pd0bxdJWDUZNQ/GWoxDn2prnuJKRdxsjulejX/ePkOsLi2tTm6P24mDqlMSgQpsT6g==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
'@radix-ui/react-use-escape-keydown': 1.0.0(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-dismissable-layer@1.0.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q==}
peerDependencies:
@@ -4740,6 +4823,20 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-focus-scope@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-C4SWtsULLGf/2L4oGeIHlvWQx7Rf+7cX/vKOAD2dXW0A1b5QXwi3wWeaEgW+wn+SEVrraMUk05vLU9fZZz5HbQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-focus-scope@1.0.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-spwXlNTfeIprt+kaEWE/qYuYT3ZAqJiAGjN/JgdvgVDTu8yc+HuX+WOWXrKliKnLnwck0F6JDkqIERncnih+4A==}
peerDependencies:
@@ -4980,6 +5077,18 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-portal@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-a8qyFO/Xb99d8wQdu4o7qnigNjTPG123uADNecz0eX4usnQEj7o+cG4ZX4zkqq98NYekT7UoEQIjxBNWIFuqTA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-portal@1.0.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-swu32idoCW7KA2VEiUZGBSu9nB6qwGdV6k6HYhUoOo3M1FFpD+VgLzUqtt3mwL1ssz7r2x8MggpLSQach2Xy/Q==}
peerDependencies:
@@ -5048,6 +5157,18 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-primitive@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/react-slot': 1.0.0(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-primitive@1.0.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==}
peerDependencies:
@@ -5201,6 +5322,16 @@ packages:
react-remove-scroll: 2.5.5(@types/react@18.2.7)(react@18.2.0)
dev: false
/@radix-ui/react-slot@1.0.0(react@18.2.0):
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
react: 18.2.0
dev: false
/@radix-ui/react-slot@1.0.1(react@18.2.0):
resolution: {integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==}
peerDependencies:
@@ -5333,6 +5464,16 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-escape-keydown@1.0.0(react@18.2.0):
resolution: {integrity: sha512-JwfBCUIfhXRxKExgIqGa4CQsiMemo1Xt0W/B4ei3fpzpvPENKpMKQ8mZSB6Acj3ebrAEgi2xiQvcI1PAAodvyg==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
react: 18.2.0
dev: false
/@radix-ui/react-use-escape-keydown@1.0.2(react@18.2.0):
resolution: {integrity: sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA==}
peerDependencies:
@@ -8414,6 +8555,20 @@ packages:
engines: {node: '>=6'}
dev: false
/cmdk@0.2.0(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-JQpKvEOb86SnvMZbYaFKYhvzFntWBeSZdyii0rZPhKJj9uwJBxu4DaVYDrRN7r3mPop56oPhRw+JYWTKs66TYw==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
dependencies:
'@radix-ui/react-dialog': 1.0.0(@types/react@18.2.7)(react-dom@18.2.0)(react@18.2.0)
command-score: 0.1.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
/co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -8504,6 +8659,10 @@ packages:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
dev: false
/command-score@0.1.2:
resolution: {integrity: sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==}
dev: false
/commander@2.17.1:
resolution: {integrity: sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==}
dev: true
@@ -10311,13 +10470,13 @@ packages:
eslint: 8.41.0
dev: false
/eslint-config-turbo@1.10.3(eslint@8.41.0):
resolution: {integrity: sha512-ggzPfTJfMsMS383oZ4zfTP1zQvyMyiigOQJRUnLt1nqII6SKkTzdKZdwmXRDHU24KFwUfEFtT6c8vnm2VhL0uQ==}
/eslint-config-turbo@1.8.8(eslint@8.41.0):
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
eslint: 8.41.0
eslint-plugin-turbo: 1.10.3(eslint@8.41.0)
eslint-plugin-turbo: 1.8.8(eslint@8.41.0)
dev: false
/eslint-import-resolver-node@0.3.6:
@@ -10536,8 +10695,8 @@ packages:
string.prototype.matchall: 4.0.8
dev: true
/eslint-plugin-turbo@1.10.3(eslint@8.41.0):
resolution: {integrity: sha512-g3Mnnk7el1FqxHfqbE/MayLvCsYjA/vKmAnUj66kV4AlM7p/EZqdt42NMcMSKtDVEm0w+utQkkzWG2Xsa0Pd/g==}
/eslint-plugin-turbo@1.8.8(eslint@8.41.0):
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
@@ -17877,6 +18036,25 @@ packages:
tslib: 2.5.2
dev: false
/react-remove-scroll@2.5.4(@types/react@18.2.7)(react@18.2.0):
resolution: {integrity: sha512-xGVKJJr0SJGQVirVFAUZ2k1QLyO6m+2fy0l8Qawbp5Jgrv3DeLalrfMNBFSlmz5kriGGzsVBtGVnf4pTKIhhWA==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.2.7
react: 18.2.0
react-remove-scroll-bar: 2.3.4(@types/react@18.2.7)(react@18.2.0)
react-style-singleton: 2.2.1(@types/react@18.2.7)(react@18.2.0)
tslib: 2.5.2
use-callback-ref: 1.3.0(@types/react@18.2.7)(react@18.2.0)
use-sidecar: 1.1.2(@types/react@18.2.7)(react@18.2.0)
dev: false
/react-remove-scroll@2.5.5(@types/react@18.2.7)(react@18.2.0):
resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==}
engines: {node: '>=10'}