feat: Update Insight category, document sentiment and archive insights (#4038)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Johannes
2024-11-04 23:07:27 -08:00
committed by GitHub
parent 31c3f9730e
commit 74bd40e0ff
52 changed files with 764 additions and 234 deletions
@@ -14,6 +14,7 @@ import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization
import { ZId } from "@formbricks/types/common";
import { ZDocumentFilterCriteria } from "@formbricks/types/documents";
import { ZSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getDocument, updateDocument } from "./lib/documents";
const ZGetDocumentsByInsightIdSurveyIdQuestionIdAction = z.object({
insightId: ZId,
@@ -94,3 +95,30 @@ export const getDocumentsByInsightIdAction = authenticatedActionClient
parsedInput.filterCriteria
);
});
const ZUpdateDocumentAction = z.object({
documentId: ZId,
data: z
.object({
sentiment: z.enum(["positive", "negative", "neutral"]).optional(),
})
.strict(),
});
export const updateDocumentAction = authenticatedActionClient
.schema(ZUpdateDocumentAction)
.action(async ({ ctx, parsedInput }) => {
const document = await getDocument(parsedInput.documentId);
if (!document) {
throw new Error("Document not found");
}
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(document.environmentId),
rules: ["response", "update"],
});
return await updateDocument(parsedInput.documentId, parsedInput.data);
});
@@ -2,14 +2,13 @@
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { useDeferredValue, useEffect, useState } from "react";
import Markdown from "react-markdown";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { timeSince } from "@formbricks/lib/time";
import { TDocument, TDocumentFilterCriteria } from "@formbricks/types/documents";
import { TInsight } from "@formbricks/types/insights";
import { TUserLocale } from "@formbricks/types/user";
import { Badge } from "@formbricks/ui/components/Badge";
import { Button } from "@formbricks/ui/components/Button";
import { Card, CardContent, CardFooter } from "@formbricks/ui/components/Card";
import {
@@ -19,6 +18,8 @@ import {
SheetHeader,
SheetTitle,
} from "@formbricks/ui/components/Sheet";
import CategoryBadge from "../../experience/components/category-select";
import SentimentSelect from "../sentiment-select";
import { getDocumentsByInsightIdAction, getDocumentsByInsightIdSurveyIdQuestionIdAction } from "./actions";
interface InsightSheetProps {
@@ -47,54 +48,68 @@ export const InsightSheet = ({
const t = useTranslations();
const [documents, setDocuments] = useState<TDocument[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const fetchDocuments = useCallback(async () => {
if (!insight) return;
let documentsResponse;
if (questionId && surveyId) {
documentsResponse = await getDocumentsByInsightIdSurveyIdQuestionIdAction({
insightId: insight.id,
surveyId,
questionId,
limit: documentsPerPage,
offset: (page - 1) * documentsPerPage,
});
} else {
documentsResponse = await getDocumentsByInsightIdAction({
insightId: insight.id,
filterCriteria: documentsFilter,
limit: documentsPerPage,
offset: (page - 1) * documentsPerPage,
});
}
if (!documentsResponse?.data) {
const errorMessage = getFormattedErrorMessage(documentsResponse);
console.error(errorMessage);
return;
}
const fetchedDocuments = documentsResponse.data;
if (fetchedDocuments.length < documentsPerPage) {
setHasMore(false); // No more documents to fetch
}
setDocuments((prevDocuments) => [...prevDocuments, ...fetchedDocuments]);
}, [insight, page, surveyId, questionId, documentsFilter]);
const [isLoading, setIsLoading] = useState(false); // New state for loading
const [hasMore, setHasMore] = useState(false);
useEffect(() => {
if (isOpen) {
setDocuments([]);
setPage(1);
setHasMore(true);
setHasMore(false); // Reset hasMore when the sheet is opened
}
if (insight) {
if (isOpen && insight) {
fetchDocuments();
}
}, [fetchDocuments, isOpen]);
async function fetchDocuments() {
if (!insight) return;
if (isLoading) return; // Prevent fetching if already loading
setIsLoading(true); // Set loading state to true
try {
let documentsResponse;
if (questionId && surveyId) {
documentsResponse = await getDocumentsByInsightIdSurveyIdQuestionIdAction({
insightId: insight.id,
surveyId,
questionId,
limit: documentsPerPage,
offset: (page - 1) * documentsPerPage,
});
} else {
documentsResponse = await getDocumentsByInsightIdAction({
insightId: insight.id,
filterCriteria: documentsFilter,
limit: documentsPerPage,
offset: (page - 1) * documentsPerPage,
});
}
if (!documentsResponse?.data) {
const errorMessage = getFormattedErrorMessage(documentsResponse);
console.error(errorMessage);
return;
}
const fetchedDocuments = documentsResponse.data;
setDocuments((prevDocuments) => {
// Remove duplicates based on document ID
const uniqueDocuments = new Map<string, TDocument>([
...prevDocuments.map((doc) => [doc.id, doc]),
...fetchedDocuments.map((doc) => [doc.id, doc]),
]);
return Array.from(uniqueDocuments.values()) as TDocument[];
});
setHasMore(fetchedDocuments.length === documentsPerPage);
} finally {
setIsLoading(false); // Reset loading state
}
}
}, [isOpen, insight]);
const deferredDocuments = useDeferredValue(documents);
const handleFeedbackClick = (feedback: "positive" | "negative") => {
setIsOpen(false);
@@ -113,48 +128,35 @@ export const InsightSheet = ({
return (
<Sheet open={isOpen} onOpenChange={(v) => setIsOpen(v)}>
<SheetContent className="flex h-full w-[400rem] flex-col bg-white lg:max-w-lg xl:max-w-2xl">
<SheetHeader>
<SheetTitle>
<span className="mr-3">{insight.title}</span>
{insight.category === "complaint" ? (
<Badge text="Complaint" type="error" size="tiny" />
) : insight.category === "featureRequest" ? (
<Badge text="Feature Request" type="warning" size="tiny" />
) : insight.category === "praise" ? (
<Badge text="Praise" type="success" size="tiny" />
) : null}
<SheetContent className="flex h-full flex-col bg-white">
<SheetHeader className="flex flex-col gap-1.5">
<SheetTitle className="flex items-center gap-x-2">
<span>{insight.title}</span>
<CategoryBadge category={insight.category} insightId={insight.id} />
</SheetTitle>
<SheetDescription>{insight.description}</SheetDescription>
<div className="flex w-fit items-center gap-2 rounded-lg border border-slate-300 px-2 py-1 text-sm text-slate-600">
<p>{t("environments.experience.did_you_find_this_insight_helpful")}</p>
<ThumbsUpIcon
className="upvote h-4 w-4 cursor-pointer hover:text-black"
className="upvote h-4 w-4 cursor-pointer text-slate-700 hover:text-emerald-500"
onClick={() => handleFeedbackClick("positive")}
/>
<ThumbsDownIcon
className="downvote h-4 w-4 cursor-pointer hover:text-black"
className="downvote h-4 w-4 cursor-pointer text-slate-700 hover:text-amber-600"
onClick={() => handleFeedbackClick("negative")}
/>
</div>
</SheetHeader>
<div className="flex flex-1 flex-col space-y-2 overflow-auto pt-4">
{documents.map((document) => (
<Card key={document.id}>
<hr className="my-2" />
<div className="flex flex-1 flex-col gap-y-2 overflow-auto">
{deferredDocuments.map((document, index) => (
<Card key={`${document.id}-${index}`} className="transition-opacity duration-200">
<CardContent className="p-4 text-sm">
<Markdown className="whitespace-pre-wrap">{document.text}</Markdown>
</CardContent>
<CardFooter className="flex justify-between bg-slate-50 px-4 py-3 text-xs text-slate-600">
<CardFooter className="flex justify-between rounded-bl-xl rounded-br-xl border-t border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
<p>
{t("environments.experience.sentiment")}:{" "}
{document.sentiment === "positive" ? (
<Badge text={t("environments.experience.positive")} size="tiny" type="success" />
) : document.sentiment === "neutral" ? (
<Badge text="Neutral" size="tiny" type="gray" />
) : document.sentiment === "negative" ? (
<Badge text="Negative" size="tiny" type="error" />
) : null}
Sentiment: <SentimentSelect documentId={document.id} sentiment={document.sentiment} />
</p>
<p>{timeSince(new Date(document.createdAt).toISOString(), locale)}</p>
</CardFooter>
@@ -8,7 +8,12 @@ import { cache } from "@formbricks/lib/cache";
import { DOCUMENTS_PER_PAGE } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { TDocument, TDocumentFilterCriteria, ZDocumentFilterCriteria } from "@formbricks/types/documents";
import {
TDocument,
TDocumentFilterCriteria,
ZDocument,
ZDocumentFilterCriteria,
} from "@formbricks/types/documents";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurveyQuestionId, ZSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -124,3 +129,52 @@ export const getDocumentsByInsightIdSurveyIdQuestionId = reactCache(
}
)()
);
export const getDocument = reactCache(
(documentId: string): Promise<TDocument | null> =>
cache(
async () => {
validateInputs([documentId, ZId]);
try {
const document = await prisma.document.findUnique({
where: {
id: documentId,
},
});
return document;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getDocumentById-${documentId}`],
{
tags: [documentCache.tag.byId(documentId)],
}
)()
);
export const updateDocument = async (documentId: string, data: Partial<TDocument>): Promise<TDocument> => {
validateInputs([documentId, ZId], [data, ZDocument.partial()]);
try {
const updatedDocument = await prisma.document.update({
where: { id: documentId },
data,
});
documentCache.revalidate({ environmentId: updatedDocument.environmentId });
return updatedDocument;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -9,7 +9,6 @@ import { cn } from "@formbricks/lib/cn";
import { TDocumentFilterCriteria } from "@formbricks/types/documents";
import { TInsight, TInsightCategory } from "@formbricks/types/insights";
import { TUserLocale } from "@formbricks/types/user";
import { Badge } from "@formbricks/ui/components/Badge";
import { Button } from "@formbricks/ui/components/Button";
import {
Table,
@@ -20,6 +19,7 @@ import {
TableRow,
} from "@formbricks/ui/components/Table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@formbricks/ui/components/Tabs";
import CategoryBadge from "../experience/components/category-select";
interface InsightViewProps {
insights: TInsight[];
@@ -78,7 +78,18 @@ export const InsightView = ({
useEffect(() => {
handleFilterSelect(activeTab);
}, [insights]);
// Update currentInsight if it exists in the new insights array
if (currentInsight) {
const updatedInsight = insights.find((insight) => insight.id === currentInsight.id);
if (updatedInsight) {
setCurrentInsight(updatedInsight);
} else {
setCurrentInsight(null);
setIsInsightSheetOpen(false);
}
}
}, [insights, activeTab, handleFilterSelect]);
const handleLoadMore = () => {
setVisibleInsights((prevVisibleInsights) => Math.min(prevVisibleInsights + 10, insights.length));
@@ -136,19 +147,7 @@ export const InsightView = ({
{insight.description}
</TableCell>
<TableCell>
{insight.category === "complaint" ? (
<Badge text={t("environments.experience.complaint")} type="error" size="tiny" />
) : insight.category === "featureRequest" ? (
<Badge
text={t("environments.experience.feature_request")}
type="warning"
size="tiny"
/>
) : insight.category === "praise" ? (
<Badge text={t("environments.experience.praise")} type="success" size="tiny" />
) : insight.category === "other" ? (
<Badge text={t("common.other")} type="gray" size="tiny" />
) : null}
<CategoryBadge category={insight.category} insightId={insight.id} />
</TableCell>
</TableRow>
))
@@ -0,0 +1,63 @@
import { useState } from "react";
import { TDocument, TDocumentSentiment } from "@formbricks/types/documents";
import { BadgeSelect, TBadgeSelectOption } from "@formbricks/ui/components/BadgeSelect";
import { updateDocumentAction } from "./insight-sheet/actions";
interface SentimentSelectProps {
sentiment: TDocument["sentiment"];
documentId: string;
}
const sentimentOptions: TBadgeSelectOption[] = [
{ text: "Positive", type: "success" },
{ text: "Neutral", type: "gray" },
{ text: "Negative", type: "error" },
];
const getSentimentIndex = (sentiment: TDocumentSentiment) => {
switch (sentiment) {
case "positive":
return 0;
case "neutral":
return 1;
case "negative":
return 2;
default:
return 1; // Default to neutral
}
};
const SentimentSelect = ({ sentiment, documentId }: SentimentSelectProps) => {
const [currentSentiment, setCurrentSentiment] = useState(sentiment);
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdateSentiment = async (newSentiment: TDocumentSentiment) => {
setIsUpdating(true);
try {
await updateDocumentAction({
documentId,
data: { sentiment: newSentiment },
});
setCurrentSentiment(newSentiment); // Update the state with the new sentiment
} catch (error) {
console.error("Failed to update document sentiment:", error);
} finally {
setIsUpdating(false);
}
};
return (
<BadgeSelect
options={sentimentOptions}
selectedIndex={getSentimentIndex(currentSentiment)}
onChange={(newIndex) => {
const newSentiment = sentimentOptions[newIndex].text.toLowerCase() as TDocumentSentiment;
handleUpdateSentiment(newSentiment);
}}
size="tiny"
isLoading={isUpdating}
/>
);
};
export default SentimentSelect;
@@ -1,12 +1,15 @@
"use server";
import { insightCache } from "@/lib/cache/insight";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { cache } from "@formbricks/lib/cache";
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
import { ZId } from "@formbricks/types/common";
import { ZInsightFilterCriteria } from "@formbricks/types/insights";
import { getInsights } from "./lib/insights";
import { ZInsight, ZInsightFilterCriteria } from "@formbricks/types/insights";
import { getInsights, updateInsight } from "./lib/insights";
import { getStats } from "./lib/stats";
const ZGetEnvironmentInsightsAction = z.object({
@@ -49,3 +52,51 @@ export const getStatsAction = authenticatedActionClient
return await getStats(parsedInput.environmentId, parsedInput.statsFrom);
});
const ZUpdateInsightAction = z.object({
insightId: ZId,
data: ZInsight.partial(),
});
export const updateInsightAction = authenticatedActionClient
.schema(ZUpdateInsightAction)
.action(async ({ ctx, parsedInput }) => {
try {
const insight = await cache(
() =>
prisma.insight.findUnique({
where: {
id: parsedInput.insightId,
},
select: {
environmentId: true,
},
}),
[`getInsight-${parsedInput.insightId}`],
{
tags: [insightCache.tag.byId(parsedInput.insightId)],
}
)();
if (!insight) {
throw new Error("Insight not found");
}
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(insight.environmentId),
rules: ["response", "update"],
});
return await updateInsight(parsedInput.insightId, parsedInput.data);
} catch (error) {
console.error("Error updating insight:", {
insightId: parsedInput.insightId,
error,
});
if (error instanceof Error) {
throw new Error(`Failed to update insight: ${error.message}`);
}
throw new Error("An unexpected error occurred while updating the insight");
}
});
@@ -0,0 +1,70 @@
import { useState } from "react";
import { toast } from "react-hot-toast";
import { TInsight } from "@formbricks/types/insights";
import { BadgeSelect, TBadgeSelectOption } from "@formbricks/ui/components/BadgeSelect";
import { updateInsightAction } from "../actions";
interface CategoryBadgeProps {
category: TInsight["category"];
insightId: string;
}
const categoryOptions: TBadgeSelectOption[] = [
{ text: "Complaint", type: "error" },
{ text: "Request", type: "warning" },
{ text: "Praise", type: "success" },
{ text: "Other", type: "gray" },
];
const categoryMapping: Record<string, TInsight["category"]> = {
Complaint: "complaint",
Request: "featureRequest",
Praise: "praise",
Other: "other",
};
const getCategoryIndex = (category: TInsight["category"]) => {
switch (category) {
case "complaint":
return 0;
case "featureRequest":
return 1;
case "praise":
return 2;
default:
return 3;
}
};
const CategoryBadge = ({ category, insightId }: CategoryBadgeProps) => {
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdateCategory = async (newCategory: TInsight["category"]) => {
setIsUpdating(true);
try {
await updateInsightAction({ insightId, data: { category: newCategory } });
toast.success("Category updated successfully!");
} catch (error) {
console.error("Failed to update insight:", error);
toast.error("Failed to update category.");
} finally {
setIsUpdating(false);
}
};
return (
<BadgeSelect
options={categoryOptions}
selectedIndex={getCategoryIndex(category)}
onChange={(newIndex) => {
const newCategoryText = categoryOptions[newIndex].text;
const newCategory = categoryMapping[newCategoryText];
handleUpdateCategory(newCategory);
}}
size="tiny"
isLoading={isUpdating}
/>
);
};
export default CategoryBadge;
@@ -3,7 +3,6 @@
import { Greeting } from "@/modules/ee/insights/experience/components/greeting";
import { InsightsCard } from "@/modules/ee/insights/experience/components/insights-card";
import { ExperiencePageStats } from "@/modules/ee/insights/experience/components/stats";
import { TemplatesCard } from "@/modules/ee/insights/experience/components/templates-card";
import { getDateFromTimeRange } from "@/modules/ee/insights/experience/lib/utils";
import { TStatsPeriod } from "@/modules/ee/insights/experience/types/stats";
import { useTranslations } from "next-intl";
@@ -39,7 +38,12 @@ export const Dashboard = ({
<hr className="border-slate-200" />
<Tabs
value={statsPeriod}
onValueChange={(value) => value && setStatsPeriod(value as TStatsPeriod)}
onValueChange={(value) => {
if (value) {
console.log("Stats period changed to:", value);
setStatsPeriod(value as TStatsPeriod);
}
}}
className="flex justify-center">
<TabsList>
<TabsTrigger value="day" aria-label="Toggle day">
@@ -68,12 +72,6 @@ export const Dashboard = ({
documentsPerPage={documentsPerPage}
locale={locale}
/>
<TemplatesCard
environment={environment}
product={product}
user={user}
prefilledFilters={["link", null, "customerSuccess"]}
/>
</div>
);
};
@@ -1,26 +1,20 @@
const LoadingRow = () => (
<div className="flex items-center justify-between">
<div className="ph-no-capture h-6 w-10 animate-pulse rounded-full bg-slate-200"></div>
<div className="ph-no-capture h-6 w-40 animate-pulse rounded-full bg-slate-200"></div>
<div className="ph-no-capture h-6 w-48 animate-pulse rounded-full bg-slate-200"></div>
<div className="ph-no-capture h-6 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
);
export const InsightLoading = () => {
return (
<div className="space-y-4">
<div className="ph-no-capture animate-pulse rounded-lg bg-white shadow-md">
<div className="ph-no-capture animate-pulse rounded-lg bg-white">
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<div className="ph-no-capture h-8 w-10 animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-8 w-40 animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-8 w-48 animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-8 w-16 animate-pulse rounded-lg bg-gray-200"></div>
</div>
<div className="flex items-center justify-between">
<div className="ph-no-capture h-8 w-10 animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-8 w-40 animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-8 w-48 animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-8 w-16 animate-pulse rounded-lg bg-gray-200"></div>
</div>
<div className="flex items-center justify-between">
<div className="ph-no-capture h-8 w-10 animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-8 w-40 animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-8 w-48 animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-8 w-16 animate-pulse rounded-lg bg-gray-200"></div>
</div>
<LoadingRow />
<LoadingRow />
<LoadingRow />
</div>
</div>
</div>
@@ -8,7 +8,6 @@ import formbricks from "@formbricks/js";
import { TDocumentFilterCriteria } from "@formbricks/types/documents";
import { TInsight, TInsightFilterCriteria } from "@formbricks/types/insights";
import { TUserLocale } from "@formbricks/types/user";
import { Badge } from "@formbricks/ui/components/Badge";
import { Button } from "@formbricks/ui/components/Button";
import {
Table,
@@ -20,6 +19,7 @@ import {
} from "@formbricks/ui/components/Table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@formbricks/ui/components/Tabs";
import { getEnvironmentInsightsAction } from "../actions";
import CategoryBadge from "./category-select";
import { InsightLoading } from "./insight-loading";
interface InsightViewProps {
@@ -40,10 +40,10 @@ export const InsightView = ({
const t = useTranslations();
const [insights, setInsights] = useState<TInsight[]>([]);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetching, setIsFetching] = useState(true);
const [isFetching, setIsFetching] = useState(false);
const [isInsightSheetOpen, setIsInsightSheetOpen] = useState(false);
const [currentInsight, setCurrentInsight] = useState<TInsight | null>(null);
const [activeTab, setActiveTab] = useState<string>("all");
const [activeTab, setActiveTab] = useState<string>("featureRequest");
const handleFeedback = (feedback: "positive" | "negative") => {
formbricks.track("AI Insight Feedback", {
@@ -81,16 +81,27 @@ export const InsightView = ({
const fetchInitialInsights = async () => {
setIsFetching(true);
setInsights([]);
const res = await getEnvironmentInsightsAction({
environmentId,
limit: insightsPerPage,
offset: 0,
insightsFilter,
});
if (res?.data) {
setInsights(res.data);
setHasMore(res.data.length >= insightsPerPage);
setIsFetching(false);
try {
const res = await getEnvironmentInsightsAction({
environmentId,
limit: insightsPerPage,
offset: 0,
insightsFilter,
});
if (res?.data) {
setInsights(res.data);
setHasMore(res.data.length >= insightsPerPage);
// Find the updated currentInsight based on its id
const updatedCurrentInsight = res.data.find((insight) => insight.id === currentInsight?.id);
// Update currentInsight with the matched insight or default to the first one
setCurrentInsight(updatedCurrentInsight || (res.data.length > 0 ? res.data[0] : null));
}
} catch (error) {
console.error("Failed to fetch insights:", error);
} finally {
setIsFetching(false); // Ensure isFetching is set to false in all cases
}
};
@@ -119,14 +130,16 @@ export const InsightView = ({
return (
<div>
<Tabs defaultValue="all" onValueChange={handleFilterSelect}>
<TabsList>
<TabsTrigger value="all">{t("environments.experience.all")}</TabsTrigger>
<TabsTrigger value="complaint">{t("environments.experience.complaint")}</TabsTrigger>
<TabsTrigger value="featureRequest">{t("environments.experience.feature_request")}</TabsTrigger>
<TabsTrigger value="praise">{t("environments.experience.praise")}</TabsTrigger>
<TabsTrigger value="other">{t("common.other")}</TabsTrigger>
</TabsList>
<Tabs defaultValue="featureRequest" onValueChange={handleFilterSelect}>
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="all">{t("environments.experience.all")}</TabsTrigger>
<TabsTrigger value="complaint">{t("environments.experience.complaint")}</TabsTrigger>
<TabsTrigger value="featureRequest">{t("environments.experience.feature_request")}</TabsTrigger>
<TabsTrigger value="praise">{t("environments.experience.praise")}</TabsTrigger>
<TabsTrigger value="other">{t("common.other")}</TabsTrigger>
</TabsList>
</div>
<TabsContent value={activeTab}>
<Table>
<TableHeader>
@@ -145,34 +158,28 @@ export const InsightView = ({
</TableCell>
</TableRow>
) : (
insights.map((insight) => (
<TableRow
key={insight.id}
className="group cursor-pointer hover:bg-slate-50"
onClick={() => {
setCurrentInsight(insight);
setIsInsightSheetOpen(true);
}}>
<TableCell className="flex font-medium">
{insight._count.documentInsights} <UserIcon className="ml-2 h-4 w-4" />
</TableCell>
<TableCell className="font-medium">{insight.title}</TableCell>
<TableCell className="underline-offset-2 group-hover:underline">
{insight.description}
</TableCell>
<TableCell>
{insight.category === "complaint" ? (
<Badge text="Complaint" type="error" size="tiny" />
) : insight.category === "featureRequest" ? (
<Badge text="Feature Request" type="warning" size="tiny" />
) : insight.category === "praise" ? (
<Badge text="Praise" type="success" size="tiny" />
) : (
<Badge text="Other" type="gray" size="tiny" />
)}
</TableCell>
</TableRow>
))
insights
.sort((a, b) => b._count.documentInsights - a._count.documentInsights)
.map((insight) => (
<TableRow
key={insight.id}
className="group cursor-pointer hover:bg-slate-50"
onClick={() => {
setCurrentInsight(insight);
setIsInsightSheetOpen(true);
}}>
<TableCell className="flex font-medium">
{insight._count.documentInsights} <UserIcon className="ml-2 h-4 w-4" />
</TableCell>
<TableCell className="font-medium">{insight.title}</TableCell>
<TableCell className="underline-offset-2 group-hover:underline">
{insight.description}
</TableCell>
<TableCell className="flex items-center justify-between gap-2">
<CategoryBadge category={insight.category} insightId={insight.id} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
@@ -9,6 +9,7 @@ import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { Badge } from "@formbricks/ui/components/Badge";
import { Card, CardContent, CardHeader, CardTitle } from "@formbricks/ui/components/Card";
import { TooltipRenderer } from "@formbricks/ui/components/Tooltip";
import { cn } from "@formbricks/ui/lib/utils";
interface ExperiencePageStatsProps {
@@ -84,20 +85,23 @@ export const ExperiencePageStats = ({ statsFrom, environmentId }: ExperiencePage
<CardContent className="flex items-center justify-between">
<div className="text-2xl font-bold capitalize">
{isLoading ? (
<div className={cn("h-8 animate-pulse rounded bg-gray-200", stat.width)}></div>
<div className={cn("h-4 animate-pulse rounded-full bg-slate-200", stat.width)}></div>
) : stat.key === "sentimentScore" ? (
<div className="flex items-center font-medium text-slate-700">
<TooltipRenderer tooltipContent={`${stat.value} positive`}>
{stats.overallSentiment === "positive" ? (
<Badge text="Positive" type="success" size="large" />
) : stats.overallSentiment === "negative" ? (
<Badge text="Negative" type="error" size="large" />
) : (
<Badge text="Neutral" type="gray" size="large" />
)}
</TooltipRenderer>
</div>
) : (
(stat.value ?? "-")
)}
</div>
{stat.key === "sentimentScore" && stats.overallSentiment && (
<div>
{stats.overallSentiment === "positive" ? (
<Badge text={t("environments.experience.positive")} type="success" size="tiny" />
) : (
<Badge text={t("environments.experience.negative")} type="error" size="tiny" />
)}
</div>
)}
</CardContent>
</Card>
))}
@@ -82,3 +82,22 @@ export const getInsights = reactCache(
}
)()
);
export const updateInsight = async (insightId: string, updates: Partial<TInsight>): Promise<void> => {
try {
await prisma.insight.update({
where: { id: insightId },
data: updates,
});
// Invalidate the cache for the updated insight
insightCache.revalidate({ id: insightId });
} catch (error) {
console.error("Error in updateInsight:", error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -75,7 +75,8 @@ export const getStats = reactCache(
if (sentimentCounts.positive || sentimentCounts.negative) {
sentimentScore = sentimentCounts.positive / (sentimentCounts.positive + sentimentCounts.negative);
overallSentiment = sentimentScore > 0.5 ? "positive" : "negative";
overallSentiment =
sentimentScore > 0.5 ? "positive" : sentimentScore < 0.5 ? "negative" : "neutral";
}
return {
@@ -2,7 +2,7 @@ import { z } from "zod";
export const ZStats = z.object({
sentimentScore: z.number().optional(),
overallSentiment: z.enum(["positive", "negative"]).optional(),
overallSentiment: z.enum(["positive", "negative", "neutral"]).optional(),
activeSurveys: z.number(),
newResponses: z.number(),
analysedFeedbacks: z.number(),