mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 22:50:35 -06:00
Compare commits
37 Commits
tolgee-bui
...
ai-fix-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1d4ddb203 | ||
|
|
7337e94538 | ||
|
|
f55df95f8c | ||
|
|
dc894bae2a | ||
|
|
126a4c8989 | ||
|
|
add567ffdc | ||
|
|
5e17b7919f | ||
|
|
4f5ac2b27f | ||
|
|
897c2de656 | ||
|
|
5c802c2fe8 | ||
|
|
57927f6a3e | ||
|
|
4ffbbad9fe | ||
|
|
7221a12964 | ||
|
|
27fc47144e | ||
|
|
6a691e2b68 | ||
|
|
7eca969496 | ||
|
|
96292130a8 | ||
|
|
8891000c64 | ||
|
|
b23088bd2f | ||
|
|
470151d79b | ||
|
|
df054537ee | ||
|
|
12f721982f | ||
|
|
9c6aaf5365 | ||
|
|
519f7838c6 | ||
|
|
32d870b063 | ||
|
|
81738b77f5 | ||
|
|
5e9df605e4 | ||
|
|
590f9305b8 | ||
|
|
a69df3baf0 | ||
|
|
da124bbbbe | ||
|
|
539e0e2fd3 | ||
|
|
9ffd6d4121 | ||
|
|
cc9ea82e5c | ||
|
|
14e3bb07ec | ||
|
|
ab1fe677d9 | ||
|
|
703260b906 | ||
|
|
7a24badff1 |
@@ -180,3 +180,9 @@ UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
# Azure AI settings
|
||||
# AI_AZURE_RESSOURCE_NAME=
|
||||
# AI_AZURE_API_KEY=
|
||||
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
|
||||
# AI_AZURE_LLM_DEPLOYMENT_ID=
|
||||
@@ -18,8 +18,8 @@ import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/ser
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getWebhookCountBySource } from "@formbricks/lib/webhook/service";
|
||||
import { TIntegrationType } from "@formbricks/types/integration";
|
||||
import { Card } from "@formbricks/ui/components/Card";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
import { Card } from "@formbricks/ui/components/IntegrationCard";
|
||||
import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { UserIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import formbricks from "@formbricks/js/app";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TInsight } from "@formbricks/types/insights";
|
||||
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { PersonAvatar } from "@formbricks/ui/components/Avatars";
|
||||
import { Badge } from "@formbricks/ui/components/Badge";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { InsightFilter } from "@formbricks/ui/components/InsightFilter";
|
||||
import { InsightSheet } from "@formbricks/ui/components/InsightSheet";
|
||||
import { SecondaryNavigation } from "@formbricks/ui/components/SecondaryNavigation";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@formbricks/ui/components/Table";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface OpenTextSummaryProps {
|
||||
@@ -13,6 +28,9 @@ interface OpenTextSummaryProps {
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAiEnabled: boolean;
|
||||
productId: string;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
export const OpenTextSummary = ({
|
||||
@@ -20,8 +38,17 @@ export const OpenTextSummary = ({
|
||||
environmentId,
|
||||
survey,
|
||||
attributeClasses,
|
||||
isAiEnabled,
|
||||
productId,
|
||||
productName,
|
||||
}: OpenTextSummaryProps) => {
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
const [activeTab, setActiveTab] = useState<"insights" | "responses">(
|
||||
isAiEnabled ? "insights" : "responses"
|
||||
);
|
||||
const [isInsightSheetOpen, setIsInsightSheetOpen] = useState(true);
|
||||
const [insights, setInsights] = useState<TInsight[]>(questionSummary.insights);
|
||||
const [currentInsight, setCurrentInsight] = useState<TInsight | null>(null);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
@@ -30,6 +57,35 @@ export const OpenTextSummary = ({
|
||||
);
|
||||
};
|
||||
|
||||
const tabNavigation = [
|
||||
{
|
||||
id: "insights",
|
||||
label: "Insights",
|
||||
onClick: () => setActiveTab("insights"),
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
label: "Responses",
|
||||
onClick: () => setActiveTab("responses"),
|
||||
},
|
||||
];
|
||||
|
||||
const handleFeedback = (feedback: "positive" | "negative") => {
|
||||
formbricks.track("Insight Feedback", {
|
||||
hiddenFields: {
|
||||
feedbackSentiment: feedback,
|
||||
productId,
|
||||
productName,
|
||||
surveyId: survey.id,
|
||||
surveyName: survey.name,
|
||||
insightId: currentInsight?.id,
|
||||
insightCategory: currentInsight?.category,
|
||||
questionId: questionSummary.question.id,
|
||||
environmentId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
@@ -37,54 +93,113 @@ export const OpenTextSummary = ({
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div className="">
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">User</div>
|
||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||
<div className="px-4 md:px-6">Time</div>
|
||||
<InsightSheet
|
||||
isOpen={isInsightSheetOpen}
|
||||
setIsOpen={setIsInsightSheetOpen}
|
||||
insight={currentInsight}
|
||||
surveyId={survey.id}
|
||||
questionId={questionSummary.question.id}
|
||||
handleFeedback={handleFeedback}
|
||||
/>
|
||||
{isAiEnabled && (
|
||||
<div className="flex items-center justify-between pr-4">
|
||||
<SecondaryNavigation activeId={activeTab} navigation={tabNavigation} />
|
||||
<InsightFilter insights={questionSummary.insights} setInsights={setInsights} />
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.person ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.person.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getPersonIdentifier(response.person, response.personAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<div className="max-h-[40vh] overflow-y-auto">
|
||||
{activeTab === "insights" ? (
|
||||
<Table className="border-t border-slate-200">
|
||||
<TableBody>
|
||||
{questionSummary.insights.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-8 text-center">
|
||||
<p className="text-slate-500">No insights found for this question.</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : insights.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-8 text-center">
|
||||
<p className="text-slate-500">No insights found for this filter.</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
insights.map((insight) => (
|
||||
<TableRow
|
||||
key={insight.id}
|
||||
className="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>{insight.description}</TableCell>
|
||||
<TableCell>
|
||||
{insight.category === "complaint" ? (
|
||||
<Badge text="Complaint" type="error" size="tiny" />
|
||||
) : insight.category === "featureRequest" ? (
|
||||
<Badge text="Request" type="warning" size="tiny" />
|
||||
) : insight.category === "praise" ? (
|
||||
<Badge text="Praise" type="success" size="tiny" />
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : activeTab === "responses" ? (
|
||||
<>
|
||||
<Table className="border-t border-slate-200">
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Response</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell>
|
||||
{response.person ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.person.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getPersonIdentifier(response.person, response.personAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{response.value}</TableCell>
|
||||
<TableCell>{timeSince(new Date(response.updatedAt).toISOString())}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold" dir="auto">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,8 @@ interface SummaryListProps {
|
||||
survey: TSurvey;
|
||||
totalResponseCount: number;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAiEnabled: boolean;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
export const SummaryList = ({
|
||||
@@ -48,6 +50,8 @@ export const SummaryList = ({
|
||||
survey,
|
||||
totalResponseCount,
|
||||
attributeClasses,
|
||||
isAiEnabled,
|
||||
productName,
|
||||
}: SummaryListProps) => {
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const widgetSetupCompleted =
|
||||
@@ -131,6 +135,9 @@ export const SummaryList = ({
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
isAiEnabled={isAiEnabled}
|
||||
productId={environment.productId}
|
||||
productName={productName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ interface SummaryPageProps {
|
||||
user?: TUser;
|
||||
totalResponseCount: number;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAiEnabled: boolean;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
export const SummaryPage = ({
|
||||
@@ -56,6 +58,8 @@ export const SummaryPage = ({
|
||||
webAppUrl,
|
||||
totalResponseCount,
|
||||
attributeClasses,
|
||||
isAiEnabled,
|
||||
productName,
|
||||
}: SummaryPageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
@@ -174,6 +178,8 @@ export const SummaryPage = ({
|
||||
environment={environment}
|
||||
totalResponseCount={totalResponseCount}
|
||||
attributeClasses={attributeClasses}
|
||||
isAiEnabled={isAiEnabled}
|
||||
productName={productName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { IS_AI_ENABLED, IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -60,6 +60,16 @@ const Page = async ({ params }) => {
|
||||
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
// I took this out cause it's cloud only right?
|
||||
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||
|
||||
const isAiEnabled =
|
||||
// isEnterpriseEdition &&
|
||||
IS_FORMBRICKS_CLOUD &&
|
||||
(organization.billing.plan === "startup" ||
|
||||
organization.billing.plan === "scale" ||
|
||||
organization.billing.plan === "enterprise") &&
|
||||
IS_AI_ENABLED;
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -89,6 +99,8 @@ const Page = async ({ params }) => {
|
||||
user={user}
|
||||
totalResponseCount={totalResponseCount}
|
||||
attributeClasses={attributeClasses}
|
||||
isAiEnabled={isAiEnabled}
|
||||
productName={product.name}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,10 @@ import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendResponseFinishedEmail } from "@formbricks/email";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { CRON_SECRET } from "@formbricks/lib/constants";
|
||||
import { CRON_SECRET, IS_AI_ENABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { createDocument } from "@formbricks/lib/document/service";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { convertDatesInObject } from "@formbricks/lib/time";
|
||||
@@ -139,6 +141,43 @@ export const POST = async (request: Request) => {
|
||||
console.error("Promise rejected:", result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
// generate embeddings for all open text question responses for all paid plans
|
||||
// TODO: check longer surveys if documents get created multiple times
|
||||
const hasSurveyOpenTextQuestions = survey.questions.some((question) => question.type === "openText");
|
||||
if (hasSurveyOpenTextQuestions && IS_FORMBRICKS_CLOUD) {
|
||||
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||
const isAiEnabled = IS_AI_ENABLED;
|
||||
if (hasSurveyOpenTextQuestions && isAiEnabled) {
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
if (
|
||||
organization.billing.plan === "enterprise" ||
|
||||
organization.billing.plan === "scale" ||
|
||||
organization.billing.plan === "startup"
|
||||
) {
|
||||
for (const question of survey.questions) {
|
||||
if (question.type === "openText") {
|
||||
const isQuestionAnswered = response.data[question.id] !== undefined;
|
||||
if (!isQuestionAnswered) {
|
||||
continue;
|
||||
}
|
||||
const text = `**${question.headline.default}**\n${response.data[question.id]}`;
|
||||
// TODO: check if subheadline gives more context and better embeddings
|
||||
await createDocument({
|
||||
environmentId,
|
||||
surveyId,
|
||||
responseId: response.id,
|
||||
questionId: question.id,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Await webhook promises if no emails are sent (with allSettled to prevent early rejection)
|
||||
const results = await Promise.allSettled(webhookPromises);
|
||||
|
||||
@@ -56,6 +56,7 @@ const Page = async ({ params }) => {
|
||||
webAppUrl={WEBAPP_URL}
|
||||
totalResponseCount={totalResponseCount}
|
||||
attributeClasses={attributeClasses}
|
||||
isAiEnabled={false} // Disable AI for sharing page for now
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
</div>
|
||||
|
||||
@@ -20,8 +20,8 @@ import { RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
|
||||
|
||||
export const middleware = async (request: NextRequest) => {
|
||||
// issue with next auth types & Next 15; let's review when new fixes are available
|
||||
const token = await getToken({ req: request });
|
||||
// TODO: any type because of conflict between nextjs and next-auth types. Will likely be solved with future packae updates
|
||||
const token = await getToken({ req: request as any });
|
||||
|
||||
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
|
||||
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.0.0",
|
||||
"lucide-react": "^0.427.0",
|
||||
"markdown-to-jsx": "^7.5.0",
|
||||
"mime": "^4.0.4",
|
||||
"next": "14.2.5",
|
||||
"next-safe-action": "^7.6.2",
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "^14.2.5",
|
||||
"@next/eslint-plugin-next": "14.2.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.5",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^2.0.14",
|
||||
"eslint-plugin-react": "7.35.0",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"noUnusedParameters": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
version: "3.3"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
image: ankane/pgvector
|
||||
volumes:
|
||||
- formbricks-postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- CreateExtension
|
||||
CREATE EXTENSION IF NOT EXISTS "vector";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InsightCategory" AS ENUM ('featureRequest', 'complaint', 'praise');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Sentiment" AS ENUM ('positive', 'negative', 'neutral');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Insight" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"environmentId" TEXT NOT NULL,
|
||||
"category" "InsightCategory" NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"vector" vector(512),
|
||||
|
||||
CONSTRAINT "Insight_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DocumentInsight" (
|
||||
"documentId" TEXT NOT NULL,
|
||||
"insightId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "DocumentInsight_pkey" PRIMARY KEY ("documentId","insightId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Document" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"environmentId" TEXT NOT NULL,
|
||||
"surveyId" TEXT,
|
||||
"responseId" TEXT,
|
||||
"questionId" TEXT,
|
||||
"sentiment" "Sentiment" NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"vector" vector(512),
|
||||
|
||||
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DocumentInsight_insightId_idx" ON "DocumentInsight"("insightId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Document_responseId_questionId_key" ON "Document"("responseId", "questionId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Insight" ADD CONSTRAINT "Insight_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentInsight" ADD CONSTRAINT "DocumentInsight_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentInsight" ADD CONSTRAINT "DocumentInsight_insightId_fkey" FOREIGN KEY ("insightId") REFERENCES "Insight"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_surveyId_fkey" FOREIGN KEY ("surveyId") REFERENCES "Survey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "Response"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -61,13 +61,13 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@paralleldrive/cuid2": "2.2.2",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"prisma": "^5.18.0",
|
||||
"prisma-dbml-generator": "^0.12.0",
|
||||
"prisma-json-types-generator": "^3.0.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"zod": "^3.23.8",
|
||||
"zod-prisma": "^0.5.4"
|
||||
"prisma": "5.18.0",
|
||||
"prisma-dbml-generator": "0.12.0",
|
||||
"prisma-json-types-generator": "3.0.4",
|
||||
"ts-node": "10.9.2",
|
||||
"zod": "3.23.8",
|
||||
"zod-prisma": "0.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
extensions = [pgvector(map: "vector")]
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["postgresqlExtensions"]
|
||||
}
|
||||
|
||||
// generator dbml {
|
||||
@@ -134,6 +136,7 @@ model Response {
|
||||
// singleUseId, used to prevent multiple responses
|
||||
singleUseId String?
|
||||
language String?
|
||||
documents Document[]
|
||||
displayId String? @unique
|
||||
display Display? @relation(fields: [displayId], references: [id])
|
||||
|
||||
@@ -329,6 +332,7 @@ model Survey {
|
||||
displayPercentage Decimal?
|
||||
languages SurveyLanguage[]
|
||||
showLanguageSwitch Boolean?
|
||||
documents Document[]
|
||||
|
||||
@@index([environmentId, updatedAt])
|
||||
@@index([segmentId])
|
||||
@@ -404,6 +408,8 @@ model Environment {
|
||||
tags Tag[]
|
||||
segments Segment[]
|
||||
integration Integration[]
|
||||
documents Document[]
|
||||
insights Insight[]
|
||||
|
||||
@@index([productId])
|
||||
}
|
||||
@@ -648,3 +654,57 @@ model SurveyLanguage {
|
||||
@@index([surveyId])
|
||||
@@index([languageId])
|
||||
}
|
||||
|
||||
enum InsightCategory {
|
||||
featureRequest
|
||||
complaint
|
||||
praise
|
||||
}
|
||||
|
||||
model Insight {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
environmentId String
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
category InsightCategory
|
||||
title String
|
||||
description String
|
||||
vector Unsupported("vector(512)")?
|
||||
documentInsights DocumentInsight[]
|
||||
}
|
||||
|
||||
model DocumentInsight {
|
||||
documentId String
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
insightId String
|
||||
insight Insight @relation(fields: [insightId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([documentId, insightId])
|
||||
@@index([insightId])
|
||||
}
|
||||
|
||||
enum Sentiment {
|
||||
positive
|
||||
negative
|
||||
neutral
|
||||
}
|
||||
|
||||
model Document {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
environmentId String
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
surveyId String?
|
||||
survey Survey? @relation(fields: [surveyId], references: [id], onDelete: Cascade)
|
||||
responseId String?
|
||||
response Response? @relation(fields: [responseId], references: [id], onDelete: Cascade)
|
||||
questionId String?
|
||||
sentiment Sentiment
|
||||
text String
|
||||
vector Unsupported("vector(512)")?
|
||||
documentInsights DocumentInsight[]
|
||||
|
||||
@@unique([responseId, questionId])
|
||||
}
|
||||
|
||||
@@ -20,13 +20,15 @@
|
||||
"@types/react": "18.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/azure": "^0.0.17",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"ai": "^3.2.37",
|
||||
"https-proxy-agent": "^7.0.5",
|
||||
"lucide-react": "^0.427.0",
|
||||
"next": "^14.2.5",
|
||||
"next": "14.2.5",
|
||||
"next-auth": "^4.24.7",
|
||||
"node-fetch": "^3.3.2",
|
||||
"react-hook-form": "^7.52.2",
|
||||
|
||||
14
packages/lib/ai.ts
Normal file
14
packages/lib/ai.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createAzure } from "@ai-sdk/azure";
|
||||
import { env } from "./env";
|
||||
|
||||
export const llmModel = createAzure({
|
||||
resourceName: env.AI_AZURE_LLM_RESSOURCE_NAME, // Azure resource name
|
||||
apiKey: env.AI_AZURE_LLM_API_KEY, // Azure API key
|
||||
})(env.AI_AZURE_LLM_DEPLOYMENT_ID || "llm");
|
||||
|
||||
export const embeddingsModel = createAzure({
|
||||
resourceName: env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME, // Azure resource name
|
||||
apiKey: env.AI_AZURE_EMBEDDINGS_API_KEY, // Azure API key
|
||||
}).embedding(env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID || "embeddings", {
|
||||
dimensions: 512,
|
||||
});
|
||||
@@ -211,3 +211,12 @@ export const BILLING_LIMITS = {
|
||||
MIU: 20000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const IS_AI_ENABLED = !!(
|
||||
env.AI_AZURE_EMBEDDINGS_API_KEY &&
|
||||
env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID &&
|
||||
env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME &&
|
||||
env.AI_AZURE_LLM_API_KEY &&
|
||||
env.AI_AZURE_LLM_DEPLOYMENT_ID &&
|
||||
env.AI_AZURE_LLM_RESSOURCE_NAME
|
||||
);
|
||||
|
||||
53
packages/lib/document/cache.ts
Normal file
53
packages/lib/document/cache.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string | null;
|
||||
surveyId?: string | null;
|
||||
responseId?: string | null;
|
||||
questionId?: string | null;
|
||||
insightId?: string | null;
|
||||
}
|
||||
|
||||
export const documentCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `documents-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-documents`;
|
||||
},
|
||||
byResponseIdQuestionId(responseId: string, questionId: string) {
|
||||
return `responses-${responseId}-questions-${questionId}-documents`;
|
||||
},
|
||||
bySurveyId(surveyId: string) {
|
||||
return `surveys-${surveyId}-documents`;
|
||||
},
|
||||
bySurveyIdQuestionId(surveyId: string, questionId: string) {
|
||||
return `surveys-${surveyId}-questions-${questionId}-documents`;
|
||||
},
|
||||
byInsightId(insightId: string) {
|
||||
return `insights-${insightId}-documents`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, environmentId, surveyId, responseId, questionId, insightId }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
if (surveyId) {
|
||||
revalidateTag(this.tag.bySurveyId(surveyId));
|
||||
}
|
||||
if (responseId && questionId) {
|
||||
revalidateTag(this.tag.byResponseIdQuestionId(responseId, questionId));
|
||||
}
|
||||
if (surveyId && questionId) {
|
||||
revalidateTag(this.tag.bySurveyIdQuestionId(surveyId, questionId));
|
||||
}
|
||||
if (insightId) {
|
||||
revalidateTag(this.tag.byInsightId(insightId));
|
||||
}
|
||||
},
|
||||
};
|
||||
288
packages/lib/document/service.ts
Normal file
288
packages/lib/document/service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { embed, generateObject } from "ai";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
TDocument,
|
||||
TDocumentCreateInput,
|
||||
ZDocumentCreateInput,
|
||||
ZDocumentSentiment,
|
||||
} from "@formbricks/types/documents";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZInsightCategory } from "@formbricks/types/insights";
|
||||
import { embeddingsModel, llmModel } from "../ai";
|
||||
import { cache } from "../cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { documentCache } from "./cache";
|
||||
import { handleInsightAssignments } from "./utils";
|
||||
|
||||
const DOCUMENTS_PER_PAGE = 10;
|
||||
|
||||
export type TPrismaDocument = Omit<TDocument, "vector"> & {
|
||||
vector: string;
|
||||
};
|
||||
|
||||
export const getDocumentsByInsightId = reactCache(
|
||||
(insightId: string, limit?: number, offset?: number): Promise<TDocument[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([insightId, ZId]);
|
||||
|
||||
limit = limit ?? DOCUMENTS_PER_PAGE;
|
||||
try {
|
||||
const documents = await prisma.document.findMany({
|
||||
where: {
|
||||
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;
|
||||
}
|
||||
},
|
||||
[`getDocumentsByInsightId-${insightId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [documentCache.tag.byInsightId(insightId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getDocumentsByInsightIdSurveyIdQuestionId = reactCache(
|
||||
(
|
||||
insightId: string,
|
||||
surveyId: string,
|
||||
questionId: string,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<TDocument[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([insightId, ZId]);
|
||||
|
||||
limit = limit ?? DOCUMENTS_PER_PAGE;
|
||||
try {
|
||||
const documents = await prisma.document.findMany({
|
||||
where: {
|
||||
questionId,
|
||||
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;
|
||||
}
|
||||
},
|
||||
[`getDocumentsByInsightIdQuestionId-${insightId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [documentCache.tag.bySurveyIdQuestionId(surveyId, questionId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const createDocument = async (documentInput: TDocumentCreateInput): Promise<TDocument> => {
|
||||
validateInputs([documentInput, ZDocumentCreateInput]);
|
||||
|
||||
try {
|
||||
// Generate text embedding
|
||||
const { embedding } = await embed({
|
||||
model: embeddingsModel,
|
||||
value: documentInput.text,
|
||||
});
|
||||
|
||||
// generate sentiment and insights
|
||||
const { object } = await generateObject({
|
||||
model: llmModel,
|
||||
schema: z.object({
|
||||
sentiment: ZDocumentSentiment,
|
||||
insights: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
category: ZInsightCategory,
|
||||
})
|
||||
),
|
||||
}),
|
||||
system: `You are an XM researcher. You analyse user feedback and extract insights and the sentiment from it. You are very objective, for the insights split the feedback in the smallest parts possible and only use the feedback itself to draw conclusions. An insight consist of a title and description (e.g. title: "Interactive charts and graphics", description: "Users would love to see a visualization of the analytics data") as well as tag it with the right category and tries to give insights while being not too specific as it might hold multiple documents.`,
|
||||
prompt: `Analyze this feedback: "${documentInput.text}"`,
|
||||
});
|
||||
|
||||
const sentiment = object.sentiment;
|
||||
const insights = object.insights;
|
||||
|
||||
// create document
|
||||
const prismaDocument = await prisma.document.create({
|
||||
data: {
|
||||
...documentInput,
|
||||
sentiment,
|
||||
},
|
||||
});
|
||||
|
||||
const document = {
|
||||
...prismaDocument,
|
||||
vector: embedding,
|
||||
};
|
||||
|
||||
// update document vector with the embedding
|
||||
const vectorString = `[${embedding.join(",")}]`;
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "Document"
|
||||
SET "vector" = ${vectorString}::vector(512)
|
||||
WHERE "id" = ${document.id};
|
||||
`;
|
||||
|
||||
// connect or create the insights
|
||||
const insightPromises: Promise<void>[] = [];
|
||||
for (const insight of insights) {
|
||||
if (typeof insight.title !== "string" || typeof insight.description !== "string") {
|
||||
throw new Error("Insight title and description must be a string");
|
||||
}
|
||||
|
||||
// create or connect the insight
|
||||
insightPromises.push(handleInsightAssignments(documentInput.environmentId, document.id, insight));
|
||||
}
|
||||
await Promise.all(insightPromises);
|
||||
|
||||
documentCache.revalidate({
|
||||
id: document.id,
|
||||
environmentId: document.environmentId,
|
||||
surveyId: document.surveyId,
|
||||
responseId: document.responseId,
|
||||
questionId: document.questionId,
|
||||
});
|
||||
|
||||
return document;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDocumentsByResponseIdQuestionId = reactCache(
|
||||
(responseId: string, questionId: string): Promise<TDocument[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([responseId, ZId], [questionId, ZId]);
|
||||
|
||||
try {
|
||||
const prismaDocuments: TPrismaDocument[] = await prisma.$queryRaw`
|
||||
SELECT
|
||||
id,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
"responseId",
|
||||
"questionId",
|
||||
text,
|
||||
vector::text
|
||||
FROM "Document" d
|
||||
WHERE d."responseId" = ${responseId}
|
||||
AND d."questionId" = ${questionId}
|
||||
`;
|
||||
|
||||
const documents = prismaDocuments.map((prismaDocument) => {
|
||||
// Convert the string representation of the vector back to an array of numbers
|
||||
const vector = prismaDocument.vector
|
||||
.slice(1, -1) // Remove the surrounding square brackets
|
||||
.split(",") // Split the string into an array of strings
|
||||
.map(Number); // Convert each string to a number
|
||||
return {
|
||||
...prismaDocument,
|
||||
vector,
|
||||
};
|
||||
});
|
||||
|
||||
return documents;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
console.error(error);
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getDocumentsByResponseIdQuestionId-${responseId}-${questionId}`],
|
||||
{
|
||||
tags: [documentCache.tag.byResponseIdQuestionId(responseId, questionId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const findNearestDocuments = async (
|
||||
environmentId: string,
|
||||
vector: number[],
|
||||
limit: number = 5,
|
||||
threshold: number = 0.5
|
||||
): Promise<TDocument[]> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
// Convert the embedding array to a JSON-like string representation
|
||||
const vectorString = `[${vector.join(",")}]`;
|
||||
|
||||
// Execute raw SQL query to find nearest neighbors and exclude the vector column
|
||||
const prismaDocuments: TPrismaDocument[] = await prisma.$queryRaw`
|
||||
SELECT
|
||||
id,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
"environmentId",
|
||||
text,
|
||||
"responseId",
|
||||
"questionId",
|
||||
"documentGroupId",
|
||||
vector::text
|
||||
FROM "Document" d
|
||||
WHERE d."environmentId" = ${environmentId}
|
||||
AND d."vector" <=> ${vectorString}::vector(512) <= ${threshold}
|
||||
ORDER BY d."vector" <=> ${vectorString}::vector(512)
|
||||
LIMIT ${limit};
|
||||
`;
|
||||
|
||||
const documents = prismaDocuments.map((prismaDocument) => {
|
||||
// Convert the string representation of the vector back to an array of numbers
|
||||
const vector = prismaDocument.vector
|
||||
.slice(1, -1) // Remove the surrounding square brackets
|
||||
.split(",") // Split the string into an array of strings
|
||||
.map(Number); // Convert each string to a number
|
||||
return {
|
||||
...prismaDocument,
|
||||
vector,
|
||||
};
|
||||
});
|
||||
|
||||
return documents;
|
||||
};
|
||||
62
packages/lib/document/utils.ts
Normal file
62
packages/lib/document/utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import "server-only";
|
||||
import { embed } from "ai";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TInsightCategory } from "@formbricks/types/insights";
|
||||
import { embeddingsModel } from "../ai";
|
||||
import { createInsight, findNearestInsights } from "../insight/service";
|
||||
import { getInsightVectorText } from "../insight/utils";
|
||||
import { documentCache } from "./cache";
|
||||
|
||||
export const getQuestionResponseReferenceId = (surveyId: string, questionId: string) => {
|
||||
return `${surveyId}-${questionId}`;
|
||||
};
|
||||
|
||||
export const handleInsightAssignments = async (
|
||||
environmentId: string,
|
||||
documentId: string,
|
||||
insight: {
|
||||
title: string;
|
||||
description: string;
|
||||
category: TInsightCategory;
|
||||
}
|
||||
) => {
|
||||
// create embedding for insight
|
||||
const { embedding } = await embed({
|
||||
model: embeddingsModel,
|
||||
value: getInsightVectorText(insight.title, insight.description),
|
||||
});
|
||||
// find close insight to merge it with
|
||||
const nearestInsights = await findNearestInsights(environmentId, embedding, 1, 0.35);
|
||||
|
||||
if (nearestInsights.length > 0) {
|
||||
// create a documentInsight with this insight
|
||||
await prisma.documentInsight.create({
|
||||
data: {
|
||||
documentId,
|
||||
insightId: nearestInsights[0].id,
|
||||
},
|
||||
});
|
||||
documentCache.revalidate({
|
||||
insightId: nearestInsights[0].id,
|
||||
});
|
||||
} else {
|
||||
// create new insight and documentInsight
|
||||
const newInsight = await createInsight({
|
||||
environmentId: environmentId,
|
||||
title: insight.title,
|
||||
description: insight.description,
|
||||
category: insight.category,
|
||||
vector: embedding,
|
||||
});
|
||||
// create a documentInsight with this insight
|
||||
await prisma.documentInsight.create({
|
||||
data: {
|
||||
documentId,
|
||||
insightId: newInsight.id,
|
||||
},
|
||||
});
|
||||
documentCache.revalidate({
|
||||
insightId: newInsight.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,12 @@ export const env = createEnv({
|
||||
* Will throw if you access these variables on the client.
|
||||
*/
|
||||
server: {
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: z.string().optional(),
|
||||
AI_AZURE_LLM_API_KEY: z.string().optional(),
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: z.string().optional(),
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: z.string().optional(),
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: z.string().optional(),
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: z.string().optional(),
|
||||
AIRTABLE_CLIENT_ID: z.string().optional(),
|
||||
AZUREAD_CLIENT_ID: z.string().optional(),
|
||||
AZUREAD_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -113,6 +119,12 @@ export const env = createEnv({
|
||||
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: process.env.AI_AZURE_EMBEDDINGS_API_KEY,
|
||||
AI_AZURE_LLM_API_KEY: process.env.AI_AZURE_LLM_API_KEY,
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: process.env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID,
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: process.env.AI_AZURE_LLM_DEPLOYMENT_ID,
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: process.env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME,
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: process.env.AI_AZURE_LLM_RESSOURCE_NAME,
|
||||
AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID,
|
||||
AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID,
|
||||
AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET,
|
||||
|
||||
25
packages/lib/insight/cache.ts
Normal file
25
packages/lib/insight/cache.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
export const insightCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `documentGroups-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-documentGroups`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, environmentId }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
},
|
||||
};
|
||||
273
packages/lib/insight/service.ts
Normal file
273
packages/lib/insight/service.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TInsight, TInsightCreateInput, ZInsightCreateInput } from "@formbricks/types/insights";
|
||||
import { cache } from "../cache";
|
||||
import { documentCache } from "../document/cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { insightCache } from "./cache";
|
||||
|
||||
const INSIGHTS_PER_PAGE = 10;
|
||||
|
||||
export const getInsight = reactCache(
|
||||
(id: string): Promise<TInsight | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([id, ZId]);
|
||||
|
||||
try {
|
||||
const insight = await prisma.insight.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documentInsights: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return insight;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getInsight-${id}`],
|
||||
{
|
||||
tags: [insightCache.tag.byId(id)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getInsights = reactCache(
|
||||
(environmentId: string, limit?: number, offset?: number): Promise<TInsight[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
limit = limit ?? INSIGHTS_PER_PAGE;
|
||||
try {
|
||||
const insights = await prisma.insight.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documentInsights: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
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;
|
||||
}
|
||||
},
|
||||
[`getInsights-${environmentId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [insightCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getInsightsBySurveyId = reactCache(
|
||||
(surveyId: string, limit?: number, offset?: number): Promise<TInsight[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
|
||||
limit = limit ?? INSIGHTS_PER_PAGE;
|
||||
try {
|
||||
const insights = await prisma.insight.findMany({
|
||||
where: {
|
||||
documentInsights: {
|
||||
some: {
|
||||
document: {
|
||||
surveyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documentInsights: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
documentInsights: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
},
|
||||
[`getInsightsBySurveyId-${surveyId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [documentCache.tag.bySurveyId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getInsightsBySurveyIdQuestionId = reactCache(
|
||||
(surveyId: string, questionId: string, limit?: number, offset?: number): Promise<TInsight[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [questionId, ZId]);
|
||||
|
||||
limit = limit ?? INSIGHTS_PER_PAGE;
|
||||
try {
|
||||
const insights = await prisma.insight.findMany({
|
||||
where: {
|
||||
documentInsights: {
|
||||
some: {
|
||||
document: {
|
||||
surveyId,
|
||||
questionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documentInsights: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
documentInsights: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
},
|
||||
[`getInsightsBySurveyIdQuestionId-${surveyId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [documentCache.tag.bySurveyId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const createInsight = async (insightGroupInput: TInsightCreateInput): Promise<TInsight> => {
|
||||
validateInputs([insightGroupInput, ZInsightCreateInput]);
|
||||
|
||||
try {
|
||||
// create document
|
||||
const { vector, ...data } = insightGroupInput;
|
||||
const prismaInsight = await prisma.insight.create({
|
||||
data,
|
||||
});
|
||||
|
||||
const insight = {
|
||||
...prismaInsight,
|
||||
_count: {
|
||||
documentInsights: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// update document vector with the embedding
|
||||
const vectorString = `[${insightGroupInput.vector.join(",")}]`;
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "Insight"
|
||||
SET "vector" = ${vectorString}::vector(512)
|
||||
WHERE "id" = ${insight.id};
|
||||
`;
|
||||
|
||||
insightCache.revalidate({
|
||||
id: insight.id,
|
||||
environmentId: insight.environmentId,
|
||||
});
|
||||
|
||||
return insight;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const findNearestInsights = async (
|
||||
environmentId: string,
|
||||
vector: number[],
|
||||
limit: number = 5,
|
||||
threshold: number = 0.5
|
||||
): Promise<TInsight[]> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
// Convert the embedding array to a JSON-like string representation
|
||||
const vectorString = `[${vector.join(",")}]`;
|
||||
|
||||
// Execute raw SQL query to find nearest neighbors and exclude the vector column
|
||||
const insights: TInsight[] = await prisma.$queryRaw`
|
||||
SELECT
|
||||
id,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
"environmentId"
|
||||
FROM "Insight" d
|
||||
WHERE d."environmentId" = ${environmentId}
|
||||
AND d."vector" <=> ${vectorString}::vector(512) <= ${threshold}
|
||||
ORDER BY d."vector" <=> ${vectorString}::vector(512)
|
||||
LIMIT ${limit};
|
||||
`;
|
||||
|
||||
return insights;
|
||||
};
|
||||
2
packages/lib/insight/utils.ts
Normal file
2
packages/lib/insight/utils.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const getInsightVectorText = (title: string, description: string): string =>
|
||||
`${title}: ${description}`;
|
||||
@@ -3,6 +3,7 @@ import { getActionClass } from "../actionClass/service";
|
||||
import { getApiKey } from "../apiKey/service";
|
||||
import { getAttributeClass } from "../attributeClass/service";
|
||||
import { getEnvironment } from "../environment/service";
|
||||
import { getInsight } from "../insight/service";
|
||||
import { getIntegration } from "../integration/service";
|
||||
import { getInvite } from "../invite/service";
|
||||
import { getLanguage } from "../language/service";
|
||||
@@ -55,6 +56,15 @@ export const getOrganizationIdFromResponseId = async (responseId: string) => {
|
||||
return await getOrganizationIdFromSurveyId(response.surveyId);
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromInsightId = async (insightId: string) => {
|
||||
const insight = await getInsight(insightId);
|
||||
if (!insight) {
|
||||
throw new ResourceNotFoundError("insight", insightId);
|
||||
}
|
||||
|
||||
return await getOrganizationIdFromEnvironmentId(insight.environmentId);
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromPersonId = async (personId: string) => {
|
||||
const person = await getPerson(personId);
|
||||
if (!person) {
|
||||
|
||||
@@ -38,11 +38,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/ungap__structured-clone": "^1.2.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"ts-node": "^10.9.2",
|
||||
"vitest": "^2.0.5",
|
||||
"vitest-mock-extended": "^2.0.0"
|
||||
|
||||
@@ -548,7 +548,7 @@ export const getSurveySummary = reactCache(
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
|
||||
const meta = getSurveySummaryMeta(responses, displayCount);
|
||||
const questionWiseSummary = getQuestionWiseSummary(survey, responses, dropOff);
|
||||
const questionWiseSummary = await getQuestionWiseSummary(survey, responses, dropOff);
|
||||
|
||||
return { meta, dropOff, summary: questionWiseSummary };
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "../i18n/utils";
|
||||
import { getInsightsBySurveyIdQuestionId } from "../insight/service";
|
||||
import { structuredClone } from "../pollyfills/structuredClone";
|
||||
import { processResponseData } from "../responses";
|
||||
import { evaluateLogic, performActions } from "../surveyLogic/utils";
|
||||
@@ -815,15 +816,15 @@ const checkForI18n = (response: TResponse, id: string, survey: TSurvey, language
|
||||
return getLocalizedValue(choice?.label, "default") || response.data[id];
|
||||
};
|
||||
|
||||
export const getQuestionWiseSummary = (
|
||||
export const getQuestionWiseSummary = async (
|
||||
survey: TSurvey,
|
||||
responses: TResponse[],
|
||||
dropOff: TSurveySummary["dropOff"]
|
||||
): TSurveySummary["summary"] => {
|
||||
): Promise<TSurveySummary["summary"]> => {
|
||||
const VALUES_LIMIT = 50;
|
||||
let summary: TSurveySummary["summary"] = [];
|
||||
|
||||
survey.questions.forEach((question, idx) => {
|
||||
for (const question of survey.questions) {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionTypeEnum.OpenText: {
|
||||
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||
@@ -839,15 +840,17 @@ export const getQuestionWiseSummary = (
|
||||
});
|
||||
}
|
||||
});
|
||||
const insights = await getInsightsBySurveyIdQuestionId(survey.id, question.id);
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
insights,
|
||||
});
|
||||
|
||||
values = [];
|
||||
values;
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
@@ -1088,6 +1091,7 @@ export const getQuestionWiseSummary = (
|
||||
});
|
||||
|
||||
const totalResponses = data.clicked + data.dismissed;
|
||||
const idx = survey.questions.findIndex((q) => q.id === question.id);
|
||||
const impressions = dropOff[idx].impressions;
|
||||
|
||||
summary.push({
|
||||
@@ -1356,7 +1360,7 @@ export const getQuestionWiseSummary = (
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
|
||||
let values: TSurveyQuestionSummaryHiddenFields["samples"] = [];
|
||||
|
||||
@@ -49,7 +49,7 @@ export type TQuestion = {
|
||||
export const questionTypes: TQuestion[] = [
|
||||
{
|
||||
id: QuestionId.OpenText,
|
||||
label: "Free text",
|
||||
label: "Free Text",
|
||||
description: "Ask for a text-based answer",
|
||||
icon: MessageSquareTextIcon,
|
||||
preset: {
|
||||
@@ -208,7 +208,7 @@ export const questionTypes: TQuestion[] = [
|
||||
},
|
||||
{
|
||||
id: QuestionId.Cal,
|
||||
label: "Schedule a meeting",
|
||||
label: "Schedule a Meeting",
|
||||
description: "Allow respondents to schedule a meet",
|
||||
icon: PhoneIcon,
|
||||
preset: {
|
||||
|
||||
9
packages/types/document-insights.ts
Normal file
9
packages/types/document-insights.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "./common";
|
||||
|
||||
export const ZDocumentInsight = z.object({
|
||||
documentId: ZId,
|
||||
insightId: ZId,
|
||||
});
|
||||
|
||||
export type TDocumentInsight = z.infer<typeof ZDocumentInsight>;
|
||||
29
packages/types/documents.ts
Normal file
29
packages/types/documents.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "./common";
|
||||
|
||||
export const ZDocumentSentiment = z.enum(["positive", "negative", "neutral"]);
|
||||
|
||||
export type TDocumentSentiment = z.infer<typeof ZDocumentSentiment>;
|
||||
|
||||
export const ZDocument = z.object({
|
||||
id: ZId,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
environmentId: ZId,
|
||||
responseId: ZId.nullable(),
|
||||
questionId: ZId.nullable(),
|
||||
sentiment: ZDocumentSentiment,
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export type TDocument = z.infer<typeof ZDocument>;
|
||||
|
||||
export const ZDocumentCreateInput = z.object({
|
||||
environmentId: ZId,
|
||||
surveyId: ZId,
|
||||
responseId: ZId,
|
||||
questionId: ZId,
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export type TDocumentCreateInput = z.infer<typeof ZDocumentCreateInput>;
|
||||
31
packages/types/insights.ts
Normal file
31
packages/types/insights.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "./common";
|
||||
|
||||
export const ZInsightCategory = z.enum(["featureRequest", "complaint", "praise"]);
|
||||
|
||||
export type TInsightCategory = z.infer<typeof ZInsightCategory>;
|
||||
|
||||
export const ZInsight = z.object({
|
||||
id: ZId,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
environmentId: ZId,
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
category: ZInsightCategory,
|
||||
_count: z.object({
|
||||
documentInsights: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TInsight = z.infer<typeof ZInsight>;
|
||||
|
||||
export const ZInsightCreateInput = z.object({
|
||||
environmentId: ZId,
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
category: ZInsightCategory,
|
||||
vector: z.array(z.number()).length(512),
|
||||
});
|
||||
|
||||
export type TInsightCreateInput = z.infer<typeof ZInsightCreateInput>;
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
|
||||
import { ZAttributes } from "../attributes";
|
||||
import { ZAllowedFileExtension, ZColor, ZId, ZPlacement } from "../common";
|
||||
import { ZInsight } from "../insights";
|
||||
import { ZLanguage } from "../product";
|
||||
import { ZSegment } from "../segment";
|
||||
import { ZBaseStyling } from "../styling";
|
||||
@@ -2093,6 +2094,7 @@ export const ZSurveyQuestionSummaryOpenText = z.object({
|
||||
personAttributes: ZAttributes.nullable(),
|
||||
})
|
||||
),
|
||||
insights: z.array(ZInsight),
|
||||
});
|
||||
|
||||
export type TSurveyQuestionSummaryOpenText = z.infer<typeof ZSurveyQuestionSummaryOpenText>;
|
||||
@@ -2429,6 +2431,8 @@ export const ZSurveySummary = z.object({
|
||||
summary: z.array(z.union([ZSurveyQuestionSummary, ZSurveyQuestionSummaryHiddenFields])),
|
||||
});
|
||||
|
||||
export type TSurveySummary = z.infer<typeof ZSurveySummary>;
|
||||
|
||||
export const ZSurveyFilterCriteria = z.object({
|
||||
name: z.string().optional(),
|
||||
status: z.array(ZSurveyStatus).optional(),
|
||||
@@ -2467,7 +2471,6 @@ const ZSortOption = z.object({
|
||||
});
|
||||
|
||||
export type TSortOption = z.infer<typeof ZSortOption>;
|
||||
export type TSurveySummary = z.infer<typeof ZSurveySummary>;
|
||||
|
||||
export const ZSurveyRecallItem = z.object({
|
||||
id: z.string(),
|
||||
|
||||
@@ -1,65 +1,52 @@
|
||||
import { Button } from "../Button";
|
||||
import * as React from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
interface CardProps {
|
||||
connectText?: string;
|
||||
connectHref?: string;
|
||||
connectNewTab?: boolean;
|
||||
docsText?: string;
|
||||
docsHref?: string;
|
||||
docsNewTab?: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
connected?: boolean;
|
||||
statusText?: string;
|
||||
}
|
||||
|
||||
export type { CardProps };
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
connectText,
|
||||
connectHref,
|
||||
connectNewTab,
|
||||
docsText,
|
||||
docsHref,
|
||||
docsNewTab,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
connected,
|
||||
statusText,
|
||||
}) => (
|
||||
<div className="relative rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
{connected != undefined && statusText != undefined && (
|
||||
<div className="absolute right-4 top-4 flex items-center rounded bg-slate-100 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||||
{connected === true ? (
|
||||
<span className="relative mr-1 flex h-2 w-2">
|
||||
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative mr-1 flex h-2 w-2">
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
</span>
|
||||
)}
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{icon && <div className="mb-6 h-8 w-8">{icon}</div>}
|
||||
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
|
||||
<p className="text-xs text-slate-500">{description}</p>
|
||||
<div className="mt-4 flex space-x-2">
|
||||
{connectHref && (
|
||||
<Button href={connectHref} target={connectNewTab ? "_blank" : "_self"} size="sm">
|
||||
{connectText}
|
||||
</Button>
|
||||
)}
|
||||
{docsHref && (
|
||||
<Button href={docsHref} target={docsNewTab ? "_blank" : "_self"} size="sm" variant="secondary">
|
||||
{docsText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("bg-card text-card-foreground rounded-lg border shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BellRing } from "lucide-react";
|
||||
import { Card } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Card",
|
||||
title: "ui/IntegrationCard",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
|
||||
61
packages/ui/components/InsightFilter/index.tsx
Normal file
61
packages/ui/components/InsightFilter/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TInsight, TInsightCategory } from "@formbricks/types/insights";
|
||||
|
||||
interface InsightFilterProps {
|
||||
insights: TInsight[];
|
||||
setInsights: (insights: TInsight[]) => void;
|
||||
}
|
||||
|
||||
export const InsightFilter = ({ insights, setInsights }: InsightFilterProps) => {
|
||||
const [selectedFilter, setSelectedFilter] = useState<string>("all");
|
||||
|
||||
const filters = [
|
||||
{
|
||||
label: "All",
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
label: "Complaint",
|
||||
value: "complaint",
|
||||
},
|
||||
{
|
||||
label: "Feature Request",
|
||||
value: "featureRequest",
|
||||
},
|
||||
{
|
||||
label: "Praise",
|
||||
value: "praise",
|
||||
},
|
||||
];
|
||||
|
||||
const handleFilterSelect = (filterValue: string) => {
|
||||
setSelectedFilter(filterValue);
|
||||
if (filterValue === "all") {
|
||||
setInsights(insights);
|
||||
} else {
|
||||
setInsights(insights.filter((insight) => insight.category === (filterValue as TInsightCategory)));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
onClick={() => handleFilterSelect(filter.value)}
|
||||
className={cn(
|
||||
selectedFilter === filter.value
|
||||
? "bg-slate-800 font-semibold text-white"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
|
||||
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
|
||||
)}>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
30
packages/ui/components/InsightSheet/actions.ts
Normal file
30
packages/ui/components/InsightSheet/actions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getDocumentsByInsightIdSurveyIdQuestionId } from "@formbricks/lib/document/service";
|
||||
import { getOrganizationIdFromInsightId } from "@formbricks/lib/organization/utils";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZGetDocumentsByInsightIdAction = z.object({
|
||||
insightId: ZId,
|
||||
surveyId: ZId,
|
||||
questionId: ZId,
|
||||
});
|
||||
|
||||
export const getDocumentsByInsightIdSurveyIdQuestionIdAction = authenticatedActionClient
|
||||
.schema(ZGetDocumentsByInsightIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromInsightId(parsedInput.insightId),
|
||||
rules: ["response", "read"],
|
||||
});
|
||||
|
||||
return await getDocumentsByInsightIdSurveyIdQuestionId(
|
||||
parsedInput.insightId,
|
||||
parsedInput.surveyId,
|
||||
parsedInput.questionId
|
||||
);
|
||||
});
|
||||
125
packages/ui/components/InsightSheet/index.tsx
Normal file
125
packages/ui/components/InsightSheet/index.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TDocument } from "@formbricks/types/documents";
|
||||
import { TInsight } from "@formbricks/types/insights";
|
||||
import { Badge } from "../Badge";
|
||||
import { Card, CardContent, CardFooter } from "../Card";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "../Sheet";
|
||||
import { getDocumentsByInsightIdSurveyIdQuestionIdAction } from "./actions";
|
||||
|
||||
interface InsightSheetProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
insight: TInsight | null;
|
||||
surveyId: string;
|
||||
questionId: string;
|
||||
handleFeedback: (feedback: "positive" | "negative") => void;
|
||||
}
|
||||
|
||||
export const InsightSheet = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
insight,
|
||||
surveyId,
|
||||
questionId,
|
||||
handleFeedback,
|
||||
}: InsightSheetProps) => {
|
||||
const [documents, setDocuments] = useState<TDocument[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (insight) {
|
||||
fetchDocuments();
|
||||
}
|
||||
|
||||
async function fetchDocuments() {
|
||||
if (!insight) {
|
||||
throw Error("Insight is required to fetch documents");
|
||||
}
|
||||
const documentsResponse = await getDocumentsByInsightIdSurveyIdQuestionIdAction({
|
||||
insightId: insight.id,
|
||||
surveyId,
|
||||
questionId,
|
||||
});
|
||||
console.log(documentsResponse);
|
||||
if (!documentsResponse?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(documentsResponse);
|
||||
console.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
setDocuments(documentsResponse.data);
|
||||
}
|
||||
}, [insight]);
|
||||
|
||||
if (!insight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleFeedbackClick = (feedback: "positive" | "negative") => {
|
||||
setIsOpen(false);
|
||||
handleFeedback(feedback);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={isOpen} onOpenChange={(v) => setIsOpen(v)}>
|
||||
<SheetContent className="flex h-full w-[400rem] flex-col bg-white lg:max-w-lg xl:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<span className="mr-3">{insight.title}</span>
|
||||
{insight.category === "complaint" ? (
|
||||
<Badge text="Complaint" type="error" size="tiny" />
|
||||
) : insight.category === "featureRequest" ? (
|
||||
<Badge text="Request" type="warning" size="tiny" />
|
||||
) : insight.category === "praise" ? (
|
||||
<Badge text="Praise" type="success" size="tiny" />
|
||||
) : null}
|
||||
</SheetTitle>
|
||||
<SheetDescription>{insight.description}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col space-y-2 overflow-auto pt-4">
|
||||
{documents.map((document) => (
|
||||
<Card>
|
||||
<CardContent className="p-4 text-sm">
|
||||
<Markdown className="whitespace-pre-wrap">{document.text}</Markdown>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||
<p>
|
||||
Sentiment:{" "}
|
||||
{document.sentiment === "positive" ? (
|
||||
<Badge text="Positive" size="tiny" type="success" />
|
||||
) : document.sentiment === "neutral" ? (
|
||||
<Badge text="Neutral" size="tiny" type="gray" />
|
||||
) : document.sentiment === "negative" ? (
|
||||
<Badge text="Negative" size="tiny" type="error" />
|
||||
) : null}
|
||||
</p>
|
||||
<p>{timeSince(new Date(document.createdAt).toISOString())}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<div className="flex items-center gap-2">
|
||||
<p>Did you find this insight helpful?</p>
|
||||
<ThumbsUpIcon
|
||||
className="upvote h-5 w-5 cursor-pointer"
|
||||
onClick={() => handleFeedbackClick("positive")}
|
||||
/>
|
||||
<ThumbsDownIcon
|
||||
className="downvote h-5 w-5 cursor-pointer"
|
||||
onClick={() => handleFeedbackClick("negative")}
|
||||
/>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
65
packages/ui/components/IntegrationCard/index.tsx
Normal file
65
packages/ui/components/IntegrationCard/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Button } from "../Button";
|
||||
|
||||
interface CardProps {
|
||||
connectText?: string;
|
||||
connectHref?: string;
|
||||
connectNewTab?: boolean;
|
||||
docsText?: string;
|
||||
docsHref?: string;
|
||||
docsNewTab?: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
connected?: boolean;
|
||||
statusText?: string;
|
||||
}
|
||||
|
||||
export type { CardProps };
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
connectText,
|
||||
connectHref,
|
||||
connectNewTab,
|
||||
docsText,
|
||||
docsHref,
|
||||
docsNewTab,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
connected,
|
||||
statusText,
|
||||
}) => (
|
||||
<div className="relative rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
{connected != undefined && statusText != undefined && (
|
||||
<div className="absolute right-4 top-4 flex items-center rounded bg-slate-100 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||||
{connected === true ? (
|
||||
<span className="relative mr-1 flex h-2 w-2">
|
||||
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative mr-1 flex h-2 w-2">
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
</span>
|
||||
)}
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{icon && <div className="mb-6 h-8 w-8">{icon}</div>}
|
||||
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
|
||||
<p className="text-xs text-slate-500">{description}</p>
|
||||
<div className="mt-4 flex space-x-2">
|
||||
{connectHref && (
|
||||
<Button href={connectHref} target={connectNewTab ? "_blank" : "_self"} size="sm">
|
||||
{connectText}
|
||||
</Button>
|
||||
)}
|
||||
{docsHref && (
|
||||
<Button href={docsHref} target={docsNewTab ? "_blank" : "_self"} size="sm" variant="secondary">
|
||||
{docsText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
69
packages/ui/components/IntegrationCard/stories.tsx
Normal file
69
packages/ui/components/IntegrationCard/stories.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { Card } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/IntegrationCard",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `The **card** component is used to display a card with a label, description, and optional icon. It can also display a status and buttons for connecting and viewing documentation.`,
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
icon: { control: "text" },
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Card>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: true,
|
||||
statusText: "Connected",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disconnected: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: false,
|
||||
statusText: "Disconnected",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: true,
|
||||
statusText: "Connected",
|
||||
icon: <BellRing />,
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,13 @@ import Link from "next/link";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
interface SecondaryNavbarProps {
|
||||
navigation: { id: string; label: string; href: string; icon?: React.ReactNode; hidden?: boolean }[];
|
||||
navigation: {
|
||||
id: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
hidden?: boolean;
|
||||
}[];
|
||||
activeId: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
@@ -15,37 +21,70 @@ export const SecondaryNavigation = ({ navigation, activeId, loading, ...props }:
|
||||
{loading ? (
|
||||
<>
|
||||
{navigation.map((navElem) => (
|
||||
<span
|
||||
key={navElem.id}
|
||||
aria-disabled="true"
|
||||
className={cn(
|
||||
navElem.id === activeId
|
||||
? "border-slate600-dark border-b-2 font-semibold text-slate-900"
|
||||
: "border-transparent text-slate-500",
|
||||
"flex h-full items-center border-b-2 px-3 text-sm font-medium",
|
||||
navElem.hidden && "hidden"
|
||||
)}
|
||||
aria-current={navElem.id === activeId ? "page" : undefined}>
|
||||
{navElem.label}
|
||||
</span>
|
||||
<div className="group flex h-full flex-col">
|
||||
<div
|
||||
key={navElem.id}
|
||||
aria-disabled="true"
|
||||
className={cn(
|
||||
navElem.id === activeId ? "font-semibold text-slate-900" : "text-slate-500",
|
||||
"flex h-full items-center px-3 text-sm font-medium",
|
||||
navElem.hidden && "hidden"
|
||||
)}
|
||||
aria-current={navElem.id === activeId ? "page" : undefined}>
|
||||
{navElem.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"bottom-0 mt-auto h-[2px] w-full rounded-t-lg transition-all duration-150 ease-in-out",
|
||||
navElem.id === activeId ? "bg-slate-300" : "bg-transparent group-hover:bg-slate-300",
|
||||
navElem.hidden && "hidden"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{navigation.map((navElem) => (
|
||||
<Link
|
||||
key={navElem.id}
|
||||
href={navElem.href}
|
||||
className={cn(
|
||||
navElem.id === activeId
|
||||
? "border-brand-dark border-b-2 font-semibold text-slate-900"
|
||||
: "border-transparent text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700",
|
||||
"flex h-full items-center border-b-2 px-3 text-sm font-medium",
|
||||
navElem.hidden && "hidden"
|
||||
<div className="group flex h-full flex-col">
|
||||
{navElem.href ? (
|
||||
<Link
|
||||
key={navElem.id}
|
||||
href={navElem.href}
|
||||
{...(navElem.onClick ? { onClick: navElem.onClick } : {})}
|
||||
className={cn(
|
||||
navElem.id === activeId
|
||||
? "font-semibold text-slate-900"
|
||||
: "text-slate-500 hover:text-slate-700",
|
||||
"flex h-full items-center px-3 text-sm font-medium",
|
||||
navElem.hidden && "hidden"
|
||||
)}
|
||||
aria-current={navElem.id === activeId ? "page" : undefined}>
|
||||
{navElem.label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
key={navElem.id}
|
||||
{...(navElem.onClick ? { onClick: navElem.onClick } : {})}
|
||||
className={cn(
|
||||
navElem.id === activeId
|
||||
? "font-semibold text-slate-900"
|
||||
: "text-slate-500 hover:text-slate-700",
|
||||
"grow items-center px-3 text-sm font-medium transition-all duration-150 ease-in-out",
|
||||
navElem.hidden && "hidden"
|
||||
)}
|
||||
aria-current={navElem.id === activeId ? "page" : undefined}>
|
||||
{navElem.label}
|
||||
</button>
|
||||
)}
|
||||
aria-current={navElem.id === activeId ? "page" : undefined}>
|
||||
{navElem.label}
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
"bottom-0 mt-auto h-[2px] w-full rounded-t-lg transition-all duration-150 ease-in-out",
|
||||
navElem.id === activeId ? "bg-brand-dark" : "bg-transparent group-hover:bg-slate-300",
|
||||
navElem.hidden && "hidden"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
119
packages/ui/components/Sheet/index.tsx
Normal file
119
packages/ui/components/Sheet/index.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-foreground text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -4,7 +4,7 @@ module.exports = {
|
||||
"./app/**/*.{js,ts,jsx,tsx}", // Note the addition of the `app` directory.
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
// include packages if not transpiling
|
||||
"../../packages/ui/**/*.{ts,tsx}",
|
||||
"../../packages/ui/components/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
1244
pnpm-lock.yaml
generated
1244
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,12 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**"],
|
||||
"env": [
|
||||
"AI_AZURE_EMBEDDINGS_API_KEY",
|
||||
"AI_AZURE_LLM_API_KEY",
|
||||
"AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID",
|
||||
"AI_AZURE_EMBEDDINGS_RESSOURCE_NAME",
|
||||
"AI_AZURE_LLM_DEPLOYMENT_ID",
|
||||
"AI_AZURE_LLM_RESSOURCE_NAME",
|
||||
"AIRTABLE_CLIENT_ID",
|
||||
"ASSET_PREFIX_URL",
|
||||
"AZUREAD_CLIENT_ID",
|
||||
|
||||
Reference in New Issue
Block a user