mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-12 09:39:39 -06:00
chore: Tweaked survey list (#1978)
Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
committed by
GitHub
parent
70fe0fb7a7
commit
1402f4a48b
@@ -1,19 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { Team } from "@prisma/client";
|
||||
import { Prisma as prismaClient } from "@prisma/client/";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
|
||||
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -67,186 +62,6 @@ export async function createTeamAction(teamName: string): Promise<Team> {
|
||||
return newTeam;
|
||||
}
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId);
|
||||
return duplicatedSurvey;
|
||||
}
|
||||
|
||||
export async function copyToOtherEnvironmentAction(
|
||||
environmentId: string,
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
environmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
targetEnvironmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
},
|
||||
include: {
|
||||
triggers: {
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
},
|
||||
attributeFilters: {
|
||||
include: {
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
let targetEnvironmentTriggers: string[] = [];
|
||||
// map the local triggers to the target environment
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentTrigger) {
|
||||
// if the trigger does not exist in the target environment, create it
|
||||
const newTrigger = await prisma.actionClass.create({
|
||||
data: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
description: trigger.actionClass.description,
|
||||
type: trigger.actionClass.type,
|
||||
noCodeConfig: trigger.actionClass.noCodeConfig
|
||||
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
} else {
|
||||
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
|
||||
}
|
||||
}
|
||||
|
||||
let targetEnvironmentAttributeFilters: string[] = [];
|
||||
// map the local attributeFilters to the target env
|
||||
for (const attributeFilter of existingSurvey.attributeFilters) {
|
||||
// check if attributeClass exists in target env.
|
||||
// if not, create it
|
||||
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentAttributeClass) {
|
||||
const newAttributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
description: attributeFilter.attributeClass.description,
|
||||
type: attributeFilter.attributeClass.type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
|
||||
} else {
|
||||
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
|
||||
}
|
||||
}
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((actionClassId) => ({
|
||||
actionClassId: actionClassId,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
|
||||
attributeClassId: targetEnvironmentAttributeFilters[idx],
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
styling: existingSurvey.styling ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
|
||||
surveyCache.revalidate({
|
||||
id: newSurvey.id,
|
||||
environmentId: targetEnvironmentId,
|
||||
});
|
||||
return newSurvey;
|
||||
}
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
|
||||
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
export const createProductAction = async (environmentId: string, productName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
@@ -27,10 +27,7 @@ export default function SurveyStatusDropdown({
|
||||
<>
|
||||
{survey.status === "draft" ? (
|
||||
<div className="flex items-center">
|
||||
{(survey.type === "link" || environment.widgetSetupCompleted) && (
|
||||
<SurveyStatusIndicator status={survey.status} />
|
||||
)}
|
||||
{survey.status === "draft" && <p className="text-sm italic text-slate-600">Draft</p>}
|
||||
<p className="text-sm italic text-slate-600">Draft</p>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
|
||||
@@ -15,5 +15,5 @@ export async function createSurveyAction(environmentId: string, surveyBody: TSur
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await createSurvey(environmentId, surveyBody);
|
||||
return await createSurvey(environmentId, surveyBody, session.user.id);
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { UsageAttributesUpdater } from "@/app/(app)/components/FormbricksClient";
|
||||
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
|
||||
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import type { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator";
|
||||
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const isSurveyCreationDeletionDisabled = isViewer;
|
||||
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const surveys = await getSurveys(environmentId);
|
||||
|
||||
const environments: TEnvironment[] = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
|
||||
|
||||
if (surveys.length === 0) {
|
||||
return (
|
||||
<SurveyStarter
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
product={product}
|
||||
user={session.user}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="grid place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
<Link href={`/environments/${environmentId}/surveys/templates`}>
|
||||
<li className="col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-br from-slate-900 to-slate-800 font-light text-white shadow transition ease-in-out hover:scale-105 hover:from-slate-800 hover:to-slate-700">
|
||||
<div id="main-cta" className="px-4 py-8 sm:p-14 xl:p-10">
|
||||
<PlusIcon className="stroke-thin mx-auto h-14 w-14" />
|
||||
Create Survey
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</Link>
|
||||
)}
|
||||
{surveys
|
||||
.sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime())
|
||||
.map((survey) => {
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
const isEncrypted = survey.singleUse?.isEncrypted ?? false;
|
||||
const singleUseId = isSingleUse ? generateSurveySingleUseId(isEncrypted) : undefined;
|
||||
return (
|
||||
<li key={survey.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-6 py-4">
|
||||
<Badge
|
||||
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
|
||||
startIconClassName="mr-2"
|
||||
text={
|
||||
survey.type === "link"
|
||||
? "Link Survey"
|
||||
: survey.type === "web"
|
||||
? "In-Product Survey"
|
||||
: ""
|
||||
}
|
||||
type="gray"
|
||||
size={"tiny"}
|
||||
className="font-base"></Badge>
|
||||
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
survey.status === "draft"
|
||||
? `/environments/${environmentId}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environmentId}/surveys/${survey.id}/summary`
|
||||
}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
{(survey.type === "link" || environment.widgetSetupCompleted) && (
|
||||
<SurveyStatusIndicator status={survey.status} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
</div>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
singleUseId={singleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<UsageAttributesUpdater numSurveys={surveys.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,72 @@
|
||||
import WidgetStatusIndicator from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import ContentWrapper from "@formbricks/ui/ContentWrapper";
|
||||
|
||||
import SurveysList from "./components/SurveyList";
|
||||
import SurveysList from "@formbricks/ui/SurveysList";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your Surveys",
|
||||
};
|
||||
|
||||
export default async function SurveysPage({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
const team = await getTeamByEnvironmentId(params.environmentId);
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const surveys = await getSurveys(params.environmentId);
|
||||
|
||||
const environments = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
|
||||
|
||||
return (
|
||||
<ContentWrapper className="flex h-full flex-col justify-between">
|
||||
<SurveysList environmentId={params.environmentId} />
|
||||
{surveys.length > 0 ? (
|
||||
<SurveysList
|
||||
environment={environment}
|
||||
surveys={surveys}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
userId={session.user.id}
|
||||
/>
|
||||
) : (
|
||||
<SurveyStarter
|
||||
environmentId={params.environmentId}
|
||||
environment={environment}
|
||||
product={product}
|
||||
user={session.user}
|
||||
/>
|
||||
)}
|
||||
{/* <SurveysList environmentId={params.environmentId} /> */}
|
||||
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
|
||||
</ContentWrapper>
|
||||
);
|
||||
|
||||
@@ -15,5 +15,5 @@ export async function createSurveyAction(environmentId: string, surveyBody: TSur
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await createSurvey(environmentId, surveyBody);
|
||||
return await createSurvey(environmentId, surveyBody, session.user.id);
|
||||
}
|
||||
|
||||
@@ -2505,6 +2505,7 @@ export const minimalSurvey: TSurvey = {
|
||||
name: "Minimal Survey",
|
||||
type: "web",
|
||||
environmentId: "someEnvId1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
attributeFilters: [],
|
||||
displayOption: "displayOnce",
|
||||
|
||||
@@ -112,6 +112,14 @@ input[type="search"]::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.surveyFilterDropdown[data-state="open"]{
|
||||
background-color: #0f172a;
|
||||
color: white;
|
||||
}
|
||||
.surveyFilterDropdown:hover * {
|
||||
background-color: #0f172a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
background: #0f172a;
|
||||
|
||||
@@ -70,8 +70,7 @@ test.describe("JS Package Test", async () => {
|
||||
test("Admin checks Display", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
|
||||
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
|
||||
|
||||
await page.getByRole("link", { name: "In-app Open options Product" }).click();
|
||||
(await page.waitForSelector("text=Responses")).isVisible();
|
||||
|
||||
// Survey should have 1 Display
|
||||
@@ -122,8 +121,7 @@ test.describe("JS Package Test", async () => {
|
||||
test("Admin validates Response", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
|
||||
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
|
||||
|
||||
await page.getByRole("link", { name: "In-app Open options Product" }).click();
|
||||
(await page.waitForSelector("text=Responses")).isVisible();
|
||||
|
||||
// Survey should have 2 Displays
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "createdBy" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -257,6 +257,8 @@ model Survey {
|
||||
type SurveyType @default(web)
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
creator User? @relation(fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
createdBy String?
|
||||
status SurveyStatus @default(draft)
|
||||
/// @zod.custom(imports.ZSurveyWelcomeCard)
|
||||
/// [SurveyWelcomeCard]
|
||||
@@ -564,6 +566,7 @@ model User {
|
||||
/// @zod.custom(imports.ZUserNotificationSettings)
|
||||
/// [UserNotificationSettings]
|
||||
notificationSettings Json @default("{}")
|
||||
surveys Survey[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const selectSurvey = {
|
||||
name: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
createdBy: true,
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
@@ -410,6 +411,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
revalidateSurveyByAttributeClassId([...newFilters, ...removedFilters]);
|
||||
}
|
||||
|
||||
surveyData.updatedAt = new Date();
|
||||
data = {
|
||||
...surveyData,
|
||||
...data,
|
||||
@@ -478,7 +480,11 @@ export async function deleteSurvey(surveyId: string) {
|
||||
return deletedSurvey;
|
||||
}
|
||||
|
||||
export const createSurvey = async (environmentId: string, surveyBody: TSurveyInput): Promise<TSurvey> => {
|
||||
export const createSurvey = async (
|
||||
environmentId: string,
|
||||
surveyBody: TSurveyInput,
|
||||
userId?: string
|
||||
): Promise<TSurvey> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
if (surveyBody.attributeFilters) {
|
||||
@@ -503,6 +509,11 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...data,
|
||||
creator: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
@@ -525,7 +536,7 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
return transformedSurvey;
|
||||
};
|
||||
|
||||
export const duplicateSurvey = async (environmentId: string, surveyId: string) => {
|
||||
export const duplicateSurvey = async (environmentId: string, surveyId: string, userId: string) => {
|
||||
validateInputs([environmentId, ZId], [surveyId, ZId]);
|
||||
const existingSurvey = await getSurvey(surveyId);
|
||||
|
||||
@@ -546,6 +557,7 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
createdBy: undefined,
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
@@ -563,6 +575,11 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage
|
||||
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
|
||||
: Prisma.JsonNull,
|
||||
|
||||
@@ -136,6 +136,7 @@ export const mockSurveyOutput: SurveyMock = {
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
displayPercentage: null,
|
||||
createdBy: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
...baseSurveyProperties,
|
||||
@@ -159,6 +160,7 @@ export const updateSurveyInput: TSurvey = {
|
||||
styling: null,
|
||||
singleUse: null,
|
||||
displayPercentage: null,
|
||||
createdBy: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
...commonMockProperties,
|
||||
|
||||
@@ -260,7 +260,7 @@ describe("Tests for duplicateSurvey", () => {
|
||||
it("Duplicates a survey successfully", async () => {
|
||||
prismaMock.survey.findUnique.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
|
||||
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
|
||||
const createdSurvey = await duplicateSurvey(mockId, mockId);
|
||||
const createdSurvey = await duplicateSurvey(mockId, mockId, mockId);
|
||||
expect(createdSurvey).toEqual(mockSurveyWithAttributesOutput);
|
||||
});
|
||||
});
|
||||
@@ -270,13 +270,13 @@ describe("Tests for duplicateSurvey", () => {
|
||||
|
||||
it("Throws ResourceNotFoundError if the survey does not exist", async () => {
|
||||
prismaMock.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId));
|
||||
await expect(duplicateSurvey(mockId, mockId)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
it("should throw an error if there is an unknown error", async () => {
|
||||
const mockErrorMessage = "Unknown error occurred";
|
||||
prismaMock.survey.create.mockRejectedValue(new Error(mockErrorMessage));
|
||||
await expect(duplicateSurvey(mockId, mockId)).rejects.toThrow(Error);
|
||||
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,3 +230,21 @@ export const deleteUser = async (id: string): Promise<TUser> => {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const userIdRelatedToApiKey = async (apiKey: string) => {
|
||||
const userId = await prisma.apiKey.findUnique({
|
||||
where: { id: apiKey },
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
people: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return userId;
|
||||
};
|
||||
|
||||
40
packages/lib/utils/singleUseSurveys.ts
Normal file
40
packages/lib/utils/singleUseSurveys.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import cuid2 from "@paralleldrive/cuid2";
|
||||
|
||||
import { decryptAES128, symmetricDecrypt, symmetricEncrypt } from "../../lib/crypto";
|
||||
import { env } from "../../lib/env.mjs";
|
||||
|
||||
// generate encrypted single use id for the survey
|
||||
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
|
||||
const cuid = cuid2.createId();
|
||||
if (!isEncrypted) {
|
||||
return cuid;
|
||||
}
|
||||
|
||||
const encryptedCuid = symmetricEncrypt(cuid, env.ENCRYPTION_KEY);
|
||||
return encryptedCuid;
|
||||
};
|
||||
|
||||
// validate the survey single use id
|
||||
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
|
||||
try {
|
||||
let decryptedCuid: string | null = null;
|
||||
|
||||
if (surveySingleUseId.length === 64) {
|
||||
if (!env.FORMBRICKS_ENCRYPTION_KEY) {
|
||||
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
|
||||
}
|
||||
|
||||
decryptedCuid = decryptAES128(env.FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId);
|
||||
} else {
|
||||
decryptedCuid = symmetricDecrypt(surveySingleUseId, env.ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
if (cuid2.isCuid(decryptedCuid)) {
|
||||
return decryptedCuid;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -416,6 +416,7 @@ export const ZSurvey = z.object({
|
||||
name: z.string(),
|
||||
type: ZSurveyType,
|
||||
environmentId: z.string(),
|
||||
createdBy: z.string().nullable(),
|
||||
status: ZSurveyStatus,
|
||||
attributeFilters: z.array(ZSurveyAttributeFilter),
|
||||
displayOption: ZSurveyDisplayOption,
|
||||
|
||||
@@ -63,7 +63,7 @@ export const Button: React.ForwardRefExoticComponent<
|
||||
// different styles depending on size
|
||||
size === "sm" && "px-3 py-2 text-sm leading-4 font-medium rounded-md",
|
||||
size === "base" && "px-6 py-3 text-sm font-medium rounded-md",
|
||||
size === "lg" && "px-4 py-2 text-base font-medium rounded-md",
|
||||
size === "lg" && "px-8 py-4 text-base font-medium rounded-md",
|
||||
size === "icon" &&
|
||||
"w-10 h-10 justify-center group p-2 border rounded-lg border-transparent text-neutral-400 hover:border-slate-200 transition",
|
||||
// turn button into a floating action button (fab)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";
|
||||
import { CheckIcon, PauseIcon, PencilIcon } from "lucide-react";
|
||||
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../Tooltip";
|
||||
|
||||
interface SurveyStatusIndicatorProps {
|
||||
status: string;
|
||||
status: TSurvey["status"];
|
||||
tooltip?: boolean;
|
||||
}
|
||||
|
||||
@@ -31,9 +33,9 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
|
||||
<CheckIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
{status === "archived" && (
|
||||
<div className=" rounded-full bg-slate-300 p-1">
|
||||
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
|
||||
{status === "draft" && (
|
||||
<div className=" rounded-full bg-slate-200 p-1">
|
||||
<CheckIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
@@ -61,13 +63,6 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
|
||||
<CheckIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
</div>
|
||||
) : status === "archived" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Survey archived.</span>
|
||||
<div className=" rounded-full bg-slate-300 p-1">
|
||||
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@@ -84,18 +79,18 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
|
||||
</span>
|
||||
)}
|
||||
{status === "paused" && (
|
||||
<div className=" rounded-full bg-slate-300 p-1">
|
||||
<div className="rounded-full bg-slate-300 p-1">
|
||||
<PauseIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
{status === "completed" && (
|
||||
<div className=" rounded-full bg-slate-200 p-1">
|
||||
<div className="rounded-full bg-slate-200 p-1">
|
||||
<CheckIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
{status === "archived" && (
|
||||
<div className=" rounded-full bg-slate-300 p-1">
|
||||
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
|
||||
{status === "draft" && (
|
||||
<div className="rounded-full bg-slate-300 p-1">
|
||||
<PencilIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
|
||||
211
packages/ui/SurveysList/actions.ts
Normal file
211
packages/ui/SurveysList/actions.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
"use server";
|
||||
|
||||
import { Prisma as prismaClient } from "@prisma/client/";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { generateSurveySingleUseId } from "@formbricks/lib/utils/singleUseSurveys";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
import { prisma } from "../../database/src";
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId, session.user.id);
|
||||
return duplicatedSurvey;
|
||||
}
|
||||
|
||||
export async function copyToOtherEnvironmentAction(
|
||||
environmentId: string,
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
environmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
targetEnvironmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
},
|
||||
include: {
|
||||
triggers: {
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
},
|
||||
attributeFilters: {
|
||||
include: {
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
let targetEnvironmentTriggers: string[] = [];
|
||||
// map the local triggers to the target environment
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentTrigger) {
|
||||
// if the trigger does not exist in the target environment, create it
|
||||
const newTrigger = await prisma.actionClass.create({
|
||||
data: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
description: trigger.actionClass.description,
|
||||
type: trigger.actionClass.type,
|
||||
noCodeConfig: trigger.actionClass.noCodeConfig
|
||||
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
} else {
|
||||
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
|
||||
}
|
||||
}
|
||||
|
||||
let targetEnvironmentAttributeFilters: string[] = [];
|
||||
// map the local attributeFilters to the target env
|
||||
for (const attributeFilter of existingSurvey.attributeFilters) {
|
||||
// check if attributeClass exists in target env.
|
||||
// if not, create it
|
||||
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentAttributeClass) {
|
||||
const newAttributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
description: attributeFilter.attributeClass.description,
|
||||
type: attributeFilter.attributeClass.type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
|
||||
} else {
|
||||
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
|
||||
}
|
||||
}
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
createdBy: undefined,
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((actionClassId) => ({
|
||||
actionClassId: actionClassId,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
|
||||
attributeClassId: targetEnvironmentAttributeFilters[idx],
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
connect: {
|
||||
id: session.user.id,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
styling: existingSurvey.styling ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
|
||||
surveyCache.revalidate({
|
||||
id: newSurvey.id,
|
||||
environmentId: targetEnvironmentId,
|
||||
});
|
||||
return newSurvey;
|
||||
}
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
|
||||
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
export async function generateSingleUseIdAction(surveyId: string, isEncrypted: boolean): Promise<string> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
|
||||
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return generateSurveySingleUseId(isEncrypted);
|
||||
}
|
||||
156
packages/ui/SurveysList/components/SurveyCard.tsx
Normal file
156
packages/ui/SurveysList/components/SurveyCard.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Code, Link2Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { SurveyStatusIndicator } from "../../SurveyStatusIndicator";
|
||||
import { generateSingleUseIdAction } from "../actions";
|
||||
import SurveyDropDownMenu from "./SurveyDropdownMenu";
|
||||
|
||||
interface SurveyCardProps {
|
||||
survey: TSurvey;
|
||||
environment: TEnvironment;
|
||||
otherEnvironment: TEnvironment;
|
||||
isViewer: boolean;
|
||||
WEBAPP_URL: string;
|
||||
orientation: string;
|
||||
}
|
||||
export default function SurveyCard({
|
||||
survey,
|
||||
environment,
|
||||
otherEnvironment,
|
||||
isViewer,
|
||||
WEBAPP_URL,
|
||||
orientation,
|
||||
}: SurveyCardProps) {
|
||||
const isSurveyCreationDeletionDisabled = isViewer;
|
||||
|
||||
const surveyStatusLabel = useMemo(() => {
|
||||
if (survey.status === "inProgress") return "Active";
|
||||
else if (survey.status === "completed") return "Completed";
|
||||
else if (survey.status === "draft") return "Draft";
|
||||
else if (survey.status === "paused") return "Paused";
|
||||
}, [survey]);
|
||||
|
||||
const [singleUseId, setSingleUseId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.singleUse?.enabled) {
|
||||
generateSingleUseIdAction(survey.id, survey.singleUse?.isEncrypted ? true : false).then(setSingleUseId);
|
||||
} else {
|
||||
setSingleUseId(undefined);
|
||||
}
|
||||
}, [survey]);
|
||||
|
||||
const linkHref = useMemo(() => {
|
||||
return survey.status === "draft"
|
||||
? `/environments/${environment.id}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environment.id}/surveys/${survey.id}/summary`;
|
||||
}, [survey.status, survey.id, environment.id]);
|
||||
|
||||
const SurveyTypeIndicator = ({ type }: { type: string }) => (
|
||||
<div className="flex items-center space-x-2 text-sm text-slate-600">
|
||||
{type === "web" ? (
|
||||
<>
|
||||
<Code className="h-4 w-4" />
|
||||
<span> In-app</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
<span> Link</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderGridContent = () => {
|
||||
return (
|
||||
<Link
|
||||
href={linkHref}
|
||||
key={survey.id}
|
||||
className="relative col-span-2 flex h-44 flex-col justify-between rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition-all ease-in-out hover:scale-105 ">
|
||||
<div className="flex justify-between">
|
||||
<SurveyTypeIndicator type={survey.type} />
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environment.id}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
singleUseId={singleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900">{survey.name}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 flex w-fit items-center gap-2 rounded-full py-1 pl-1 pr-2 text-xs text-slate-800",
|
||||
surveyStatusLabel === "Active" && "bg-emerald-50",
|
||||
surveyStatusLabel === "Completed" && "bg-slate-200",
|
||||
surveyStatusLabel === "Draft" && "bg-slate-100",
|
||||
surveyStatusLabel === "Paused" && "bg-slate-100"
|
||||
)}>
|
||||
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const renderListContent = () => {
|
||||
return (
|
||||
<Link
|
||||
href={linkHref}
|
||||
key={survey.id}
|
||||
className="relative grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4
|
||||
shadow-sm transition-all ease-in-out hover:scale-[101%]">
|
||||
<div className="col-span-2 flex items-center justify-self-start overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-slate-900">
|
||||
{survey.name}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
|
||||
surveyStatusLabel === "Active" && "bg-emerald-50",
|
||||
surveyStatusLabel === "Completed" && "bg-slate-200",
|
||||
surveyStatusLabel === "Draft" && "bg-slate-100",
|
||||
surveyStatusLabel === "Paused" && "bg-slate-100"
|
||||
)}>
|
||||
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<SurveyTypeIndicator type={survey.type} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-4 grid w-full grid-cols-5 place-items-center">
|
||||
<div className="col-span-2 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{timeSince(survey.createdAt.toString())}
|
||||
</div>
|
||||
<div className="col-span-2 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{timeSince(survey.updatedAt.toString())}
|
||||
</div>
|
||||
<div className="place-self-end">
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environment.id}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
singleUseId={singleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
if (orientation === "grid") return renderGridContent();
|
||||
else return renderListContent();
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
copyToOtherEnvironmentAction,
|
||||
deleteSurveyAction,
|
||||
duplicateSurveyAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import {
|
||||
ArrowUpOnSquareStackIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -21,15 +16,17 @@ import toast from "react-hot-toast";
|
||||
|
||||
import type { TEnvironment } from "@formbricks/types/environment";
|
||||
import type { TSurvey } from "@formbricks/types/surveys";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
|
||||
import { DeleteDialog } from "../../DeleteDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
|
||||
} from "../../DropdownMenu";
|
||||
import LoadingSpinner from "../../LoadingSpinner";
|
||||
import { copyToOtherEnvironmentAction, deleteSurveyAction, duplicateSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
environmentId: string;
|
||||
@@ -52,11 +49,12 @@ export default function SurveyDropDownMenu({
|
||||
}: SurveyDropDownMenuProps) {
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const surveyUrl = useMemo(() => webAppUrl + "/s/" + survey.id, [survey.id, webAppUrl]);
|
||||
|
||||
const handleDeleteSurvey = async (survey) => {
|
||||
const handleDeleteSurvey = async (survey: TSurvey) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction(survey.id);
|
||||
@@ -69,7 +67,7 @@ export default function SurveyDropDownMenu({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRefresh = async (surveyId) => {
|
||||
const duplicateSurveyAndRefresh = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await duplicateSurveyAction(environmentId, surveyId);
|
||||
@@ -81,7 +79,7 @@ export default function SurveyDropDownMenu({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copyToOtherEnvironment = async (surveyId) => {
|
||||
const copyToOtherEnvironment = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
|
||||
@@ -105,11 +103,11 @@ export default function SurveyDropDownMenu({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div>
|
||||
<div className="rounded-lg border p-2 hover:bg-slate-50">
|
||||
<span className="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
@@ -129,7 +127,9 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async () => {
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
duplicateSurveyAndRefresh(survey.id);
|
||||
}}>
|
||||
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
|
||||
@@ -145,7 +145,9 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
@@ -157,7 +159,9 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
@@ -170,23 +174,27 @@ export default function SurveyDropDownMenu({
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={
|
||||
singleUseId
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
const previewUrl = singleUseId
|
||||
? `/s/${survey.id}?suId=${singleUseId}&preview=true`
|
||||
: `/s/${survey.id}?preview=true`
|
||||
}
|
||||
target="_blank">
|
||||
: `/s/${survey.id}?preview=true`;
|
||||
window.open(previewUrl, "_blank");
|
||||
}}>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
</Link>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
navigator.clipboard.writeText(
|
||||
singleUseId ? `${surveyUrl}?suId=${singleUseId}` : surveyUrl
|
||||
);
|
||||
@@ -204,7 +212,9 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
307
packages/ui/SurveysList/components/SurveyFilters.tsx
Normal file
307
packages/ui/SurveysList/components/SurveyFilters.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { ChevronDownIcon, Equal, Grid2X2, Search, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Button } from "../../Button";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../DropdownMenu";
|
||||
import { TooltipRenderer } from "../../Tooltip";
|
||||
|
||||
interface SurveyFilterProps {
|
||||
surveys: TSurvey[];
|
||||
setFilteredSurveys: (surveys: TSurvey[]) => void;
|
||||
orientation: string;
|
||||
setOrientation: (orientation: string) => void;
|
||||
userId: string;
|
||||
}
|
||||
interface TFilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
interface TSortOption {
|
||||
label: string;
|
||||
sortFunction: (a: TSurvey, b: TSurvey) => number;
|
||||
}
|
||||
|
||||
interface FilterDropdownProps {
|
||||
title: string;
|
||||
id: string;
|
||||
options: TFilterOption[];
|
||||
selectedOptions: string[];
|
||||
setSelectedOptions: (options: string[]) => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "In Progress", value: "inProgress" },
|
||||
{ label: "Paused", value: "paused" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
{ label: "Draft", value: "draft" },
|
||||
];
|
||||
const typeOptions = [
|
||||
{ label: "Link", value: "link" },
|
||||
{ label: "In-app", value: "web" },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{
|
||||
label: "Last Modified",
|
||||
sortFunction: (a: TSurvey, b: TSurvey) => {
|
||||
const dateA = new Date(a.updatedAt);
|
||||
const dateB = new Date(b.updatedAt);
|
||||
if (!isNaN(dateA.getTime()) && !isNaN(dateB.getTime())) {
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Created On",
|
||||
sortFunction: (a: TSurvey, b: TSurvey) => {
|
||||
const dateA = new Date(a.createdAt);
|
||||
const dateB = new Date(b.createdAt);
|
||||
if (!isNaN(dateA.getTime()) && !isNaN(dateB.getTime())) {
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Alphabetical",
|
||||
sortFunction: (a: TSurvey, b: TSurvey) => a.name.localeCompare(b.name),
|
||||
},
|
||||
// Add other sorting options as needed
|
||||
];
|
||||
|
||||
const getToolTipContent = (orientation: string) => {
|
||||
return <div>{orientation} View</div>;
|
||||
};
|
||||
|
||||
export default function SurveyFilters({
|
||||
surveys,
|
||||
setFilteredSurveys,
|
||||
orientation,
|
||||
setOrientation,
|
||||
userId,
|
||||
}: SurveyFilterProps) {
|
||||
const [createdByFilter, setCreatedByFilter] = useState<string[]>([]);
|
||||
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
||||
const [typeFilters, setTypeFilters] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState(sortOptions[0]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dropdownOpenStates, setDropdownOpenStates] = useState(new Map());
|
||||
|
||||
const toggleDropdown = (id: string) => {
|
||||
setDropdownOpenStates(new Map(dropdownOpenStates).set(id, !dropdownOpenStates.get(id)));
|
||||
};
|
||||
|
||||
const creatorOptions = [
|
||||
{ label: "You", value: userId },
|
||||
{ label: "Others", value: "other" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = [...surveys];
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter((survey) => survey.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}
|
||||
|
||||
if (createdByFilter.length > 0) {
|
||||
filtered = filtered.filter((survey) => {
|
||||
if (survey.createdBy) {
|
||||
if (createdByFilter.length === 2) return true;
|
||||
if (createdByFilter.includes("other")) return survey.createdBy !== userId;
|
||||
else {
|
||||
return survey.createdBy === userId;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (statusFilters.length > 0) {
|
||||
filtered = filtered.filter((survey) => statusFilters.includes(survey.status));
|
||||
}
|
||||
if (typeFilters.length > 0) {
|
||||
filtered = filtered.filter((survey) => typeFilters.includes(survey.type));
|
||||
}
|
||||
if (sortBy && sortBy.sortFunction) {
|
||||
filtered.sort(sortBy.sortFunction);
|
||||
}
|
||||
|
||||
setFilteredSurveys(filtered);
|
||||
}, [createdByFilter, statusFilters, typeFilters, sortBy, searchTerm, surveys]);
|
||||
|
||||
const handleFilterChange = (
|
||||
value: string,
|
||||
selectedOptions: string[],
|
||||
setSelectedOptions: (options: string[]) => void
|
||||
) => {
|
||||
if (selectedOptions.includes(value)) {
|
||||
setSelectedOptions(selectedOptions.filter((option) => option !== value));
|
||||
} else {
|
||||
setSelectedOptions([...selectedOptions, value]);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSortOption = (option: TSortOption) => (
|
||||
<DropdownMenuItem
|
||||
key={option.label}
|
||||
className="m-0 p-0"
|
||||
onClick={() => {
|
||||
setSortBy(option);
|
||||
}}>
|
||||
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
|
||||
<span
|
||||
className={`h-4 w-4 rounded-full border ${sortBy === option ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
|
||||
<p className="font-normal text-white">{option.label}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
const FilterDropdown = ({
|
||||
title,
|
||||
id,
|
||||
options,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
isOpen,
|
||||
}: FilterDropdownProps) => {
|
||||
const triggerClasses = `surveyFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none
|
||||
${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900"}`;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={() => toggleDropdown(id)}>
|
||||
<DropdownMenuTrigger asChild className={triggerClasses}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="text-sm">{title}</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-slate-900">
|
||||
{options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
className="m-0 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleFilterChange(option.value, selectedOptions, setSelectedOptions);
|
||||
}}>
|
||||
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
|
||||
<Checkbox
|
||||
checked={selectedOptions.includes(option.value)}
|
||||
className={`bg-white ${selectedOptions.includes(option.value) ? "bg-brand-dark border-none" : ""}`}
|
||||
/>
|
||||
<p className="font-normal text-white">{option.label}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex h-8 items-center rounded-lg border border-slate-300 bg-white px-4">
|
||||
<Search className="h-4 w-4" />
|
||||
<input
|
||||
type="text"
|
||||
className="border-none bg-transparent placeholder:text-sm"
|
||||
placeholder="Search by survey name"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FilterDropdown
|
||||
title="Created By"
|
||||
id="creatorDropdown"
|
||||
options={creatorOptions}
|
||||
selectedOptions={createdByFilter}
|
||||
setSelectedOptions={setCreatedByFilter}
|
||||
isOpen={dropdownOpenStates.get("creatorDropdown")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FilterDropdown
|
||||
title="Status"
|
||||
id="statusDropdown"
|
||||
options={statusOptions}
|
||||
selectedOptions={statusFilters}
|
||||
setSelectedOptions={setStatusFilters}
|
||||
isOpen={dropdownOpenStates.get("statusDropdown")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FilterDropdown
|
||||
title="Type"
|
||||
id="typeDropdown"
|
||||
options={typeOptions}
|
||||
selectedOptions={typeFilters}
|
||||
setSelectedOptions={setTypeFilters}
|
||||
isOpen={dropdownOpenStates.get("typeDropdown")}
|
||||
/>
|
||||
</div>
|
||||
{(createdByFilter.length > 0 || statusFilters.length > 0 || typeFilters.length > 0) && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatedByFilter([]);
|
||||
setStatusFilters([]);
|
||||
setTypeFilters([]);
|
||||
}}
|
||||
className="h-8"
|
||||
EndIcon={X}
|
||||
endIconClassName="h-4 w-4">
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<TooltipRenderer
|
||||
shouldRender={true}
|
||||
tooltipContent={getToolTipContent("List")}
|
||||
className="bg-slate-900 text-white">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${orientation === "list" ? "bg-slate-900 text-white" : "bg-white"}`}
|
||||
onClick={() => setOrientation("list")}>
|
||||
<Equal className="h-5 w-5" />
|
||||
</div>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer
|
||||
shouldRender={true}
|
||||
tooltipContent={getToolTipContent("Grid")}
|
||||
className="bg-slate-900 text-white">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${orientation === "grid" ? "bg-slate-900 text-white" : "bg-white"}`}
|
||||
onClick={() => setOrientation("grid")}>
|
||||
<Grid2X2 className="h-5 w-5" />
|
||||
</div>
|
||||
</TooltipRenderer>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className="surveyFilterDropdown h-full cursor-pointer border border-slate-700 outline-none hover:bg-slate-900">
|
||||
<div className="min-w-auto h-8 rounded-md border sm:flex sm:px-2">
|
||||
<div className="hidden w-full items-center justify-between hover:text-white sm:flex">
|
||||
<span className="text-sm ">Sort by: {sortBy.label}</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-slate-900 ">
|
||||
{sortOptions.map(renderSortOption)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
packages/ui/SurveysList/index.tsx
Normal file
101
packages/ui/SurveysList/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Button } from "../v2/Button";
|
||||
import SurveyCard from "./components/SurveyCard";
|
||||
import SurveyFilters from "./components/SurveyFilters";
|
||||
|
||||
interface SurveysListProps {
|
||||
environment: TEnvironment;
|
||||
surveys: TSurvey[];
|
||||
otherEnvironment: TEnvironment;
|
||||
isViewer: boolean;
|
||||
WEBAPP_URL: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export default function SurveysList({
|
||||
environment,
|
||||
surveys,
|
||||
otherEnvironment,
|
||||
isViewer,
|
||||
WEBAPP_URL,
|
||||
userId,
|
||||
}: SurveysListProps) {
|
||||
const [filteredSurveys, setFilteredSurveys] = useState<TSurvey[]>(surveys);
|
||||
const [orientation, setOrientation] = useState("grid");
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="my-2 text-3xl font-bold text-slate-800">Surveys</h1>
|
||||
<Button
|
||||
href={`/environments/${environment.id}/surveys/templates`}
|
||||
variant="darkCTA"
|
||||
EndIcon={PlusIcon}>
|
||||
New survey
|
||||
</Button>
|
||||
</div>
|
||||
<SurveyFilters
|
||||
surveys={surveys}
|
||||
setFilteredSurveys={setFilteredSurveys}
|
||||
orientation={orientation}
|
||||
setOrientation={setOrientation}
|
||||
userId={userId}
|
||||
/>
|
||||
{filteredSurveys.length > 0 ? (
|
||||
<div>
|
||||
{orientation === "list" && (
|
||||
<div className="flex-col space-y-3">
|
||||
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 text-sm text-slate-800">
|
||||
<div className="col-span-4 place-self-start">Name</div>
|
||||
<div className="col-span-4 grid w-full grid-cols-5 place-items-center">
|
||||
<div className="col-span-2">Created at</div>
|
||||
<div className="col-span-2">Updated at</div>
|
||||
</div>
|
||||
</div>
|
||||
{filteredSurveys.map((survey) => {
|
||||
return (
|
||||
<SurveyCard
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
orientation={orientation}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{orientation === "grid" && (
|
||||
<div className="grid grid-cols-4 place-content-stretch gap-4 lg:grid-cols-6 ">
|
||||
{filteredSurveys.map((survey) => {
|
||||
return (
|
||||
<SurveyCard
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
orientation={orientation}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<span className="mb-4 h-24 w-24 rounded-full bg-slate-100 p-6 text-5xl">🕵️</span>
|
||||
|
||||
<div className="text-slate-600">No surveys found</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
@@ -31,3 +32,25 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
interface TooltipRendererProps {
|
||||
shouldRender: boolean;
|
||||
tooltipContent: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export function TooltipRenderer(props: TooltipRendererProps) {
|
||||
const { children, shouldRender, tooltipContent, className } = props;
|
||||
if (shouldRender) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{children}</TooltipTrigger>
|
||||
<TooltipContent className={className}>{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user