diff --git a/apps/web/app/auth/signup/page.tsx b/apps/web/app/auth/signup/page.tsx index a31b698f13..bcb654e864 100644 --- a/apps/web/app/auth/signup/page.tsx +++ b/apps/web/app/auth/signup/page.tsx @@ -1,4 +1,4 @@ -'use client' +"use client"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; diff --git a/apps/web/app/environments/[environmentId]/settings/SettingsNavbar.tsx b/apps/web/app/environments/[environmentId]/settings/SettingsNavbar.tsx index 0e7b4ee856..00364dd54a 100644 --- a/apps/web/app/environments/[environmentId]/settings/SettingsNavbar.tsx +++ b/apps/web/app/environments/[environmentId]/settings/SettingsNavbar.tsx @@ -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; + 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`, diff --git a/apps/web/app/environments/[environmentId]/settings/tags/EditTagsWrapper.tsx b/apps/web/app/environments/[environmentId]/settings/tags/EditTagsWrapper.tsx new file mode 100644 index 0000000000..e9d6818c82 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/settings/tags/EditTagsWrapper.tsx @@ -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 ( +
+
+
+
+ { + updateTag( + { name: e.target.value.trim() }, + { + onSuccess: () => { + toast.success("Tag updated"); + refetchEnvironmentTags(); + }, + onError: (error) => { + toast.error(error?.message ?? "Failed to update tag"); + }, + } + ); + }} + /> +
+
+ +
+
{tagCountLoading ? :

{tagCount}

}
+
+ +
+
+ {isMergingTags ? ( +
+ +
+ ) : ( + tag.id !== tagId) + ?.map((tag) => ({ label: tag.name, value: tag.id })) ?? [] + } + onSelect={(newTagId) => { + mergeTags( + { + originalTagId: tagId, + newTagId, + }, + { + onSuccess: () => { + toast.success("Tags merged"); + refetchEnvironmentTags(); + updateTagsCount(); + }, + } + ); + }} + /> + )} +
+ +
+ +
+
+
+
+ ); +}; + +const EditTagsWrapper: React.FC = (props) => { + const { environmentId } = props; + const { data: environmentTags, isLoading: isLoadingEnvironmentTags } = useTagsForEnvironment(environmentId); + + const { tagsCount, isLoadingTagsCount, mutateTagsCount } = useTagsCountForEnvironment(environmentId); + + if (isLoadingEnvironmentTags) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+
Name
+
Count
+
Actions
+
+ + {!environmentTags?.length ? ( + + ) : null} + + {environmentTags?.map((tag) => ( + count.tagId === tag.id)?.count ?? 0} + tagCountLoading={isLoadingTagsCount} + updateTagsCount={mutateTagsCount} + /> + ))} +
+
+ ); +}; + +export default EditTagsWrapper; diff --git a/apps/web/app/environments/[environmentId]/settings/tags/MergeTagsCombobox.tsx b/apps/web/app/environments/[environmentId]/settings/tags/MergeTagsCombobox.tsx new file mode 100644 index 0000000000..62b37a8beb --- /dev/null +++ b/apps/web/app/environments/[environmentId]/settings/tags/MergeTagsCombobox.tsx @@ -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 = ({ tags, onSelect }) => { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + + return ( + + + + + + +
+ +
+ +
No tag found
+
+ + {tags?.length === 0 ? No tags found : null} + + {tags?.map((tag) => ( + { + setValue(currentValue === value ? "" : currentValue); + setOpen(false); + onSelect(tag.value); + }}> + {tag.label} + + ))} + +
+
+
+ ); +}; + +export default MergeTagsCombobox; diff --git a/apps/web/app/environments/[environmentId]/settings/tags/page.tsx b/apps/web/app/environments/[environmentId]/settings/tags/page.tsx new file mode 100644 index 0000000000..bbdae0a3e2 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/settings/tags/page.tsx @@ -0,0 +1,11 @@ +import EditTagsWrapper from "./EditTagsWrapper"; +import SettingsTitle from "../SettingsTitle"; + +export default function MembersSettingsPage({ params }) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx index 21154db5a9..8d9cc9d560 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx @@ -37,7 +37,6 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr } }, [survey]); - if (isLoadingSurvey || isLoadingProduct || !localSurvey) { return ; } diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx index 80ef8722eb..8cfc6dea6a 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx @@ -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); } }; diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTagsWrapper.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTagsWrapper.tsx new file mode 100644 index 0000000000..fcf940efc6 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTagsWrapper.tsx @@ -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 ( +
+
+ {tagName} +
+ + { + setTagsState(tags.filter((tag) => tag.tagId !== tagId)); + + onDelete(tagId); + }}> + + +
+ ); +} + +const ResponseTagsWrapper: React.FC = ({ + 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 ( +
+
+ {tagsState?.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(); + }, + } + ); + }} + /> +
+
+ ); +}; + +export default ResponseTagsWrapper; diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTimeline.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTimeline.tsx index f8e0994416..a257af517e 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTimeline.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseTimeline.tsx @@ -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 ?? ""} /> ); })} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx index a70e20f4c8..a7248b6405 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx @@ -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 ))} + + ({ tagId: tag.id, tagName: tag.name }))} + key={data.tags.map((tag) => tag.id).join("-")} + /> + ); diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/TagsCombobox.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/TagsCombobox.tsx new file mode 100644 index 0000000000..17186c3b79 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/TagsCombobox.tsx @@ -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>; + open: boolean; + setOpen: React.Dispatch>; +} + +type Tag = { + label: string; + value: string; +}; + +const TagsCombobox: React.FC = ({ + 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 ( + + + + + + { + 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; + }}> +
+ setSearchValue(search)} + onKeyDown={(e) => { + if (e.key === "Enter" && searchValue !== "") { + if ( + !tagsToSearch?.find((tag) => + tag?.label?.toLowerCase().includes(searchValue?.toLowerCase()) + ) + ) { + createTag?.(searchValue); + } + } + }} + /> +
+ + {tagsToSearch?.map((tag) => { + return ( + { + setOpen(false); + addTag(currentValue); + }} + className="hover:cursor-pointer hover:bg-slate-50"> + {tag.label} + + ); + })} + {searchValue !== "" && + !currentTags.find((tag) => tag.label === searchValue) && + !tagsToSearch.find((tag) => tag.label === searchValue) && ( + + + + )} + +
+
+
+ ); +}; + +export default TagsCombobox; diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 72db47afab..e324d44536 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; } diff --git a/apps/web/components/shared/EmptySpaceFiller.tsx b/apps/web/components/shared/EmptySpaceFiller.tsx index e8715ec748..e4c0bc1dbd 100644 --- a/apps/web/components/shared/EmptySpaceFiller.tsx +++ b/apps/web/components/shared/EmptySpaceFiller.tsx @@ -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 = ({ type, environmentId ); } + if (type === "response") { return (
@@ -73,6 +74,36 @@ const EmptySpaceFiller: React.FC = ({ type, environmentId
); } + + if (type === "tag") { + return ( +
+
+
+
+
+
+
+
+ {!environment.widgetSetupCompleted && !noWidgetRequired && ( + + + Install Formbricks Widget. Go to Setup Checklist 👉 + + + )} + {(environment.widgetSetupCompleted || noWidgetRequired) && ( + Tag a submission to find your list of tags here. + )} +
+
+
+
+ ); + } + return (
@@ -101,8 +132,6 @@ const EmptySpaceFiller: React.FC = ({ type, environmentId
); - - return null; }; export default EmptySpaceFiller; diff --git a/apps/web/lib/api/apiHelper.ts b/apps/web/lib/api/apiHelper.ts index 2def7cf8f3..6bfefe35ff 100644 --- a/apps/web/lib/api/apiHelper.ts +++ b/apps/web/lib/api/apiHelper.ts @@ -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) { diff --git a/apps/web/lib/tags/mutateTags.ts b/apps/web/lib/tags/mutateTags.ts new file mode 100644 index 0000000000..a976f89435 --- /dev/null +++ b/apps/web/lib/tags/mutateTags.ts @@ -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 => { + 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 => { + 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 => { + 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, + }; +}; diff --git a/apps/web/lib/tags/tags.ts b/apps/web/lib/tags/tags.ts new file mode 100644 index 0000000000..3e887ac35b --- /dev/null +++ b/apps/web/lib/tags/tags.ts @@ -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(`/api/v1/environments/${environmentId}/tags`, fetcher); + + return tagsForProducts; +}; + +export const useTagsCountForEnvironment = (environmentId: string) => { + const { + data: tagsCount, + isLoading: isLoadingTagsCount, + mutate: mutateTagsCount, + } = useSWR(`/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, + }; +}; diff --git a/apps/web/pages/api/v1/environments/[environmentId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/index.ts index 63934c26a2..84ddeed457 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/index.ts @@ -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" }); diff --git a/apps/web/pages/api/v1/environments/[environmentId]/posthog/export/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/posthog/export/index.ts index fd186b1671..099220241c 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/posthog/export/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/posthog/export/index.ts @@ -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" }); diff --git a/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts index 95200cc94b..c998abb660 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts @@ -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); diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/index.ts index d578d35388..fbe75f8a67 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/index.ts @@ -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) { diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/[tagId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/[tagId]/index.ts new file mode 100644 index 0000000000..c799bdce27 --- /dev/null +++ b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/[tagId]/index.ts @@ -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.`); + } +} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/index.ts new file mode 100644 index 0000000000..b25bdea50e --- /dev/null +++ b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/index.ts @@ -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.`); + } +} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts index d804cc64d1..6fb63a0239 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts @@ -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, + }, + }, + }, + }, }, }); diff --git a/apps/web/pages/api/v1/environments/[environmentId]/tags/[tagId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/tags/[tagId]/index.ts new file mode 100644 index 0000000000..2a5e542cb6 --- /dev/null +++ b/apps/web/pages/api/v1/environments/[environmentId]/tags/[tagId]/index.ts @@ -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.`); + } +} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/tags/count.ts b/apps/web/pages/api/v1/environments/[environmentId]/tags/count.ts new file mode 100644 index 0000000000..7389edfb1a --- /dev/null +++ b/apps/web/pages/api/v1/environments/[environmentId]/tags/count.ts @@ -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.`); + } +} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/tags/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/tags/index.ts new file mode 100644 index 0000000000..87278c9417 --- /dev/null +++ b/apps/web/pages/api/v1/environments/[environmentId]/tags/index.ts @@ -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.`); + } +} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/tags/merge.ts b/apps/web/pages/api/v1/environments/[environmentId]/tags/merge.ts new file mode 100644 index 0000000000..3879ffb2d8 --- /dev/null +++ b/apps/web/pages/api/v1/environments/[environmentId]/tags/merge.ts @@ -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.`); + } +} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts index 4a921e205c..b81f0b65a4 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts @@ -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" }); diff --git a/packages/database/migrations/20230624161355_add_tags/migration.sql b/packages/database/migrations/20230624161355_add_tags/migration.sql new file mode 100644 index 0000000000..de73bb0337 --- /dev/null +++ b/packages/database/migrations/20230624161355_add_tags/migration.sql @@ -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; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index d443073b06..8419f24b28 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -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 { diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index 558af651d4..b5b129bd43 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -1,5 +1,4 @@ import { PrismaClient } from "@prisma/client"; -import "../jsonTypes"; declare global { var prisma: PrismaClient | undefined; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 5ec76921e1..d6ca9a4a55 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1 +1,2 @@ +import "../jsonTypes"; export * from "./client"; diff --git a/packages/types/v1/tags.ts b/packages/types/v1/tags.ts new file mode 100644 index 0000000000..c2b44d54df --- /dev/null +++ b/packages/types/v1/tags.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export type TTag = z.infer; + +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; + +export const ZTagsCount = z.array( + z.object({ + tagId: z.string().cuid2(), + _count: z.object({ + _all: z.number(), + }), + }) +); diff --git a/packages/ui/components/Command.tsx b/packages/ui/components/Command.tsx new file mode 100644 index 0000000000..3d88aec5ac --- /dev/null +++ b/packages/ui/components/Command.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +type SafeHTMLAttributes = Omit, "dangerouslySetInnerHTML">; + +const CommandShortcut = ({ className, ...props }: SafeHTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/packages/ui/components/Dialog.tsx b/packages/ui/components/Dialog.tsx new file mode 100644 index 0000000000..cb28fa8fd2 --- /dev/null +++ b/packages/ui/components/Dialog.tsx @@ -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) => ( + +
{children}
+
+); +DialogPortal.displayName = DialogPrimitive.Portal.displayName; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +type DialogHeaderProps = Omit, "dangerouslySetInnerHTML"> & { + dangerouslySetInnerHTML?: { + __html: string; + }; +}; + +const DialogHeader = ({ className, ...props }: DialogHeaderProps) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +type DialogFooterProps = Omit, "dangerouslySetInnerHTML"> & { + dangerouslySetInnerHTML?: { + __html: string; + }; +}; + +const DialogFooter = ({ className, ...props }: DialogFooterProps) => ( +
+); + +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }; diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 2dfc138573..d62a7e3cbf 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -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"; diff --git a/packages/ui/package.json b/packages/ui/package.json index ff1f2f9853..5e6f5eeda6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f0906929b..f1c57efb29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'}