mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-02 03:15:05 -05:00
feat: Update Insight category, document sentiment and archive insights (#4038)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user