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:
Dhruwang Jariwala
2024-02-01 13:21:21 +05:30
committed by GitHub
parent 70fe0fb7a7
commit 1402f4a48b
25 changed files with 1011 additions and 392 deletions

View File

@@ -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");

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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} />
</>
);
}

View File

@@ -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>
);

View File

@@ -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);
}

View File

@@ -2505,6 +2505,7 @@ export const minimalSurvey: TSurvey = {
name: "Minimal Survey",
type: "web",
environmentId: "someEnvId1",
createdBy: null,
status: "draft",
attributeFilters: [],
displayOption: "displayOnce",

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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])
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
});
});
});

View File

@@ -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;
};

View 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;
}
};

View File

@@ -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,

View File

@@ -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)

View File

@@ -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>

View 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);
}

View 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();
}

View File

@@ -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" />

View 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>
);
}

View 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>
);
}

View File

@@ -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}</>;
}