mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
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:
@@ -1,4 +1,4 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,6 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
}
|
||||
}, [survey]);
|
||||
|
||||
|
||||
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ?? ""}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
147
apps/web/lib/tags/mutateTags.ts
Normal file
147
apps/web/lib/tags/mutateTags.ts
Normal 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
30
apps/web/lib/tags/tags.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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" });
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
161
apps/web/pages/api/v1/environments/[environmentId]/tags/merge.ts
Normal file
161
apps/web/pages/api/v1/environments/[environmentId]/tags/merge.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import "../jsonTypes";
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
import "../jsonTypes";
|
||||
export * from "./client";
|
||||
|
||||
22
packages/types/v1/tags.ts
Normal file
22
packages/types/v1/tags.ts
Normal 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(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
136
packages/ui/components/Command.tsx
Normal file
136
packages/ui/components/Command.tsx
Normal 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,
|
||||
};
|
||||
108
packages/ui/components/Dialog.tsx
Normal file
108
packages/ui/components/Dialog.tsx
Normal 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 };
|
||||
@@ -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";
|
||||
|
||||
@@ -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
190
pnpm-lock.yaml
generated
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user