mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-24 06:28:49 -05:00
chore: remove old AI classification feature (#5529)
Co-authored-by: Victor Santos <victor@formbricks.com>
This commit is contained in:
@@ -37,12 +37,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-ai",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
|
||||
-1
@@ -12,7 +12,6 @@ export const openTextQuestion: Survey["questions"][number] = {
|
||||
inputType: "text",
|
||||
required: true,
|
||||
headline: { en: "Open Text Question" },
|
||||
insightsEnabled: true,
|
||||
};
|
||||
|
||||
export const fileUploadQuestion: Survey["questions"][number] = {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
|
||||
import { getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { getIsAIEnabled, getIsOrganizationAIReady } from "@/modules/ee/license-check/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
|
||||
export const checkAIPermission = async (organizationId: string) => {
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled({
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
billing: organization.billing,
|
||||
});
|
||||
|
||||
if (!isAIEnabled) {
|
||||
throw new OperationNotAllowedError("AI is not enabled for this organization");
|
||||
}
|
||||
};
|
||||
|
||||
const ZGenerateInsightsForSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const generateInsightsForSurveyAction = authenticatedActionClient
|
||||
.schema(ZGenerateInsightsForSurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZGenerateInsightsForSurveyAction,
|
||||
data: parsedInput,
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await checkAIPermission(organizationId);
|
||||
generateInsightsForSurvey(parsedInput.surveyId);
|
||||
});
|
||||
|
||||
const ZUpdateOrganizationAIEnabledAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationUpdateInput.pick({ isAIEnabled: true }),
|
||||
});
|
||||
|
||||
export const updateOrganizationAIEnabledAction = authenticatedActionClient
|
||||
.schema(ZUpdateOrganizationAIEnabledAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const organizationId = parsedInput.organizationId;
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationUpdateInput.pick({ isAIEnabled: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan);
|
||||
|
||||
if (!isOrganizationAIReady) {
|
||||
throw new OperationNotAllowedError("AI is not ready for this organization");
|
||||
}
|
||||
|
||||
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import {
|
||||
getEnvironmentIdFromInsightId,
|
||||
getEnvironmentIdFromSurveyId,
|
||||
getOrganizationIdFromDocumentId,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromInsightId,
|
||||
getProjectIdFromDocumentId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromInsightId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { checkAIPermission } from "@/modules/ee/insights/actions";
|
||||
import {
|
||||
getDocumentsByInsightId,
|
||||
getDocumentsByInsightIdSurveyIdQuestionId,
|
||||
} from "@/modules/ee/insights/components/insight-sheet/lib/documents";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZDocumentFilterCriteria } from "@formbricks/types/documents";
|
||||
import { ZSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { updateDocument } from "./lib/documents";
|
||||
|
||||
const ZGetDocumentsByInsightIdSurveyIdQuestionIdAction = z.object({
|
||||
insightId: ZId,
|
||||
surveyId: ZId,
|
||||
questionId: ZSurveyQuestionId,
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
});
|
||||
|
||||
export const getDocumentsByInsightIdSurveyIdQuestionIdAction = authenticatedActionClient
|
||||
.schema(ZGetDocumentsByInsightIdSurveyIdQuestionIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const insightEnvironmentId = await getEnvironmentIdFromInsightId(parsedInput.insightId);
|
||||
const surveyEnvironmentId = await getEnvironmentIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
if (insightEnvironmentId !== surveyEnvironmentId) {
|
||||
throw new Error("Insight and survey are not in the same environment");
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(surveyEnvironmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(surveyEnvironmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await checkAIPermission(organizationId);
|
||||
|
||||
return await getDocumentsByInsightIdSurveyIdQuestionId(
|
||||
parsedInput.insightId,
|
||||
parsedInput.surveyId,
|
||||
parsedInput.questionId,
|
||||
parsedInput.limit,
|
||||
parsedInput.offset
|
||||
);
|
||||
});
|
||||
|
||||
const ZGetDocumentsByInsightIdAction = z.object({
|
||||
insightId: ZId,
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
filterCriteria: ZDocumentFilterCriteria.optional(),
|
||||
});
|
||||
|
||||
export const getDocumentsByInsightIdAction = authenticatedActionClient
|
||||
.schema(ZGetDocumentsByInsightIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromInsightId(parsedInput.insightId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromInsightId(parsedInput.insightId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await checkAIPermission(organizationId);
|
||||
|
||||
return await getDocumentsByInsightId(
|
||||
parsedInput.insightId,
|
||||
parsedInput.limit,
|
||||
parsedInput.offset,
|
||||
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 organizationId = await getOrganizationIdFromDocumentId(parsedInput.documentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromDocumentId(parsedInput.documentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await checkAIPermission(organizationId);
|
||||
|
||||
return await updateDocument(parsedInput.documentId, parsedInput.data);
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TInsightWithDocumentCount } from "@/modules/ee/insights/experience/types/insights";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Card, CardContent, CardFooter } from "@/modules/ui/components/card";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/modules/ui/components/sheet";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
|
||||
import { useDeferredValue, useEffect, useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { TDocument, TDocumentFilterCriteria } from "@formbricks/types/documents";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import CategoryBadge from "../../experience/components/category-select";
|
||||
import SentimentSelect from "../sentiment-select";
|
||||
import { getDocumentsByInsightIdAction, getDocumentsByInsightIdSurveyIdQuestionIdAction } from "./actions";
|
||||
|
||||
interface InsightSheetProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
insight: TInsightWithDocumentCount | null;
|
||||
surveyId?: string;
|
||||
questionId?: string;
|
||||
handleFeedback: (feedback: "positive" | "negative") => void;
|
||||
documentsFilter?: TDocumentFilterCriteria;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const InsightSheet = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
insight,
|
||||
surveyId,
|
||||
questionId,
|
||||
handleFeedback,
|
||||
documentsFilter,
|
||||
documentsPerPage = 10,
|
||||
locale,
|
||||
}: InsightSheetProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [documents, setDocuments] = useState<TDocument[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false); // New state for loading
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDocuments([]);
|
||||
setPage(1);
|
||||
setHasMore(false); // Reset hasMore when the sheet is opened
|
||||
}
|
||||
if (isOpen && insight) {
|
||||
fetchDocuments();
|
||||
}
|
||||
|
||||
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);
|
||||
handleFeedback(feedback);
|
||||
};
|
||||
|
||||
const loadMoreDocuments = () => {
|
||||
if (hasMore) {
|
||||
setPage((prevPage) => prevPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!insight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(v) => setIsOpen(v)}>
|
||||
<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 text-slate-700 hover:text-emerald-500"
|
||||
onClick={() => handleFeedbackClick("positive")}
|
||||
/>
|
||||
<ThumbsDownIcon
|
||||
className="downvote h-4 w-4 cursor-pointer text-slate-700 hover:text-amber-600"
|
||||
onClick={() => handleFeedbackClick("negative")}
|
||||
/>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<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 whitespace-pre-wrap">
|
||||
<Markdown>{document.text}</Markdown>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between rounded-br-xl rounded-bl-xl border-t border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||
<p>
|
||||
Sentiment: <SentimentSelect documentId={document.id} sentiment={document.sentiment} />
|
||||
</p>
|
||||
<p>{timeSince(new Date(document.createdAt).toISOString(), locale)}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-5">
|
||||
<Button onClick={loadMoreDocuments} variant="secondary" size="sm">
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { documentCache } from "@/lib/cache/document";
|
||||
import { insightCache } from "@/lib/cache/insight";
|
||||
import { DOCUMENTS_PER_PAGE } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
TDocument,
|
||||
TDocumentFilterCriteria,
|
||||
ZDocument,
|
||||
ZDocumentFilterCriteria,
|
||||
} from "@formbricks/types/documents";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuestionId, ZSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getDocumentsByInsightId = reactCache(
|
||||
async (
|
||||
insightId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
filterCriteria?: TDocumentFilterCriteria
|
||||
): Promise<TDocument[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs(
|
||||
[insightId, ZId],
|
||||
[limit, z.number().optional()],
|
||||
[offset, z.number().optional()],
|
||||
[filterCriteria, ZDocumentFilterCriteria.optional()]
|
||||
);
|
||||
|
||||
limit = limit ?? DOCUMENTS_PER_PAGE;
|
||||
try {
|
||||
const documents = await prisma.document.findMany({
|
||||
where: {
|
||||
documentInsights: {
|
||||
some: {
|
||||
insightId,
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
gte: filterCriteria?.createdAt?.min,
|
||||
lte: filterCriteria?.createdAt?.max,
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
return documents;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getDocumentsByInsightId-${insightId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [documentCache.tag.byInsightId(insightId), insightCache.tag.byId(insightId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getDocumentsByInsightIdSurveyIdQuestionId = reactCache(
|
||||
async (
|
||||
insightId: string,
|
||||
surveyId: string,
|
||||
questionId: TSurveyQuestionId,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<TDocument[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs(
|
||||
[insightId, ZId],
|
||||
[surveyId, ZId],
|
||||
[questionId, ZSurveyQuestionId],
|
||||
[limit, z.number().optional()],
|
||||
[offset, z.number().optional()]
|
||||
);
|
||||
|
||||
limit = limit ?? DOCUMENTS_PER_PAGE;
|
||||
try {
|
||||
const documents = await prisma.document.findMany({
|
||||
where: {
|
||||
questionId,
|
||||
surveyId,
|
||||
documentInsights: {
|
||||
some: {
|
||||
insightId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
return documents;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getDocumentsByInsightIdSurveyIdQuestionId-${insightId}-${surveyId}-${questionId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [
|
||||
documentCache.tag.byInsightIdSurveyIdQuestionId(insightId, surveyId, questionId),
|
||||
documentCache.tag.byInsightId(insightId),
|
||||
insightCache.tag.byId(insightId),
|
||||
],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getDocument = reactCache(
|
||||
async (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<void> => {
|
||||
validateInputs([documentId, ZId], [data, ZDocument.partial()]);
|
||||
try {
|
||||
const updatedDocument = await prisma.document.update({
|
||||
where: { id: documentId },
|
||||
data,
|
||||
select: {
|
||||
environmentId: true,
|
||||
documentInsights: {
|
||||
select: {
|
||||
insightId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
documentCache.revalidate({ environmentId: updatedDocument.environmentId });
|
||||
|
||||
for (const { insightId } of updatedDocument.documentInsights) {
|
||||
documentCache.revalidate({ insightId });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
// InsightView.test.jsx
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { InsightView } from "./insights-view";
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
// Stub out the translation hook so that keys are returned as-is.
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Spy on formbricks.track
|
||||
vi.mock("@formbricks/js", () => ({
|
||||
default: {
|
||||
track: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// A simple implementation for classnames.
|
||||
vi.mock("@/lib/cn", () => ({
|
||||
cn: (...classes) => classes.join(" "),
|
||||
}));
|
||||
|
||||
// Mock CategoryBadge to render a simple button.
|
||||
vi.mock("../experience/components/category-select", () => ({
|
||||
default: ({ category, insightId, onCategoryChange }) => (
|
||||
<button data-testid="category-badge" onClick={() => onCategoryChange(insightId, category)}>
|
||||
CategoryBadge: {category}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock InsightSheet to display its open/closed state and the insight title.
|
||||
vi.mock("@/modules/ee/insights/components/insight-sheet", () => ({
|
||||
InsightSheet: ({ isOpen, insight }) => (
|
||||
<div data-testid="insight-sheet">
|
||||
{isOpen ? "InsightSheet Open" : "InsightSheet Closed"}
|
||||
{insight && ` - ${insight.title}`}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Create an array of 15 dummy insights.
|
||||
// Even-indexed insights will have the category "complaint"
|
||||
// and odd-indexed insights will have "praise".
|
||||
const dummyInsights = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `insight-${i}`,
|
||||
_count: { documentInsights: i },
|
||||
title: `Insight Title ${i}`,
|
||||
description: `Insight Description ${i}`,
|
||||
category: i % 2 === 0 ? "complaint" : "praise",
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
environmentId: "environment-1",
|
||||
})) as TSurveyQuestionSummaryOpenText["insights"];
|
||||
|
||||
// Helper function to render the component with default props.
|
||||
const renderComponent = (props = {}) => {
|
||||
const defaultProps = {
|
||||
insights: dummyInsights,
|
||||
questionId: "question-1",
|
||||
surveyId: "survey-1",
|
||||
documentsFilter: {},
|
||||
isFetching: false,
|
||||
documentsPerPage: 5,
|
||||
locale: "en" as TUserLocale,
|
||||
};
|
||||
|
||||
return render(<InsightView {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
// --- Tests ---
|
||||
describe("InsightView Component", () => {
|
||||
test("renders table headers", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText("#")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.title")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.description")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.experience.category")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows "no insights found" when insights array is empty', () => {
|
||||
renderComponent({ insights: [] });
|
||||
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render insights when isFetching is true", () => {
|
||||
renderComponent({ isFetching: true, insights: [] });
|
||||
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("filters insights based on selected tab", async () => {
|
||||
renderComponent();
|
||||
|
||||
// Click on the "complaint" tab.
|
||||
const complaintTab = screen.getAllByText("environments.experience.complaint")[0];
|
||||
fireEvent.click(complaintTab);
|
||||
|
||||
// Grab all table rows from the table body.
|
||||
const rows = await screen.findAllByRole("row");
|
||||
|
||||
// Check that none of the rows include text from a "praise" insight.
|
||||
rows.forEach((row) => {
|
||||
expect(row.textContent).not.toEqual(/Insight Title 1/);
|
||||
});
|
||||
});
|
||||
|
||||
test("load more button increases visible insights count", () => {
|
||||
renderComponent();
|
||||
// Initially, "Insight Title 10" should not be visible because only 10 items are shown.
|
||||
expect(screen.queryByText("Insight Title 10")).not.toBeInTheDocument();
|
||||
|
||||
// Get all buttons with the text "common.load_more" and filter for those that are visible.
|
||||
const loadMoreButtons = screen.getAllByRole("button", { name: /common\.load_more/i });
|
||||
expect(loadMoreButtons.length).toBeGreaterThan(0);
|
||||
|
||||
// Click the first visible "load more" button.
|
||||
fireEvent.click(loadMoreButtons[0]);
|
||||
|
||||
// Now, "Insight Title 10" should be visible.
|
||||
expect(screen.getByText("Insight Title 10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens insight sheet when a row is clicked", () => {
|
||||
renderComponent();
|
||||
// Get all elements that display "Insight Title 0" and use the first one to find its table row
|
||||
const cells = screen.getAllByText("Insight Title 0");
|
||||
expect(cells.length).toBeGreaterThan(0);
|
||||
const rowElement = cells[0].closest("tr");
|
||||
expect(rowElement).not.toBeNull();
|
||||
// Simulate a click on the table row
|
||||
fireEvent.click(rowElement!);
|
||||
|
||||
// Get all instances of the InsightSheet component
|
||||
const sheets = screen.getAllByTestId("insight-sheet");
|
||||
// Filter for the one that contains the expected text
|
||||
const matchingSheet = sheets.find((sheet) =>
|
||||
sheet.textContent?.includes("InsightSheet Open - Insight Title 0")
|
||||
);
|
||||
|
||||
expect(matchingSheet).toBeDefined();
|
||||
expect(matchingSheet).toHaveTextContent("InsightSheet Open - Insight Title 0");
|
||||
});
|
||||
|
||||
test("category badge calls onCategoryChange and updates the badge (even if value remains the same)", () => {
|
||||
renderComponent();
|
||||
// Get the first category badge. For index 0, the category is "complaint".
|
||||
const categoryBadge = screen.getAllByTestId("category-badge")[0];
|
||||
|
||||
// It should display "complaint" initially.
|
||||
expect(categoryBadge).toHaveTextContent("CategoryBadge: complaint");
|
||||
|
||||
// Click the category badge to trigger onCategoryChange.
|
||||
fireEvent.click(categoryBadge);
|
||||
|
||||
// After clicking, the badge should still display "complaint" (since our mock simply passes the current value).
|
||||
expect(categoryBadge).toHaveTextContent("CategoryBadge: complaint");
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { InsightSheet } from "@/modules/ee/insights/components/insight-sheet";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { Insight, InsightCategory } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { UserIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { TDocumentFilterCriteria } from "@formbricks/types/documents";
|
||||
import { TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import CategoryBadge from "../experience/components/category-select";
|
||||
|
||||
interface InsightViewProps {
|
||||
insights: TSurveyQuestionSummaryOpenText["insights"];
|
||||
questionId?: string;
|
||||
surveyId?: string;
|
||||
documentsFilter?: TDocumentFilterCriteria;
|
||||
isFetching?: boolean;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const InsightView = ({
|
||||
insights,
|
||||
questionId,
|
||||
surveyId,
|
||||
documentsFilter,
|
||||
isFetching,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
}: InsightViewProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isInsightSheetOpen, setIsInsightSheetOpen] = useState(true);
|
||||
const [localInsights, setLocalInsights] = useState<TSurveyQuestionSummaryOpenText["insights"]>(insights);
|
||||
const [currentInsight, setCurrentInsight] = useState<
|
||||
TSurveyQuestionSummaryOpenText["insights"][number] | null
|
||||
>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>("all");
|
||||
const [visibleInsights, setVisibleInsights] = useState(10);
|
||||
|
||||
const handleFeedback = (_feedback: "positive" | "negative") => {
|
||||
formbricks.track("AI Insight Feedback");
|
||||
};
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(filterValue: string) => {
|
||||
setActiveTab(filterValue);
|
||||
if (filterValue === "all") {
|
||||
setLocalInsights(insights);
|
||||
} else {
|
||||
setLocalInsights(insights.filter((insight) => insight.category === (filterValue as InsightCategory)));
|
||||
}
|
||||
},
|
||||
[insights]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleFilterSelect(activeTab);
|
||||
|
||||
// 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));
|
||||
};
|
||||
|
||||
const updateLocalInsight = (insightId: string, updates: Partial<Insight>) => {
|
||||
setLocalInsights((prevInsights) =>
|
||||
prevInsights.map((insight) => (insight.id === insightId ? { ...insight, ...updates } : insight))
|
||||
);
|
||||
};
|
||||
|
||||
const onCategoryChange = async (insightId: string, newCategory: InsightCategory) => {
|
||||
updateLocalInsight(insightId, { category: newCategory });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("mt-2")}>
|
||||
<Tabs defaultValue="all" onValueChange={handleFilterSelect}>
|
||||
<TabsList className={cn("ml-2")}>
|
||||
<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>
|
||||
<TabsContent value={activeTab}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">#</TableHead>
|
||||
<TableHead>{t("common.title")}</TableHead>
|
||||
<TableHead>{t("common.description")}</TableHead>
|
||||
<TableHead>{t("environments.experience.category")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isFetching ? null : insights.length === 0 ? (
|
||||
<TableRow className="pointer-events-none">
|
||||
<TableCell colSpan={4} className="py-8 text-center">
|
||||
<p className="text-slate-500">{t("environments.experience.no_insights_found")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : localInsights.length === 0 ? (
|
||||
<TableRow className="pointer-events-none">
|
||||
<TableCell colSpan={4} className="py-8 text-center">
|
||||
<p className="text-slate-500">
|
||||
{t("environments.experience.no_insights_for_this_filter")}
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
localInsights.slice(0, visibleInsights).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>
|
||||
<CategoryBadge
|
||||
category={insight.category}
|
||||
insightId={insight.id}
|
||||
onCategoryChange={onCategoryChange}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{visibleInsights < localInsights.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InsightSheet
|
||||
isOpen={isInsightSheetOpen}
|
||||
setIsOpen={setIsInsightSheetOpen}
|
||||
insight={currentInsight}
|
||||
surveyId={surveyId}
|
||||
questionId={questionId}
|
||||
handleFeedback={handleFeedback}
|
||||
documentsFilter={documentsFilter}
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import { BadgeSelect, TBadgeSelectOption } from "@/modules/ui/components/badge-select";
|
||||
import { useState } from "react";
|
||||
import { TDocument, TDocumentSentiment } from "@formbricks/types/documents";
|
||||
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,130 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromInsightId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromInsightId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { checkAIPermission } from "@/modules/ee/insights/actions";
|
||||
import { ZInsightFilterCriteria } from "@/modules/ee/insights/experience/types/insights";
|
||||
import { z } from "zod";
|
||||
import { ZInsight } from "@formbricks/database/zod/insights";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { getInsights, updateInsight } from "./lib/insights";
|
||||
import { getStats } from "./lib/stats";
|
||||
|
||||
const ZGetEnvironmentInsightsAction = z.object({
|
||||
environmentId: ZId,
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
insightsFilter: ZInsightFilterCriteria.optional(),
|
||||
});
|
||||
|
||||
export const getEnvironmentInsightsAction = authenticatedActionClient
|
||||
.schema(ZGetEnvironmentInsightsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await checkAIPermission(organizationId);
|
||||
|
||||
return await getInsights(
|
||||
parsedInput.environmentId,
|
||||
parsedInput.limit,
|
||||
parsedInput.offset,
|
||||
parsedInput.insightsFilter
|
||||
);
|
||||
});
|
||||
|
||||
const ZGetStatsAction = z.object({
|
||||
environmentId: ZId,
|
||||
statsFrom: z.date().optional(),
|
||||
});
|
||||
|
||||
export const getStatsAction = authenticatedActionClient
|
||||
.schema(ZGetStatsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await checkAIPermission(organizationId);
|
||||
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 organizationId = await getOrganizationIdFromInsightId(parsedInput.insightId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromInsightId(parsedInput.insightId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await checkAIPermission(organizationId);
|
||||
|
||||
return await updateInsight(parsedInput.insightId, parsedInput.data);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
insightId: parsedInput.insightId,
|
||||
error,
|
||||
},
|
||||
"Error updating insight"
|
||||
);
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to update insight: ${error.message}`);
|
||||
}
|
||||
throw new Error("An unexpected error occurred while updating the insight");
|
||||
}
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BadgeSelect, TBadgeSelectOption } from "@/modules/ui/components/badge-select";
|
||||
import { InsightCategory } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { updateInsightAction } from "../actions";
|
||||
|
||||
interface CategoryBadgeProps {
|
||||
category: InsightCategory;
|
||||
insightId: string;
|
||||
onCategoryChange?: (insightId: string, category: InsightCategory) => void;
|
||||
}
|
||||
|
||||
const categoryOptions: TBadgeSelectOption[] = [
|
||||
{ text: "Complaint", type: "error" },
|
||||
{ text: "Request", type: "warning" },
|
||||
{ text: "Praise", type: "success" },
|
||||
{ text: "Other", type: "gray" },
|
||||
];
|
||||
|
||||
const categoryMapping: Record<string, InsightCategory> = {
|
||||
Complaint: "complaint",
|
||||
Request: "featureRequest",
|
||||
Praise: "praise",
|
||||
Other: "other",
|
||||
};
|
||||
|
||||
const getCategoryIndex = (category: InsightCategory) => {
|
||||
switch (category) {
|
||||
case "complaint":
|
||||
return 0;
|
||||
case "featureRequest":
|
||||
return 1;
|
||||
case "praise":
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
const CategoryBadge = ({ category, insightId, onCategoryChange }: CategoryBadgeProps) => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const handleUpdateCategory = async (newCategory: InsightCategory) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateInsightAction({ insightId, data: { category: newCategory } });
|
||||
onCategoryChange?.(insightId, newCategory);
|
||||
toast.success(t("environments.experience.category_updated_successfully"));
|
||||
} catch (error) {
|
||||
console.error(t("environments.experience.failed_to_update_category"), error);
|
||||
toast.error(t("environments.experience.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;
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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 { getDateFromTimeRange } from "@/modules/ee/insights/experience/lib/utils";
|
||||
import { TStatsPeriod } from "@/modules/ee/insights/experience/types/stats";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface DashboardProps {
|
||||
user: TUser;
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
insightsPerPage: number;
|
||||
documentsPerPage: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const Dashboard = ({
|
||||
environment,
|
||||
project,
|
||||
user,
|
||||
insightsPerPage,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
}: DashboardProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [statsPeriod, setStatsPeriod] = useState<TStatsPeriod>("week");
|
||||
const statsFrom = getDateFromTimeRange(statsPeriod);
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4">
|
||||
<Greeting userName={user.name} />
|
||||
<hr className="border-slate-200" />
|
||||
<Tabs
|
||||
value={statsPeriod}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setStatsPeriod(value as TStatsPeriod);
|
||||
}
|
||||
}}
|
||||
className="flex justify-center">
|
||||
<TabsList>
|
||||
<TabsTrigger value="day" aria-label="Toggle day">
|
||||
{t("environments.experience.today")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="week" aria-label="Toggle week">
|
||||
{t("environments.experience.this_week")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="month" aria-label="Toggle month">
|
||||
{t("environments.experience.this_month")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="quarter" aria-label="Toggle quarter">
|
||||
{t("environments.experience.this_quarter")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all" aria-label="Toggle all">
|
||||
{t("environments.experience.all_time")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<ExperiencePageStats statsFrom={statsFrom} environmentId={environment.id} />
|
||||
<InsightsCard
|
||||
statsFrom={statsFrom}
|
||||
projectName={project.name}
|
||||
environmentId={environment.id}
|
||||
insightsPerPage={insightsPerPage}
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { H1 } from "@/modules/ui/components/typography";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
interface GreetingProps {
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export const Greeting = ({ userName }: GreetingProps) => {
|
||||
const { t } = useTranslate();
|
||||
function getGreeting() {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return t("environments.experience.good_morning");
|
||||
if (hour < 18) return t("environments.experience.good_afternoon");
|
||||
return t("environments.experience.good_evening");
|
||||
}
|
||||
|
||||
const greeting = getGreeting();
|
||||
|
||||
return (
|
||||
<H1>
|
||||
{greeting}, {userName}
|
||||
</H1>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
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">
|
||||
<div className="space-y-4 p-4">
|
||||
<LoadingRow />
|
||||
<LoadingRow />
|
||||
<LoadingRow />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,215 +0,0 @@
|
||||
import { TInsightWithDocumentCount } from "@/modules/ee/insights/experience/types/insights";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { InsightView } from "./insight-view";
|
||||
|
||||
// Mock the translation hook to simply return the key.
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the action that fetches insights.
|
||||
const mockGetEnvironmentInsightsAction = vi.fn();
|
||||
vi.mock("../actions", () => ({
|
||||
getEnvironmentInsightsAction: (...args: any[]) => mockGetEnvironmentInsightsAction(...args),
|
||||
}));
|
||||
|
||||
// Mock InsightSheet so we can assert on its open state.
|
||||
vi.mock("@/modules/ee/insights/components/insight-sheet", () => ({
|
||||
InsightSheet: ({
|
||||
isOpen,
|
||||
insight,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
insight: any;
|
||||
setIsOpen: any;
|
||||
handleFeedback: any;
|
||||
documentsFilter: any;
|
||||
documentsPerPage: number;
|
||||
locale: string;
|
||||
}) => (
|
||||
<div data-testid="insight-sheet">
|
||||
{isOpen ? `InsightSheet Open${insight ? ` - ${insight.title}` : ""}` : "InsightSheet Closed"}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock InsightLoading.
|
||||
vi.mock("./insight-loading", () => ({
|
||||
InsightLoading: () => <div data-testid="insight-loading">Loading...</div>,
|
||||
}));
|
||||
|
||||
// For simplicity, we won’t mock CategoryBadge so it renders normally.
|
||||
// If needed, you can also mock it similar to InsightSheet.
|
||||
|
||||
// --- Dummy Data ---
|
||||
const dummyInsight1 = {
|
||||
id: "1",
|
||||
title: "Insight 1",
|
||||
description: "Description 1",
|
||||
category: "featureRequest",
|
||||
_count: { documentInsights: 5 },
|
||||
};
|
||||
const dummyInsight2 = {
|
||||
id: "2",
|
||||
title: "Insight 2",
|
||||
description: "Description 2",
|
||||
category: "featureRequest",
|
||||
_count: { documentInsights: 3 },
|
||||
};
|
||||
const dummyInsightComplaint = {
|
||||
id: "3",
|
||||
title: "Complaint Insight",
|
||||
description: "Complaint Description",
|
||||
category: "complaint",
|
||||
_count: { documentInsights: 10 },
|
||||
};
|
||||
const dummyInsightPraise = {
|
||||
id: "4",
|
||||
title: "Praise Insight",
|
||||
description: "Praise Description",
|
||||
category: "praise",
|
||||
_count: { documentInsights: 8 },
|
||||
};
|
||||
|
||||
// A helper to render the component with required props.
|
||||
const renderComponent = (props = {}) => {
|
||||
const defaultProps = {
|
||||
statsFrom: new Date("2023-01-01"),
|
||||
environmentId: "env-1",
|
||||
insightsPerPage: 2,
|
||||
documentsPerPage: 5,
|
||||
locale: "en-US" as TUserLocale,
|
||||
};
|
||||
|
||||
return render(<InsightView {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
// --- Tests ---
|
||||
describe("InsightView Component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders "no insights found" message when insights array is empty', async () => {
|
||||
// Set up the mock to return an empty array.
|
||||
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [] });
|
||||
renderComponent();
|
||||
// Wait for the useEffect to complete.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders table rows when insights are fetched", async () => {
|
||||
// Return two insights for the initial fetch.
|
||||
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] });
|
||||
renderComponent();
|
||||
// Wait until the insights are rendered.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Insight 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Insight 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("opens insight sheet when a table row is clicked", async () => {
|
||||
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] });
|
||||
renderComponent();
|
||||
// Wait for the insight to appear.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Instead of grabbing the first "Insight 1" cell,
|
||||
// get all table rows (they usually have role="row") and then find the row that contains "Insight 1".
|
||||
const rows = screen.getAllByRole("row");
|
||||
const targetRow = rows.find((row) => row.textContent?.includes("Insight 1"));
|
||||
|
||||
console.log(targetRow?.textContent);
|
||||
|
||||
expect(targetRow).toBeTruthy();
|
||||
|
||||
// Click the entire row.
|
||||
fireEvent.click(targetRow!);
|
||||
|
||||
// Wait for the InsightSheet to update.
|
||||
await waitFor(() => {
|
||||
const sheet = screen.getAllByTestId("insight-sheet");
|
||||
|
||||
const matchingSheet = sheet.find((s) => s.textContent?.includes("InsightSheet Open - Insight 1"));
|
||||
expect(matchingSheet).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking load more fetches next page of insights", async () => {
|
||||
// First fetch returns two insights.
|
||||
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] });
|
||||
// Second fetch returns one additional insight.
|
||||
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsightPraise] });
|
||||
renderComponent();
|
||||
|
||||
// Wait for the initial insights to be rendered.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Insight 2").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The load more button should be visible because hasMore is true.
|
||||
const loadMoreButton = screen.getAllByText("common.load_more")[0];
|
||||
fireEvent.click(loadMoreButton);
|
||||
|
||||
// Wait for the new insight to be appended.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Praise Insight").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("changes filter tab and re-fetches insights", async () => {
|
||||
// For initial active tab "featureRequest", return a featureRequest insight.
|
||||
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] });
|
||||
renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Insight 1")[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({
|
||||
data: [dummyInsightComplaint as TInsightWithDocumentCount],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Find the complaint tab and click it.
|
||||
const complaintTab = screen.getAllByText("environments.experience.complaint")[0];
|
||||
fireEvent.click(complaintTab);
|
||||
|
||||
// Wait until the new complaint insight is rendered.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Complaint Insight")[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("shows loading indicator when fetching insights", async () => {
|
||||
// Make the mock return a promise that doesn't resolve immediately.
|
||||
let resolveFetch: any;
|
||||
const fetchPromise = new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
mockGetEnvironmentInsightsAction.mockReturnValueOnce(fetchPromise);
|
||||
renderComponent();
|
||||
|
||||
// While fetching, the loading indicator should be visible.
|
||||
expect(screen.getByTestId("insight-loading")).toBeInTheDocument();
|
||||
|
||||
// Resolve the fetch.
|
||||
resolveFetch({ data: [dummyInsight1] });
|
||||
await waitFor(() => {
|
||||
// After fetching, the loading indicator should disappear.
|
||||
expect(screen.queryByTestId("insight-loading")).not.toBeInTheDocument();
|
||||
// Instead of getByText, use getAllByText to assert at least one instance of "Insight 1" exists.
|
||||
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { InsightSheet } from "@/modules/ee/insights/components/insight-sheet";
|
||||
import {
|
||||
TInsightFilterCriteria,
|
||||
TInsightWithDocumentCount,
|
||||
} from "@/modules/ee/insights/experience/types/insights";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { InsightCategory } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { UserIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { TDocumentFilterCriteria } from "@formbricks/types/documents";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getEnvironmentInsightsAction } from "../actions";
|
||||
import CategoryBadge from "./category-select";
|
||||
import { InsightLoading } from "./insight-loading";
|
||||
|
||||
interface InsightViewProps {
|
||||
statsFrom?: Date;
|
||||
environmentId: string;
|
||||
documentsPerPage: number;
|
||||
insightsPerPage: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const InsightView = ({
|
||||
statsFrom,
|
||||
environmentId,
|
||||
insightsPerPage,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
}: InsightViewProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [insights, setInsights] = useState<TInsightWithDocumentCount[]>([]);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [isInsightSheetOpen, setIsInsightSheetOpen] = useState(false);
|
||||
const [currentInsight, setCurrentInsight] = useState<TInsightWithDocumentCount | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>("featureRequest");
|
||||
|
||||
const handleFeedback = (_feedback: "positive" | "negative") => {
|
||||
formbricks.track("AI Insight Feedback");
|
||||
};
|
||||
|
||||
const insightsFilter: TInsightFilterCriteria = useMemo(
|
||||
() => ({
|
||||
documentCreatedAt: {
|
||||
min: statsFrom,
|
||||
},
|
||||
category: activeTab === "all" ? undefined : (activeTab as InsightCategory),
|
||||
}),
|
||||
[statsFrom, activeTab]
|
||||
);
|
||||
|
||||
const documentsFilter: TDocumentFilterCriteria = useMemo(
|
||||
() => ({
|
||||
createdAt: {
|
||||
min: statsFrom,
|
||||
},
|
||||
}),
|
||||
[statsFrom]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialInsights = async () => {
|
||||
setIsFetching(true);
|
||||
setInsights([]);
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialInsights();
|
||||
}, [environmentId, insightsPerPage, insightsFilter]);
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
if (!hasMore) return;
|
||||
setIsFetching(true);
|
||||
const res = await getEnvironmentInsightsAction({
|
||||
environmentId,
|
||||
limit: insightsPerPage,
|
||||
offset: insights.length,
|
||||
insightsFilter,
|
||||
});
|
||||
if (res?.data) {
|
||||
setInsights((prevInsights) => [...prevInsights, ...(res.data || [])]);
|
||||
setHasMore(res.data.length >= insightsPerPage);
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [environmentId, insights, insightsPerPage, insightsFilter, hasMore]);
|
||||
|
||||
const handleFilterSelect = (value: string) => {
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">#</TableHead>
|
||||
<TableHead>{t("common.title")}</TableHead>
|
||||
<TableHead>{t("common.description")}</TableHead>
|
||||
<TableHead>{t("environments.experience.category")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.length === 0 && !isFetching ? (
|
||||
<TableRow className="pointer-events-none">
|
||||
<TableCell colSpan={4} className="py-8 text-center">
|
||||
<p className="text-slate-500">{t("environments.experience.no_insights_found")}</p>
|
||||
</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>
|
||||
{isFetching && <InsightLoading />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{hasMore && !isFetching && (
|
||||
<div className="flex justify-center py-5">
|
||||
<Button onClick={fetchNextPage} variant="secondary" size="sm" loading={isFetching}>
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InsightSheet
|
||||
isOpen={isInsightSheetOpen}
|
||||
setIsOpen={setIsInsightSheetOpen}
|
||||
insight={currentInsight}
|
||||
handleFeedback={handleFeedback}
|
||||
documentsFilter={documentsFilter}
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/modules/ui/components/card";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { InsightView } from "./insight-view";
|
||||
|
||||
interface InsightsCardProps {
|
||||
environmentId: string;
|
||||
insightsPerPage: number;
|
||||
projectName: string;
|
||||
statsFrom?: Date;
|
||||
documentsPerPage: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const InsightsCard = ({
|
||||
statsFrom,
|
||||
environmentId,
|
||||
projectName,
|
||||
insightsPerPage: insightsLimit,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
}: InsightsCardProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("environments.experience.insights_for_project", { projectName })}</CardTitle>
|
||||
<CardDescription>{t("environments.experience.insights_description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InsightView
|
||||
statsFrom={statsFrom}
|
||||
environmentId={environmentId}
|
||||
documentsPerPage={documentsPerPage}
|
||||
insightsPerPage={insightsLimit}
|
||||
locale={locale}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { getStatsAction } from "@/modules/ee/insights/experience/actions";
|
||||
import { TStats } from "@/modules/ee/insights/experience/types/stats";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/modules/ui/components/card";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ActivityIcon, GaugeIcon, InboxIcon, MessageCircleIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface ExperiencePageStatsProps {
|
||||
statsFrom?: Date;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const ExperiencePageStats = ({ statsFrom, environmentId }: ExperiencePageStatsProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [stats, setStats] = useState<TStats>({
|
||||
activeSurveys: 0,
|
||||
newResponses: 0,
|
||||
analysedFeedbacks: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const getData = async () => {
|
||||
setIsLoading(true);
|
||||
const getStatsResponse = await getStatsAction({ environmentId, statsFrom });
|
||||
|
||||
if (getStatsResponse?.data) {
|
||||
setStats(getStatsResponse.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(getStatsResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
getData();
|
||||
}, [environmentId, statsFrom]);
|
||||
|
||||
const statsData = [
|
||||
{
|
||||
key: "sentimentScore",
|
||||
title: t("environments.experience.sentiment_score"),
|
||||
value: stats.sentimentScore ? `${Math.floor(stats.sentimentScore * 100)}%` : "-",
|
||||
icon: GaugeIcon,
|
||||
width: "w-20",
|
||||
},
|
||||
{
|
||||
key: "activeSurveys",
|
||||
title: t("common.active_surveys"),
|
||||
value: stats.activeSurveys,
|
||||
icon: MessageCircleIcon,
|
||||
width: "w-10",
|
||||
},
|
||||
{
|
||||
key: "newResponses",
|
||||
title: t("environments.experience.new_responses"),
|
||||
value: stats.newResponses,
|
||||
icon: InboxIcon,
|
||||
width: "w-10",
|
||||
},
|
||||
{
|
||||
key: "analysedFeedbacks",
|
||||
title: t("environments.experience.analysed_feedbacks"),
|
||||
value: stats.analysedFeedbacks,
|
||||
icon: ActivityIcon,
|
||||
width: "w-10",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
|
||||
{statsData.map((stat, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div className="text-2xl font-bold capitalize">
|
||||
{isLoading ? (
|
||||
<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 type="success" size="large" text={t("common.positive")} />
|
||||
) : stats.overallSentiment === "negative" ? (
|
||||
<Badge type="error" size="large" text={t("common.negative")} />
|
||||
) : (
|
||||
<Badge type="gray" size="large" text={t("common.neutral")} />
|
||||
)}
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
) : (
|
||||
(stat.value ?? "-")
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TemplateList } from "@/modules/survey/components/template-list";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/modules/ui/components/card";
|
||||
import { Project } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TTemplateFilter } from "@formbricks/types/templates";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface TemplatesCardProps {
|
||||
environment: TEnvironment;
|
||||
project: Project;
|
||||
user: TUser;
|
||||
prefilledFilters: TTemplateFilter[];
|
||||
}
|
||||
|
||||
export const TemplatesCard = ({ environment, project, user, prefilledFilters }: TemplatesCardProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("environments.experience.templates_card_title")}</CardTitle>
|
||||
<CardDescription>{t("environments.experience.templates_card_description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TemplateList
|
||||
environmentId={environment.id}
|
||||
project={project}
|
||||
showFilters={false}
|
||||
userId={user.id}
|
||||
prefilledFilters={prefilledFilters}
|
||||
noPreview={true}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { insightCache } from "@/lib/cache/insight";
|
||||
import { INSIGHTS_PER_PAGE } from "@/lib/constants";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
TInsightFilterCriteria,
|
||||
TInsightWithDocumentCount,
|
||||
ZInsightFilterCriteria,
|
||||
} from "@/modules/ee/insights/experience/types/insights";
|
||||
import { Insight, Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const getInsights = reactCache(
|
||||
async (
|
||||
environmentId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
filterCriteria?: TInsightFilterCriteria
|
||||
): Promise<TInsightWithDocumentCount[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs(
|
||||
[environmentId, ZId],
|
||||
[limit, ZOptionalNumber],
|
||||
[offset, ZOptionalNumber],
|
||||
[filterCriteria, ZInsightFilterCriteria.optional()]
|
||||
);
|
||||
|
||||
limit = limit ?? INSIGHTS_PER_PAGE;
|
||||
try {
|
||||
const insights = await prisma.insight.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
documentInsights: {
|
||||
some: {
|
||||
document: {
|
||||
createdAt: {
|
||||
gte: filterCriteria?.documentCreatedAt?.min,
|
||||
lte: filterCriteria?.documentCreatedAt?.max,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
category: filterCriteria?.category,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documentInsights: {
|
||||
where: {
|
||||
document: {
|
||||
createdAt: {
|
||||
gte: filterCriteria?.documentCreatedAt?.min,
|
||||
lte: filterCriteria?.documentCreatedAt?.max,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
return insights;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`experience-getInsights-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
|
||||
{
|
||||
tags: [insightCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const updateInsight = async (insightId: string, updates: Partial<Insight>): Promise<void> => {
|
||||
try {
|
||||
const updatedInsight = await prisma.insight.update({
|
||||
where: { id: insightId },
|
||||
data: updates,
|
||||
select: {
|
||||
environmentId: true,
|
||||
documentInsights: {
|
||||
select: {
|
||||
document: {
|
||||
select: {
|
||||
surveyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const uniqueSurveyIds = Array.from(
|
||||
new Set(updatedInsight.documentInsights.map((di) => di.document.surveyId))
|
||||
);
|
||||
|
||||
insightCache.revalidate({ id: insightId, environmentId: updatedInsight.environmentId });
|
||||
|
||||
for (const surveyId of uniqueSurveyIds) {
|
||||
if (surveyId) {
|
||||
responseCache.revalidate({
|
||||
surveyId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error in updateInsight");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,106 +0,0 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { documentCache } from "@/lib/cache/document";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TStats } from "../types/stats";
|
||||
|
||||
export const getStats = reactCache(
|
||||
async (environmentId: string, statsFrom?: Date): Promise<TStats> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
try {
|
||||
const groupedResponesPromise = prisma.response.groupBy({
|
||||
by: ["surveyId"],
|
||||
_count: {
|
||||
surveyId: true,
|
||||
},
|
||||
where: {
|
||||
survey: {
|
||||
environmentId,
|
||||
},
|
||||
createdAt: {
|
||||
gte: statsFrom,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const groupedSentimentsPromise = prisma.document.groupBy({
|
||||
by: ["sentiment"],
|
||||
_count: {
|
||||
sentiment: true,
|
||||
},
|
||||
where: {
|
||||
environmentId,
|
||||
createdAt: {
|
||||
gte: statsFrom,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [groupedRespones, groupedSentiments] = await Promise.all([
|
||||
groupedResponesPromise,
|
||||
groupedSentimentsPromise,
|
||||
]);
|
||||
|
||||
const activeSurveys = groupedRespones.length;
|
||||
|
||||
const newResponses = groupedRespones.reduce((acc, { _count }) => acc + _count.surveyId, 0);
|
||||
|
||||
const sentimentCounts = groupedSentiments.reduce(
|
||||
(acc, { sentiment, _count }) => {
|
||||
acc[sentiment] = _count.sentiment;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
positive: 0,
|
||||
negative: 0,
|
||||
neutral: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// analysed feedbacks is the sum of all the sentiments
|
||||
const analysedFeedbacks = Object.values(sentimentCounts).reduce((acc, count) => acc + count, 0);
|
||||
|
||||
// the sentiment score is the ratio of positive to total (positive + negative) sentiment counts. For this we ignore neutral sentiment counts.
|
||||
let sentimentScore: number = 0,
|
||||
overallSentiment: TStats["overallSentiment"];
|
||||
|
||||
if (sentimentCounts.positive || sentimentCounts.negative) {
|
||||
sentimentScore = sentimentCounts.positive / (sentimentCounts.positive + sentimentCounts.negative);
|
||||
|
||||
overallSentiment =
|
||||
sentimentScore > 0.5 ? "positive" : sentimentScore < 0.5 ? "negative" : "neutral";
|
||||
}
|
||||
|
||||
return {
|
||||
newResponses,
|
||||
activeSurveys,
|
||||
analysedFeedbacks,
|
||||
sentimentScore,
|
||||
overallSentiment,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error fetching stats");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`stats-${environmentId}-${statsFrom?.toDateString()}`],
|
||||
{
|
||||
tags: [
|
||||
responseCache.tag.byEnvironmentId(environmentId),
|
||||
documentCache.tag.byEnvironmentId(environmentId),
|
||||
],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,18 +0,0 @@
|
||||
import { TStatsPeriod } from "@/modules/ee/insights/experience/types/stats";
|
||||
|
||||
export const getDateFromTimeRange = (timeRange: TStatsPeriod): Date | undefined => {
|
||||
if (timeRange === "all") {
|
||||
return new Date(0);
|
||||
}
|
||||
const now = new Date();
|
||||
switch (timeRange) {
|
||||
case "day":
|
||||
return new Date(now.getTime() - 1000 * 60 * 60 * 24);
|
||||
case "week":
|
||||
return new Date(now.getTime() - 1000 * 60 * 60 * 24 * 7);
|
||||
case "month":
|
||||
return new Date(now.getTime() - 1000 * 60 * 60 * 24 * 30);
|
||||
case "quarter":
|
||||
return new Date(now.getTime() - 1000 * 60 * 60 * 24 * 90);
|
||||
}
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import { DOCUMENTS_PER_PAGE, INSIGHTS_PER_PAGE } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { Dashboard } from "@/modules/ee/insights/experience/components/dashboard";
|
||||
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const ExperiencePage = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const [environment, project, organization] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
if (isBilling) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled({
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
billing: organization.billing,
|
||||
});
|
||||
|
||||
if (!isAIEnabled) {
|
||||
notFound();
|
||||
}
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<Dashboard
|
||||
environment={environment}
|
||||
insightsPerPage={INSIGHTS_PER_PAGE}
|
||||
project={project}
|
||||
user={user}
|
||||
documentsPerPage={DOCUMENTS_PER_PAGE}
|
||||
locale={locale}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Insight } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { ZInsight } from "@formbricks/database/zod/insights";
|
||||
|
||||
export const ZInsightFilterCriteria = z.object({
|
||||
documentCreatedAt: z
|
||||
.object({
|
||||
min: z.date().optional(),
|
||||
max: z.date().optional(),
|
||||
})
|
||||
.optional(),
|
||||
category: ZInsight.shape.category.optional(),
|
||||
});
|
||||
|
||||
export type TInsightFilterCriteria = z.infer<typeof ZInsightFilterCriteria>;
|
||||
|
||||
export interface TInsightWithDocumentCount extends Insight {
|
||||
_count: {
|
||||
documentInsights: number;
|
||||
};
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZStats = z.object({
|
||||
sentimentScore: z.number().optional(),
|
||||
overallSentiment: z.enum(["positive", "negative", "neutral"]).optional(),
|
||||
activeSurveys: z.number(),
|
||||
newResponses: z.number(),
|
||||
analysedFeedbacks: z.number(),
|
||||
});
|
||||
|
||||
export type TStats = z.infer<typeof ZStats>;
|
||||
|
||||
export const ZStatsPeriod = z.enum(["all", "day", "week", "month", "quarter"]);
|
||||
export type TStatsPeriod = z.infer<typeof ZStatsPeriod>;
|
||||
@@ -3,7 +3,6 @@ import { cache, revalidateTag } from "@/lib/cache";
|
||||
import {
|
||||
E2E_TESTING,
|
||||
ENTERPRISE_LICENSE_KEY,
|
||||
IS_AI_CONFIGURED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
PROJECT_FEATURE_KEYS,
|
||||
} from "@/lib/constants";
|
||||
@@ -389,25 +388,6 @@ export const getIsSamlSsoEnabled = async (): Promise<boolean> => {
|
||||
return licenseFeatures.sso && licenseFeatures.saml;
|
||||
};
|
||||
|
||||
export const getIsOrganizationAIReady = async (billingPlan: Organization["billing"]["plan"]) => {
|
||||
if (!IS_AI_CONFIGURED) return false;
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features ? previousResult.features.ai : false;
|
||||
}
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return Boolean(license.features?.ai && billingPlan !== PROJECT_FEATURE_KEYS.FREE);
|
||||
}
|
||||
|
||||
return Boolean(license.features?.ai);
|
||||
};
|
||||
|
||||
export const getIsAIEnabled = async (organization: Pick<Organization, "isAIEnabled" | "billing">) => {
|
||||
return organization.isAIEnabled && (await getIsOrganizationAIReady(organization.billing.plan));
|
||||
};
|
||||
|
||||
export const getOrganizationProjectsLimit = async (
|
||||
limits: Organization["billing"]["limits"]
|
||||
): Promise<number> => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { selectSurvey } from "@/modules/survey/lib/survey";
|
||||
import { doesSurveyHasOpenTextQuestion, getInsightsEnabled } from "@/modules/survey/lib/utils";
|
||||
import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -52,33 +50,6 @@ export const createSurvey = async (
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
}
|
||||
|
||||
//AI Insights
|
||||
const isAIEnabled = await getIsAIEnabled(organization);
|
||||
if (isAIEnabled) {
|
||||
if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) {
|
||||
const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? [];
|
||||
const insightsEnabledValues = await Promise.all(
|
||||
openTextQuestions.map(async (question) => {
|
||||
const insightsEnabled = await getInsightsEnabled(question);
|
||||
|
||||
return { id: question.id, insightsEnabled };
|
||||
})
|
||||
);
|
||||
|
||||
data.questions = data.questions?.map((question) => {
|
||||
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
|
||||
if (index !== -1) {
|
||||
return {
|
||||
...question,
|
||||
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Survey follow-ups
|
||||
if (restSurveyBody.followUps?.length) {
|
||||
data.followUps = {
|
||||
@@ -106,14 +77,6 @@ export const createSurvey = async (
|
||||
|
||||
// if the survey created is an "app" survey, we also create a private segment for it.
|
||||
if (survey.type === "app") {
|
||||
// const newSegment = await createSegment({
|
||||
// environmentId: parsedEnvironmentId,
|
||||
// surveyId: survey.id,
|
||||
// filters: [],
|
||||
// title: survey.id,
|
||||
// isPrivate: true,
|
||||
// });
|
||||
|
||||
const newSegment = await prisma.segment.create({
|
||||
data: {
|
||||
title: survey.id,
|
||||
|
||||
@@ -44,12 +44,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getSurvey, selectSurvey } from "@/modules/survey/lib/survey";
|
||||
import { doesSurveyHasOpenTextQuestion, getInsightsEnabled } from "@/modules/survey/lib/utils";
|
||||
import { ActionClass, Prisma, Survey } from "@prisma/client";
|
||||
import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyOpenTextQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
|
||||
try {
|
||||
@@ -253,71 +251,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
}
|
||||
|
||||
//AI Insights
|
||||
const isAIEnabled = await getIsAIEnabled(organization);
|
||||
if (isAIEnabled) {
|
||||
if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) {
|
||||
const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? [];
|
||||
const currentSurveyOpenTextQuestions = currentSurvey.questions?.filter(
|
||||
(question) => question.type === "openText"
|
||||
);
|
||||
|
||||
// find the questions that have been updated or added
|
||||
const questionsToCheckForInsights: Survey["questions"] = [];
|
||||
|
||||
for (const question of openTextQuestions) {
|
||||
const existingQuestion = currentSurveyOpenTextQuestions?.find((ques) => ques.id === question.id) as
|
||||
| TSurveyOpenTextQuestion
|
||||
| undefined;
|
||||
const isExistingQuestion = !!existingQuestion;
|
||||
|
||||
if (
|
||||
isExistingQuestion &&
|
||||
question.headline.default === existingQuestion.headline.default &&
|
||||
existingQuestion.insightsEnabled !== undefined
|
||||
) {
|
||||
continue;
|
||||
} else {
|
||||
questionsToCheckForInsights.push(question);
|
||||
}
|
||||
}
|
||||
|
||||
if (questionsToCheckForInsights.length > 0) {
|
||||
const insightsEnabledValues = await Promise.all(
|
||||
questionsToCheckForInsights.map(async (question) => {
|
||||
const insightsEnabled = await getInsightsEnabled(question);
|
||||
|
||||
return { id: question.id, insightsEnabled };
|
||||
})
|
||||
);
|
||||
|
||||
data.questions = data.questions?.map((question) => {
|
||||
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
|
||||
if (index !== -1) {
|
||||
return {
|
||||
...question,
|
||||
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// check if an existing question got changed that had insights enabled
|
||||
const insightsEnabledOpenTextQuestions = currentSurvey.questions?.filter(
|
||||
(question) => question.type === "openText" && question.insightsEnabled !== undefined
|
||||
);
|
||||
// if question headline changed, remove insightsEnabled
|
||||
for (const question of insightsEnabledOpenTextQuestions) {
|
||||
const updatedQuestion = data.questions?.find((q) => q.id === question.id);
|
||||
if (updatedQuestion && updatedQuestion.headline.default !== question.headline.default) {
|
||||
updatedQuestion.insightsEnabled = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
surveyData.updatedAt = new Date();
|
||||
|
||||
data = {
|
||||
|
||||
@@ -1,33 +1,8 @@
|
||||
import "server-only";
|
||||
import { llmModel } from "@/lib/aiModels";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyFilterCriteria,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestions,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getInsightsEnabled = async (question: TSurveyQuestion): Promise<boolean> => {
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: llmModel,
|
||||
schema: z.object({
|
||||
insightsEnabled: z.boolean(),
|
||||
}),
|
||||
prompt: `We extract insights (e.g. feature requests, complaints, other) from survey questions. Can we find them in this question?: ${question.headline.default}`,
|
||||
experimental_telemetry: { isEnabled: true },
|
||||
});
|
||||
|
||||
return object.insightsEnabled;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
import { TSurvey, TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
|
||||
surveyPrisma: any
|
||||
@@ -114,7 +89,3 @@ export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => {
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const doesSurveyHasOpenTextQuestion = (questions: TSurveyQuestions): boolean => {
|
||||
return questions.some((question) => question.type === "openText");
|
||||
};
|
||||
|
||||
@@ -21,12 +21,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
|
||||
@@ -27,12 +27,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { llmModel } from "@/lib/aiModels";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAIKeys } from "@/modules/survey/lib/organization";
|
||||
import { createSurvey } from "@/modules/survey/templates/lib/survey";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { ZSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
const ZCreateAISurveyAction = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
prompt: z.string(),
|
||||
});
|
||||
|
||||
export const createAISurveyAction = authenticatedActionClient
|
||||
.schema(ZCreateAISurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganizationAIKeys(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled({
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
billing: organization.billing,
|
||||
});
|
||||
|
||||
if (!isAIEnabled) {
|
||||
throw new Error("AI is not enabled for this organization");
|
||||
}
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: llmModel,
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
questions: z.array(
|
||||
z.object({
|
||||
headline: z.string(),
|
||||
subheader: z.string(),
|
||||
type: z.enum(["openText", "multipleChoiceSingle", "multipleChoiceMulti"]),
|
||||
choices: z
|
||||
.array(z.string())
|
||||
.min(2, { message: "Multiple Choice Question must have at least two choices" })
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
system: `You are a survey AI. Create a survey with 3 questions max that fits the schema and user input.`,
|
||||
prompt: parsedInput.prompt,
|
||||
experimental_telemetry: { isEnabled: true },
|
||||
});
|
||||
|
||||
const parsedQuestions = object.questions.map((question) => {
|
||||
return ZSurveyQuestion.parse({
|
||||
id: createId(),
|
||||
headline: { default: question.headline },
|
||||
subheader: { default: question.subheader },
|
||||
type: question.type,
|
||||
choices: question.choices
|
||||
? question.choices.map((choice) => ({ id: createId(), label: { default: choice } }))
|
||||
: undefined,
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
return await createSurvey(parsedInput.environmentId, { name: object.name, questions: parsedQuestions });
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createAISurveyAction } from "@/modules/survey/templates/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/modules/ui/components/card";
|
||||
import { Textarea } from "@/modules/ui/components/textarea";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface FormbricksAICardProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const FormbricksAICard = ({ environmentId }: FormbricksAICardProps) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
// Here you would typically send the data to your backend
|
||||
const createSurveyResponse = await createAISurveyAction({
|
||||
environmentId,
|
||||
prompt: aiPrompt,
|
||||
});
|
||||
|
||||
if (createSurveyResponse?.data) {
|
||||
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
// Reset form field after submission
|
||||
setAiPrompt("");
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mx-auto w-full bg-gradient-to-tr from-slate-100 to-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Formbricks AI</CardTitle>
|
||||
<CardDescription>{t("environments.surveys.edit.formbricks_ai_description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Textarea
|
||||
className="bg-slate-50"
|
||||
id="ai-prompt"
|
||||
placeholder={t("environments.surveys.edit.formbricks_ai_prompt_placeholder")}
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
required
|
||||
aria-label="AI Prompt"
|
||||
/>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full shadow-sm"
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
variant="secondary"
|
||||
loading={isLoading}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t("environments.surveys.edit.formbricks_ai_generate")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import { customSurveyTemplate } from "@/app/lib/templates";
|
||||
import { TemplateList } from "@/modules/survey/components/template-list";
|
||||
import { FormbricksAICard } from "@/modules/survey/templates/components/formbricks-ai-card";
|
||||
import { MenuBar } from "@/modules/survey/templates/components/menu-bar";
|
||||
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
|
||||
import { SearchBar } from "@/modules/ui/components/search-bar";
|
||||
import { Separator } from "@/modules/ui/components/separator";
|
||||
import { Project } from "@prisma/client";
|
||||
import { Environment } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -20,7 +18,6 @@ type TemplateContainerWithPreviewProps = {
|
||||
environment: Pick<Environment, "id" | "appSetupCompleted">;
|
||||
userId: string;
|
||||
prefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[];
|
||||
isAIEnabled: boolean;
|
||||
};
|
||||
|
||||
export const TemplateContainerWithPreview = ({
|
||||
@@ -28,7 +25,6 @@ export const TemplateContainerWithPreview = ({
|
||||
environment,
|
||||
userId,
|
||||
prefilledFilters,
|
||||
isAIEnabled,
|
||||
}: TemplateContainerWithPreviewProps) => {
|
||||
const { t } = useTranslate();
|
||||
const initialTemplate = customSurveyTemplate(t);
|
||||
@@ -41,7 +37,7 @@ export const TemplateContainerWithPreview = ({
|
||||
<MenuBar />
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 flex-col overflow-auto bg-slate-50">
|
||||
<div className="mb-3 ml-6 mt-6 flex flex-col items-center justify-between md:flex-row md:items-end">
|
||||
<div className="mt-6 mb-3 ml-6 flex flex-col items-center justify-between md:flex-row md:items-end">
|
||||
<h1 className="text-2xl font-bold text-slate-800">
|
||||
{t("environments.surveys.templates.create_a_new_survey")}
|
||||
</h1>
|
||||
@@ -54,16 +50,6 @@ export const TemplateContainerWithPreview = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAIEnabled && (
|
||||
<>
|
||||
<div className="px-6">
|
||||
<FormbricksAICard environmentId={environment.id} />
|
||||
</div>
|
||||
<Separator className="mt-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TemplateList
|
||||
environmentId={environment.id}
|
||||
project={project}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { getInsightsEnabled } from "@/modules/survey/lib/utils";
|
||||
import { doesSurveyHasOpenTextQuestion } from "@/modules/survey/lib/utils";
|
||||
import { Prisma, Survey } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -13,30 +11,6 @@ export const createSurvey = async (
|
||||
surveyBody: Pick<Survey, "name" | "questions">
|
||||
): Promise<{ id: string }> => {
|
||||
try {
|
||||
if (doesSurveyHasOpenTextQuestion(surveyBody.questions ?? [])) {
|
||||
const openTextQuestions =
|
||||
surveyBody.questions?.filter((question) => question.type === "openText") ?? [];
|
||||
const insightsEnabledValues = await Promise.all(
|
||||
openTextQuestions.map(async (question) => {
|
||||
const insightsEnabled = await getInsightsEnabled(question);
|
||||
|
||||
return { id: question.id, insightsEnabled };
|
||||
})
|
||||
);
|
||||
|
||||
surveyBody.questions = surveyBody.questions?.map((question) => {
|
||||
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
|
||||
if (index !== -1) {
|
||||
return {
|
||||
...question,
|
||||
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
});
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...surveyBody,
|
||||
|
||||
@@ -43,8 +43,6 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
|
||||
environment={environment}
|
||||
project={project}
|
||||
prefilledFilters={prefilledFilters}
|
||||
// AI Survey Creation -- Need improvement
|
||||
isAIEnabled={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user