sync with main

This commit is contained in:
Piyush Gupta
2023-09-19 21:18:56 +05:30
189 changed files with 5972 additions and 1916 deletions

View File

@@ -1,3 +1,4 @@
/*
########################################################################
# ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------#
########################################################################
@@ -105,3 +106,4 @@ NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Cron Secret
CRON_SECRET=
*/

View File

@@ -0,0 +1,29 @@
import { Fence } from "@/components/shared/Fence";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
export const meta = {
title: "n8n Setup",
description: "Wire up Formbricks with n8n and 350+ other apps",
};
#### Integrations
# Google Sheets
The Google Sheets integration allows you to automatically send responses to a Google Sheet of your choice.
<Note>
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
self-hosted version of Formbricks.
</Note>
## Setup in self-hosted Formbricks
Enabling the Google Sheets Integration in a self-hosted environment isn't easy and requires a setup using Google Cloud and changing the environment variables of your Formbricks instance.
The environment variables you need to set are:
- `GOOGLE_SHEETS_CLIENT_ID`
- `GOOGLE_SHEETS_CLIENT_SECRET`
- `GOOGLE_SHEETS_REDIRECT_URL`

View File

@@ -229,6 +229,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Zapier", href: "/docs/integrations/zapier" },
{ title: "n8n", href: "/docs/integrations/n8n" },
{ title: "Make.com", href: "/docs/integrations/make" },
{ title: "Google Sheets", href: "/docs/integrations/google-sheets" },
],
},
{

View File

@@ -264,11 +264,11 @@ export default function Header() {
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
Blog {/* <p className="bg-brand inline rounded-full px-2 text-xs text-white">1</p> */}
</Link>
<Link
{/* <Link
href="/careers"
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
Careers <p className="bg-brand inline rounded-full px-2 text-xs text-white">1</p>
</Link>
</Link> */}
<Link
href="/concierge"
@@ -372,7 +372,7 @@ export default function Header() {
<Link href="#pricing">Pricing</Link>
<Link href="/docs">Docs</Link>
<Link href="/blog">Blog</Link>
<Link href="/careers">Careers</Link>
{/* <Link href="/careers">Careers</Link> */}
<Button
variant="secondary"
EndIcon={GitHubIcon}

View File

@@ -93,6 +93,17 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
"Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
href: "https://openbb.co",
},
{
name: "OpenStatus",
description: "Open-source monitoring platform with beautiful status pages",
href: "https://www.openstatus.dev",
},
{
name: "Requestly",
description:
"Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.",
href: "https://requestly.io",
},
{
name: "Rivet",
description: "Open-source solution to deploy, scale, and operate your multiplayer game.",

3
apps/web/.gitignore vendored
View File

@@ -37,3 +37,6 @@ next-env.d.ts
# Sentry Auth Token
.sentryclirc
# Google Sheets Token File
token.json

View File

@@ -8,8 +8,11 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { testURLmatch } from "./testURLmatch";
import { createActionClass } from "@formbricks/lib/services/actionClass";
import { TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
import { useRouter } from "next/navigation";
import {
TActionClassInput,
TActionClassNoCodeConfig,
TActionClass,
} from "@formbricks/types/v1/actionClasses";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector";
@@ -18,10 +21,15 @@ interface AddNoCodeActionModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
setActionClassArray?;
}
export default function AddNoCodeActionModal({ environmentId, open, setOpen }: AddNoCodeActionModalProps) {
const router = useRouter();
export default function AddNoCodeActionModal({
environmentId,
open,
setOpen,
setActionClassArray,
}: AddNoCodeActionModalProps) {
const { register, control, handleSubmit, watch, reset } = useForm();
const [isPageUrl, setIsPageUrl] = useState(false);
const [isCssSelector, setIsCssSelector] = useState(false);
@@ -75,8 +83,13 @@ export default function AddNoCodeActionModal({ environmentId, open, setOpen }: A
type: "noCode",
} as TActionClassInput;
await createActionClass(environmentId, updatedData);
router.refresh();
const newActionClass: TActionClass = await createActionClass(environmentId, updatedData);
if (setActionClassArray) {
setActionClassArray((prevActionClassArray: TActionClass[]) => [
...prevActionClassArray,
newActionClass,
]);
}
reset();
resetAllStates(false);
toast.success("Action added successfully.");

View File

@@ -0,0 +1,326 @@
import { TSurvey } from "@formbricks/types/v1/surveys";
import {
TGoogleSheetIntegration,
TGoogleSheetsConfigData,
TGoogleSpreadsheet,
} from "@formbricks/types/v1/integrations";
import { Button, Checkbox, Label } from "@formbricks/ui";
import GoogleSheetLogo from "@/images/google-sheets-small.png";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import Image from "next/image";
import Modal from "@/components/shared/Modal";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { upsertIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
interface AddWebhookModalProps {
environmentId: string;
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
spreadsheets: TGoogleSpreadsheet[];
googleSheetIntegration: TGoogleSheetIntegration;
selectedIntegration?: (TGoogleSheetsConfigData & { index: number }) | null;
}
export default function AddIntegrationModal({
environmentId,
surveys,
open,
setOpen,
spreadsheets,
googleSheetIntegration,
selectedIntegration,
}: AddWebhookModalProps) {
const { handleSubmit } = useForm();
const integrationData = {
spreadsheetId: "",
spreadsheetName: "",
surveyId: "",
surveyName: "",
questionIds: [""],
questions: "",
createdAt: new Date(),
};
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
const [isLinkingSheet, setIsLinkingSheet] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [selectedSpreadsheet, setSelectedSpreadsheet] = useState<any>(null);
const [isDeleting, setIsDeleting] = useState<any>(null);
const existingIntegrationData = googleSheetIntegration?.config?.data;
const googleSheetIntegrationData: Partial<TGoogleSheetIntegration> = {
type: "googleSheets",
config: {
key: googleSheetIntegration?.config?.key,
email: googleSheetIntegration.config.email,
data: existingIntegrationData || [],
},
};
useEffect(() => {
if (selectedSurvey) {
const questionIds = selectedSurvey.questions.map((question) => question.id);
if (!selectedIntegration) {
setSelectedQuestions(questionIds);
}
}
}, [selectedSurvey]);
useEffect(() => {
if (selectedIntegration) {
setSelectedSpreadsheet({
id: selectedIntegration.spreadsheetId,
name: selectedIntegration.spreadsheetName,
});
setSelectedSurvey(
surveys.find((survey) => {
return survey.id === selectedIntegration.surveyId;
})!
);
setSelectedQuestions(selectedIntegration.questionIds);
return;
}
resetForm();
}, [selectedIntegration]);
const linkSheet = async () => {
try {
if (!selectedSpreadsheet) {
throw new Error("Please select a spreadsheet");
}
if (!selectedSurvey) {
throw new Error("Please select a survey");
}
if (selectedQuestions.length === 0) {
throw new Error("Please select at least one question");
}
setIsLinkingSheet(true);
integrationData.spreadsheetId = selectedSpreadsheet.id;
integrationData.spreadsheetName = selectedSpreadsheet.name;
integrationData.surveyId = selectedSurvey.id;
integrationData.surveyName = selectedSurvey.name;
integrationData.questionIds = selectedQuestions;
integrationData.questions =
selectedQuestions.length === selectedSurvey?.questions.length
? "All questions"
: "Selected questions";
integrationData.createdAt = new Date();
if (selectedIntegration) {
// update action
googleSheetIntegrationData.config!.data[selectedIntegration.index] = integrationData;
} else {
// create action
googleSheetIntegrationData.config!.data.push(integrationData);
}
await upsertIntegrationAction(environmentId, googleSheetIntegrationData);
toast.success(`Integration ${selectedIntegration ? "updated" : "added"} successfully`);
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
} finally {
setIsLinkingSheet(false);
}
};
const handleCheckboxChange = (questionId: string) => {
setSelectedQuestions((prevValues) =>
prevValues.includes(questionId)
? prevValues.filter((value) => value !== questionId)
: [...prevValues, questionId]
);
};
const setOpenWithStates = (isOpen: boolean) => {
setOpen(isOpen);
};
const resetForm = () => {
setIsLinkingSheet(false);
setSelectedSpreadsheet("");
setSelectedSurvey(null);
};
const deleteLink = async () => {
googleSheetIntegrationData.config!.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await upsertIntegrationAction(environmentId, googleSheetIntegrationData);
toast.success("Integration removed successfully");
setOpen(false);
} catch (error) {
toast.error(error.message);
} finally {
setIsDeleting(false);
}
};
const hasMatchingId = googleSheetIntegration.config.data.some((configData) => {
if (!selectedSpreadsheet) {
return false;
}
return configData.spreadsheetId === selectedSpreadsheet.id;
});
const DropdownSelector = ({ label, items, selectedItem, setSelectedItem, disabled }) => {
return (
<div className="col-span-1">
<Label htmlFor={label}>{label}</Label>
<div className="mt-1 flex">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
disabled={disabled ? disabled : false}
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300">
<span className="flex flex-1">
<span>{selectedItem ? selectedItem.name : `${label}`}</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
</span>
</button>
</DropdownMenu.Trigger>
{!disabled && (
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 min-w-[220px] rounded-md bg-white text-sm text-slate-800 shadow-md"
align="start">
{items &&
items.map((item) => (
<DropdownMenu.Item
key={item.id}
className="flex cursor-pointer items-center p-3 hover:bg-gray-100 hover:outline-none data-[disabled]:cursor-default data-[disabled]:opacity-50"
onSelect={() => setSelectedItem(item)}>
{item.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</DropdownMenu.Root>
</div>
</div>
);
};
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={GoogleSheetLogo} alt="Google Sheet logo" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Link Google Sheet</div>
<div className="text-sm text-slate-500">Sync responses with a Google Sheet</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkSheet)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<div className="mb-4">
<DropdownSelector
label="Select Spreadsheet"
items={spreadsheets}
selectedItem={selectedSpreadsheet}
setSelectedItem={setSelectedSpreadsheet}
disabled={spreadsheets.length === 0}
/>
{selectedSpreadsheet && hasMatchingId && (
<p className="text-xs text-amber-700">
<strong>Warning:</strong> You have already connected one survey with this sheet. Your
data will be inconsistent
</p>
)}
<p className="m-1 text-xs text-slate-500">
{spreadsheets.length === 0 &&
"You have to create at least one spreadshseet to be able to setup this integration"}
</p>
</div>
<div>
<DropdownSelector
label="Select Survey"
items={surveys}
selectedItem={selectedSurvey}
setSelectedItem={setSelectedSurvey}
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 &&
"You have to create a survey to be able to setup this integration"}
</p>
</div>
</div>
{selectedSurvey && (
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{selectedSurvey?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">{question.headline}</span>
</label>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="warn"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
Delete
</Button>
) : (
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
resetForm();
}}>
Cancel
</Button>
)}
<Button variant="darkCTA" type="submit" loading={isLinkingSheet}>
{selectedIntegration ? "Update" : "Link Sheet"}
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import GoogleSheetLogo from "@/images/google-sheets-small.png";
import FormbricksLogo from "@/images/logo.svg";
import { authorize } from "@formbricks/lib/client/google";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { Button } from "@formbricks/ui";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
interface ConnectProps {
enabled: boolean;
environmentId: string;
}
export default function Connect({ enabled, environmentId }: ConnectProps) {
const [isConnecting, setIsConnecting] = useState(false);
const handleGoogleLogin = async () => {
setIsConnecting(true);
authorize(environmentId, WEBAPP_URL).then((url: string) => {
if (url) {
window.location.replace(url);
}
});
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-1/2 flex-col items-center justify-center rounded-lg bg-white p-8 shadow">
<div className="flex w-1/2 justify-center -space-x-4">
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={FormbricksLogo} alt="Formbricks Logo" />
</div>
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={GoogleSheetLogo} alt="Google Sheet logo" />
</div>
</div>
<p className="my-8">Sync responses directly with Google Sheets.</p>
{!enabled && (
<p className="mb-8 rounded border-gray-200 bg-gray-100 p-3 text-sm">
Google Sheets Integration is not configured in your instance of Formbricks.
<br />
Please follow the{" "}
<Link href="https://formbricks.com/docs/integrations/google-sheets" className="underline">
docs
</Link>{" "}
to configure it.
</p>
)}
<Button variant="darkCTA" loading={isConnecting} onClick={handleGoogleLogin} disabled={!enabled}>
Connect with Google
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import Home from "./Home";
import Connect from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/Connect";
import AddIntegrationModal from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/AddIntegrationModal";
import {
TGoogleSheetIntegration,
TGoogleSheetsConfigData,
TGoogleSpreadsheet,
} from "@formbricks/types/v1/integrations";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { refreshSheetAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
interface GoogleSheetWrapperProps {
enabled: boolean;
environmentId: string;
surveys: TSurvey[];
spreadSheetArray: TGoogleSpreadsheet[];
googleSheetIntegration: TGoogleSheetIntegration | undefined;
}
export default function GoogleSheetWrapper({
enabled,
environmentId,
surveys,
spreadSheetArray,
googleSheetIntegration,
}: GoogleSheetWrapperProps) {
const [isConnected, setIsConnected] = useState(
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [spreadsheets, setSpreadsheets] = useState(spreadSheetArray);
const [isModalOpen, setModalOpen] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TGoogleSheetsConfigData & { index: number }) | null
>(null);
const refreshSheet = async () => {
const latestSpreadsheets = await refreshSheetAction(environmentId);
setSpreadsheets(latestSpreadsheets);
};
return (
<>
{isConnected && googleSheetIntegration ? (
<>
<AddIntegrationModal
environmentId={environmentId}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
spreadsheets={spreadsheets}
googleSheetIntegration={googleSheetIntegration}
selectedIntegration={selectedIntegration}
/>
<Home
environmentId={environmentId}
googleSheetIntegration={googleSheetIntegration}
setOpenAddIntegrationModal={setModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
refreshSheet={refreshSheet}
/>
</>
) : (
<Connect enabled={enabled} environmentId={environmentId} />
)}
</>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { timeSince } from "@formbricks/lib/time";
import { TGoogleSheetIntegration, TGoogleSheetsConfigData } from "@formbricks/types/v1/integrations";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
interface HomeProps {
environmentId: string;
googleSheetIntegration: TGoogleSheetIntegration;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TGoogleSheetsConfigData & { index: number }) | null) => void;
refreshSheet: () => void;
}
export default function Home({
environmentId,
googleSheetIntegration,
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
refreshSheet,
}: HomeProps) {
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const integrationArray = googleSheetIntegration
? googleSheetIntegration.config.data
? googleSheetIntegration.config.data
: []
: [];
const [isDeleting, setisDeleting] = useState(false);
const handleDeleteIntegration = async () => {
try {
setisDeleting(true);
await deleteIntegrationAction(googleSheetIntegration.id);
setIsConnected(false);
toast.success("Integration removed successfully");
} catch (error) {
toast.error(error.message);
} finally {
setisDeleting(false);
setIsDeleteIntegrationModalOpen(false);
}
};
const editIntegration = (index: number) => {
setSelectedIntegration({
...googleSheetIntegration.config.data[index],
index: index,
});
setOpenAddIntegrationModal(true);
};
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span
className="cursor-pointer text-slate-500"
onClick={() => {
setIsDeleteIntegrationModalOpen(true);
}}>
Connected with {googleSheetIntegration.config.email}
</span>
</div>
<Button
variant="darkCTA"
onClick={() => {
refreshSheet();
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
Link new Sheet
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environmentId={environmentId}
noWidgetRequired={true}
emptyMessage="Your google sheet integrations will appear here as soon as you add them. ⏲️"
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">Survey</div>
<div className="col-span-2 hidden text-center sm:block">Google Sheet Name</div>
<div className="col-span-2 hidden text-center sm:block">Questions</div>
<div className="col-span-2 hidden text-center sm:block">Updated At</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString())}</div>
</div>
);
})}
</div>
</div>
)}
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat="Google Connection"
onDelete={handleDeleteIntegration}
text="Are you sure? Your integrations will break."
isDeleting={isDeleting}
/>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use server";
import { getSpreadSheets } from "@formbricks/lib/services/googleSheet";
import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/services/integrations";
import { TGoogleSheetIntegration } from "@formbricks/types/v1/integrations";
export async function upsertIntegrationAction(
environmentId: string,
integrationData: Partial<TGoogleSheetIntegration>
) {
return await createOrUpdateIntegration(environmentId, integrationData);
}
export async function deleteIntegrationAction(integrationId: string) {
return await deleteIntegration(integrationId);
}
export async function refreshSheetAction(environmentId: string) {
return await getSpreadSheets(environmentId);
}

View File

@@ -1,40 +1,42 @@
import GoBackButton from "@/components/shared/GoBackButton";
import { Button } from "@formbricks/ui";
import { Webhook } from "lucide-react";
export default function Loading() {
return (
<>
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button
variant="darkCTA"
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-gray-200">
<Webhook className="mr-2 h-5 w-5 text-white" />
Loading
Link new sheet
</Button>
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">Edit</span>
<div className="col-span-4 pl-6 ">Webhook</div>
<div className="col-span-4 text-center">Surveys</div>
<div className="col-span-2 text-center ">Triggers</div>
<div className="col-span-2 text-center">Updated</div>
<div className="col-span-4 text-center ">Survey</div>
<div className="col-span-4 text-center">Google Sheet Name</div>
<div className="col-span-2 text-center ">Questions</div>
<div className="col-span-2 text-center">Updated At</div>
</div>
<div className="grid-cols-7">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="text-left">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
</div>
<div className="col-span-1 my-auto flex items-center justify-center text-center text-sm text-slate-500">
<div className="font-medium text-slate-500">
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
<div className="col-span-4 my-auto flex items-center justify-center text-center text-sm text-slate-500">
<div className="font-medium text-slate-500">
<div className="mt-0 h-4 w-36 animate-pulse rounded-full bg-gray-200"></div>
@@ -53,6 +55,6 @@ export default function Loading() {
))}
</div>
</div>
</>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import GoogleSheetWrapper from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/GoogleSheetWrapper";
import GoBackButton from "@/components/shared/GoBackButton";
import { env } from "@/env.mjs";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getSpreadSheets } from "@formbricks/lib/services/googleSheet";
import { getIntegrations } from "@formbricks/lib/services/integrations";
import { getSurveys } from "@formbricks/lib/services/survey";
import { TGoogleSheetIntegration, TGoogleSpreadsheet } from "@formbricks/types/v1/integrations";
export default async function GoogleSheet({ params }) {
const enabled = !!(
env.GOOGLE_SHEETS_CLIENT_ID &&
env.GOOGLE_SHEETS_CLIENT_SECRET &&
env.GOOGLE_SHEETS_REDIRECT_URL
);
const surveys = await getSurveys(params.environmentId);
let spreadSheetArray: TGoogleSpreadsheet[] = [];
const integrations = await getIntegrations(params.environmentId);
const googleSheetIntegration: TGoogleSheetIntegration | undefined = integrations?.find(
(integration): integration is TGoogleSheetIntegration => integration.type === "googleSheets"
);
if (googleSheetIntegration && googleSheetIntegration.config.key) {
spreadSheetArray = await getSpreadSheets(params.environmentId);
}
return (
<>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<div className="h-[75vh] w-full">
<GoogleSheetWrapper
enabled={enabled}
environmentId={params.environmentId}
surveys={surveys}
spreadSheetArray={spreadSheetArray}
googleSheetIntegration={googleSheetIntegration}
/>
</div>
</>
);
}

View File

@@ -1,6 +1,7 @@
import JsLogo from "@/images/jslogo.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
import GoogleSheetsLogo from "@/images/google-sheets-small.png";
import n8nLogo from "@/images/n8n.png";
import MakeLogo from "@/images/make-small.png";
import { Card } from "@formbricks/ui";
@@ -42,6 +43,17 @@ export default function IntegrationsPage({ params }) {
description="Trigger Webhooks based on actions in your surveys"
icon={<Image src={WebhookLogo} alt="Webhook Logo" />}
/>
<Card
connectHref={`/environments/${params.environmentId}/integrations/google-sheets`}
connectText="Connect"
connectNewTab={false}
docsHref="https://formbricks.com/docs/integrations/google-sheets"
docsText="Docs"
docsNewTab={true}
label="Google Sheets"
description="Instantly populate your spreadsheets with survey data"
icon={<Image src={GoogleSheetsLogo} alt="Google sheets Logo" />}
/>
<Card
docsHref="https://formbricks.com/docs/integrations/n8n"
docsText="Docs"

View File

@@ -3,7 +3,7 @@ import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integ
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers";
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
import Modal from "@/components/shared/Modal";
import { createWebhook } from "@formbricks/lib/services/webhook";
import { createWebhookAction } from "./actions";
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TWebhookInput } from "@formbricks/types/v1/webhooks";
@@ -97,11 +97,12 @@ export default function AddWebhookModal({ environmentId, surveys, open, setOpen
const updatedData: TWebhookInput = {
name: data.name,
url: testEndpointInput,
source: "user",
triggers: selectedTriggers,
surveyIds: selectedSurveys,
};
await createWebhook(environmentId, updatedData);
await createWebhookAction(environmentId, updatedData);
router.refresh();
setOpenWithStates(false);
toast.success("Webhook added successfully.");
@@ -194,6 +195,7 @@ export default function AddWebhookModal({ environmentId, surveys, open, setOpen
triggers={triggers}
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
allowChanges={true}
/>
</div>
@@ -205,6 +207,7 @@ export default function AddWebhookModal({ environmentId, surveys, open, setOpen
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
allowChanges={true}
/>
</div>
</div>

View File

@@ -8,6 +8,7 @@ interface SurveyCheckboxGroupProps {
selectedAllSurveys: boolean;
onSelectAllSurveys: () => void;
onSelectedSurveyChange: (surveyId: string) => void;
allowChanges: boolean;
}
export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
@@ -16,6 +17,7 @@ export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
selectedAllSurveys,
onSelectAllSurveys,
onSelectedSurveyChange,
allowChanges,
}) => {
return (
<div className="mt-1 rounded-lg border border-slate-200">
@@ -28,10 +30,13 @@ export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
value=""
checked={selectedAllSurveys}
onCheckedChange={onSelectAllSurveys}
disabled={!allowChanges}
/>
<label
htmlFor="allSurveys"
className={`flex cursor-pointer items-center ${selectedAllSurveys ? "font-semibold" : ""}`}>
className={`flex cursor-pointer items-center ${selectedAllSurveys ? "font-semibold" : ""} ${
!allowChanges ? "cursor-not-allowed opacity-50" : ""
}`}>
All current and new surveys
</label>
</div>
@@ -43,14 +48,18 @@ export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
value={survey.id}
className="bg-white"
checked={selectedSurveys.includes(survey.id) && !selectedAllSurveys}
disabled={selectedAllSurveys}
onCheckedChange={() => onSelectedSurveyChange(survey.id)}
disabled={selectedAllSurveys || !allowChanges}
onCheckedChange={() => {
if (allowChanges) {
onSelectedSurveyChange(survey.id);
}
}}
/>
<label
htmlFor={survey.id}
className={`flex cursor-pointer items-center ${
selectedAllSurveys ? "cursor-not-allowed opacity-50" : ""
}`}>
} ${!allowChanges ? "cursor-not-allowed opacity-50" : ""}`}>
{survey.name}
</label>
</div>

View File

@@ -6,19 +6,25 @@ interface TriggerCheckboxGroupProps {
triggers: { title: string; value: TPipelineTrigger }[];
selectedTriggers: TPipelineTrigger[];
onCheckboxChange: (selectedValue: TPipelineTrigger) => void;
allowChanges: boolean;
}
export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
triggers,
selectedTriggers,
onCheckboxChange,
allowChanges,
}) => {
return (
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{triggers.map((trigger) => (
<div key={trigger.value} className="my-1 flex items-center space-x-2">
<label htmlFor={trigger.value} className="flex cursor-pointer items-center">
<label
htmlFor={trigger.value}
className={`flex ${
!allowChanges ? "cursor-not-allowed opacity-50" : "cursor-pointer"
} items-center`}>
<Checkbox
type="button"
id={trigger.value}
@@ -26,8 +32,11 @@ export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
className="bg-white"
checked={selectedTriggers.includes(trigger.value)}
onCheckedChange={() => {
onCheckboxChange(trigger.value);
if (allowChanges) {
onCheckboxChange(trigger.value);
}
}}
disabled={!allowChanges}
/>
<span className="ml-2">{trigger.title}</span>
</label>

View File

@@ -2,6 +2,7 @@ import { Label } from "@formbricks/ui";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { TWebhook } from "@formbricks/types/v1/webhooks";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { capitalizeFirstLetter } from "@/lib/utils";
interface ActivityTabProps {
webhook: TWebhook;
@@ -38,7 +39,14 @@ export default function WebhookOverviewTab({ webhook, surveys }: ActivityTabProp
<div className="col-span-2 space-y-4 pr-6">
<div>
<Label className="text-slate-500">Name</Label>
<p className="text-sm text-slate-900">{webhook.name ? webhook.name : "-"}</p>
<p className="truncate text-sm text-slate-900">{webhook.name ? webhook.name : "-"}</p>
</div>
<div>
<Label className="text-slate-500">Created by a Third Party</Label>
<p className="text-sm text-slate-900">
{webhook.source === "user" ? "No" : capitalizeFirstLetter(webhook.source)}
</p>
</div>
<div>

View File

@@ -1,6 +1,8 @@
import { capitalizeFirstLetter } from "@/lib/utils";
import { timeSinceConditionally } from "@formbricks/lib/time";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TWebhook } from "@formbricks/types/v1/webhooks";
import { Badge } from "@formbricks/ui";
const renderSelectedSurveysText = (webhook: TWebhook, allSurveys: TSurvey[]) => {
if (webhook.surveyIds.length === 0) {
@@ -51,8 +53,8 @@ const renderSelectedTriggersText = (webhook: TWebhook) => {
export default function WebhookRowData({ webhook, surveys }: { webhook: TWebhook; surveys: TSurvey[] }) {
return (
<div className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="mt-2 grid h-auto grid-cols-12 content-center rounded-lg py-2 hover:bg-slate-100">
<div className="col-span-3 flex items-center truncate pl-6 text-sm">
<div className="flex items-center">
<div className="text-left">
{webhook.name ? (
@@ -66,6 +68,9 @@ export default function WebhookRowData({ webhook, surveys }: { webhook: TWebhook
</div>
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm text-slate-800">
<Badge text={capitalizeFirstLetter(webhook.source) || "User"} type="gray" size="tiny" />
</div>
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
{renderSelectedSurveysText(webhook, surveys)}
</div>

View File

@@ -9,7 +9,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
import { deleteWebhook, updateWebhook } from "@formbricks/lib/services/webhook";
import { deleteWebhookAction, updateWebhookAction } from "./actions";
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
@@ -108,11 +108,12 @@ export default function WebhookSettingsTab({
const updatedData: TWebhookInput = {
name: data.name,
url: data.url as string,
source: data.source,
triggers: selectedTriggers,
surveyIds: selectedSurveys,
};
setIsUpdatingWebhook(true);
await updateWebhook(environmentId, webhook.id, updatedData);
await updateWebhookAction(environmentId, webhook.id, updatedData);
toast.success("Webhook updated successfully.");
router.refresh();
setIsUpdatingWebhook(false);
@@ -147,7 +148,9 @@ export default function WebhookSettingsTab({
onChange={(e) => {
setTestEndpointInput(e.target.value);
}}
readOnly={webhook.source !== "user"}
className={clsx(
webhook.source === "user" ? null : "cursor-not-allowed bg-gray-100 text-gray-500",
endpointAccessible === true
? "border-green-500 bg-green-50"
: endpointAccessible === false
@@ -177,6 +180,7 @@ export default function WebhookSettingsTab({
triggers={triggers}
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
allowChanges={webhook.source === "user"}
/>
</div>
@@ -188,19 +192,22 @@ export default function WebhookSettingsTab({
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
allowChanges={webhook.source === "user"}
/>
</div>
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
<Button
type="button"
variant="warn"
onClick={() => setOpenDeleteDialog(true)}
StartIcon={TrashIcon}
className="mr-3">
Delete
</Button>
{webhook.source === "user" && (
<Button
type="button"
variant="warn"
onClick={() => setOpenDeleteDialog(true)}
StartIcon={TrashIcon}
className="mr-3">
Delete
</Button>
)}
<Button
variant="secondary"
@@ -225,7 +232,7 @@ export default function WebhookSettingsTab({
onDelete={async () => {
setOpen(false);
try {
await deleteWebhook(webhook.id);
await deleteWebhookAction(webhook.id);
router.refresh();
toast.success("Webhook deleted successfully");
} catch (error) {

View File

@@ -28,6 +28,7 @@ export default function WebhookTable({
id: "",
name: "",
url: "",
source: "user",
triggers: [],
surveyIds: [],
createdAt: new Date(),

View File

@@ -3,7 +3,8 @@ export default function WebhookTableHeading() {
<>
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">Edit</span>
<div className="col-span-4 pl-6 ">Webhook</div>
<div className="col-span-3 pl-6 ">Webhook</div>
<div className="col-span-1 text-center">Source</div>
<div className="col-span-4 text-center">Surveys</div>
<div className="col-span-2 text-center ">Triggers</div>
<div className="col-span-2 text-center">Updated</div>

View File

@@ -0,0 +1,23 @@
"use server";
import { createWebhook, deleteWebhook, updateWebhook } from "@formbricks/lib/services/webhook";
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
export const createWebhookAction = async (
environmentId: string,
webhookInput: TWebhookInput
): Promise<TWebhook> => {
return await createWebhook(environmentId, webhookInput);
};
export const deleteWebhookAction = async (id: string): Promise<TWebhook> => {
return await deleteWebhook(id);
};
export const updateWebhookAction = async (
environmentId: string,
webhookId: string,
webhookInput: Partial<TWebhookInput>
): Promise<TWebhook> => {
return await updateWebhook(environmentId, webhookId, webhookInput);
};

View File

@@ -9,6 +9,9 @@ import { getEnvironment } from "@formbricks/lib/services/environment";
export default async function ProfileSettingsPage({ params }) {
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error("Environment not found");
}
return (
<div>
<SettingsTitle title="API Keys" />

View File

@@ -2,7 +2,7 @@
import { cn } from "@formbricks/lib/cn";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { TProduct } from "@formbricks/types/v1/product";
import { Button, ColorPicker, Label, Switch } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -20,10 +20,7 @@ export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => {
const handleUpdateHighlightBorder = async () => {
try {
setUpdatingBorder(true);
let inputProduct: Partial<TProductUpdateInput> = {
highlightBorderColor: color,
};
await updateProductAction(product.id, inputProduct);
await updateProductAction(product.id, { highlightBorderColor: color });
toast.success("Border color updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
@@ -46,54 +43,6 @@ export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => {
}
};
/* return (
<div className="flex min-h-full w-full">
<div className="flex w-1/2 flex-col px-6 py-5">
<div className="mb-6 flex items-center space-x-2">
<Switch id="highlightBorder" checked={showHighlightBorder} onCheckedChange={handleSwitch} />
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
</div>
{showHighlightBorder && color ? (
<>
<Label htmlFor="brandcolor">Color (HEX)</Label>
<ColorPicker color={color} onChange={setColor} />
</>
) : null}
<Button
variant="darkCTA"
className="mt-4 flex max-w-[80px] items-center justify-center"
loading={updatingBorder}
onClick={handleUpdateHighlightBorder}>
Save
</Button>
</div>
<div className="flex w-1/2 flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5">
<h3 className="text-slate-500">Preview</h3>
<div
className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}
{...(showHighlightBorder &&
color && {
style: {
borderColor: color,
},
})}>
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
<div className="flex rounded-2xl border border-slate-400">
{[1, 2, 3, 4, 5].map((num) => (
<div key={num} className="border-r border-slate-400 px-6 py-5 last:border-r-0">
<span className="text-sm font-medium">{num}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}; */
return (
<div className="flex min-h-full w-full flex-col md:flex-row">
<div className="flex w-full flex-col px-6 py-5 md:w-1/2">

View File

@@ -1,52 +1,38 @@
"use client";
import { deleteTeamAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useMembers } from "@/lib/members";
import { useMemberships } from "@/lib/memberships";
import { useProfile } from "@/lib/profile";
import { deleteTeam, useTeam } from "@/lib/teams/teams";
import { Button, ErrorComponent, Input } from "@formbricks/ui";
import { TTeam } from "@formbricks/types/v1/teams";
import { Button, Input } from "@formbricks/ui";
import { useRouter } from "next/navigation";
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
export default function DeleteTeam({ environmentId }) {
type DeleteTeamProps = {
team: TTeam;
isDeleteDisabled?: boolean;
isUserOwner?: boolean;
};
export default function DeleteTeam({ team, isDeleteDisabled = false, isUserOwner = false }: DeleteTeamProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const { profile } = useProfile();
const { memberships } = useMemberships();
const { team } = useMembers(environmentId);
const { team: teamData, isLoadingTeam, isErrorTeam } = useTeam(environmentId);
const availableTeams = memberships?.length;
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
const isUserOwner = role === "owner";
const isDeleteDisabled = availableTeams <= 1 || !isUserOwner;
if (isLoadingTeam) {
return <LoadingSpinner />;
}
if (isErrorTeam) {
return <ErrorComponent />;
}
const handleDeleteTeam = async () => {
setIsDeleting(true);
const deleteTeamRes = await deleteTeam(environmentId);
setIsDeleteDialogOpen(false);
setIsDeleting(false);
if (deleteTeamRes?.deletedTeam?.id?.length > 0) {
try {
await deleteTeamAction(team.id);
toast.success("Team deleted successfully.");
router.push("/");
} else if (deleteTeamRes?.message?.length > 0) {
toast.error(deleteTeamRes.message);
} else {
} catch (err) {
toast.error("Error deleting team. Please try again.");
}
setIsDeleteDialogOpen(false);
setIsDeleting(false);
};
return (
@@ -75,7 +61,7 @@ export default function DeleteTeam({ environmentId }) {
<DeleteTeamModal
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
teamData={teamData}
teamData={team}
deleteTeam={handleDeleteTeam}
isDeleting={isDeleting}
/>
@@ -86,7 +72,8 @@ export default function DeleteTeam({ environmentId }) {
interface DeleteTeamModalProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
teamData: { name: string; id: string; plan: string };
// teamData: { name: string; id: string; plan: string };
teamData: TTeam;
deleteTeam: () => void;
isDeleting?: boolean;
}

View File

@@ -1,417 +0,0 @@
"use client";
import ShareInviteModal from "@/app/(app)/environments/[environmentId]/settings/members/ShareInviteModal";
import TransferOwnershipModal from "@/app/(app)/environments/[environmentId]/settings/members/TransferOwnershipModal";
import CustomDialog from "@/components/shared/CustomDialog";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import CreateTeamModal from "@/components/team/CreateTeamModal";
import { env } from "@/env.mjs";
import {
addMember,
deleteInvite,
removeMember,
resendInvite,
shareInvite,
transferOwnership,
updateInviteeRole,
updateMemberRole,
useMembers,
} from "@/lib/members";
import { useMemberships } from "@/lib/memberships";
import { useProfile } from "@/lib/profile";
import { capitalizeFirstLetter } from "@/lib/utils";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
ProfileAvatar,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import AddMemberModal from "./AddMemberModal";
type EditMembershipsProps = {
environmentId: string;
};
interface Role {
isAdminOrOwner: boolean;
memberRole: MembershipRole;
teamId: string;
memberId: string;
memberName: string;
environmentId: string;
userId: string;
memberAccepted: boolean;
inviteId: string;
currentUserRole: string;
}
enum MembershipRole {
Owner = "owner",
Admin = "admin",
Editor = "editor",
Developer = "developer",
Viewer = "viewer",
}
function RoleElement({
isAdminOrOwner,
memberRole,
teamId,
memberId,
memberName,
environmentId,
userId,
memberAccepted,
inviteId,
currentUserRole,
}: Role) {
const { mutateTeam } = useMembers(environmentId);
const [loading, setLoading] = useState(false);
const [isTransferOwnershipModalOpen, setTransferOwnershipModalOpen] = useState(false);
const disableRole =
memberRole && memberId && userId
? memberRole === ("owner" as MembershipRole) || memberId === userId
: false;
const handleMemberRoleUpdate = async (role: string) => {
setLoading(true);
if (memberAccepted) {
await updateMemberRole(teamId, memberId, role);
} else {
await updateInviteeRole(teamId, inviteId, role);
}
setLoading(false);
mutateTeam();
};
const handleOwnershipTransfer = async () => {
setLoading(true);
const isTransfered = await transferOwnership(teamId, memberId);
if (isTransfered) {
toast.success("Ownership transferred successfully");
} else {
toast.error("Something went wrong");
}
setTransferOwnershipModalOpen(false);
setLoading(false);
mutateTeam();
};
const handleRoleChange = (role: string) => {
if (role === "owner") {
setTransferOwnershipModalOpen(true);
} else {
handleMemberRoleUpdate(role);
}
};
const getMembershipRoles = () => {
if (currentUserRole === "owner" && memberAccepted) {
return Object.keys(MembershipRole);
}
return Object.keys(MembershipRole).filter((role) => role !== "Owner");
};
if (isAdminOrOwner) {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={disableRole}
variant="secondary"
className="flex items-center gap-1 p-1.5 text-xs"
loading={loading}
size="sm">
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
{!disableRole && (
<DropdownMenuContent>
<DropdownMenuRadioGroup
value={capitalizeFirstLetter(memberRole)}
onValueChange={(value) => handleRoleChange(value.toLowerCase())}>
{getMembershipRoles().map((role) => (
<DropdownMenuRadioItem key={role} value={role}>
{capitalizeFirstLetter(role)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
)}
</DropdownMenu>
<TransferOwnershipModal
open={isTransferOwnershipModalOpen}
setOpen={setTransferOwnershipModalOpen}
memberName={memberName}
onSubmit={handleOwnershipTransfer}
isLoading={loading}
/>
</>
);
}
return <Badge text={capitalizeFirstLetter(memberRole)} type="gray" size="tiny" />;
}
export function EditMemberships({ environmentId }: EditMembershipsProps) {
const { team, isErrorTeam, isLoadingTeam, mutateTeam } = useMembers(environmentId);
const [loading, setLoading] = useState(false);
const [isAddMemberModalOpen, setAddMemberModalOpen] = useState(false);
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const [showShareInviteModal, setShowShareInviteModal] = useState(false);
const [isLeaveTeamModalOpen, setLeaveTeamModalOpen] = useState(false);
const [shareInviteToken, setShareInviteToken] = useState<string>("");
const [activeMember, setActiveMember] = useState({} as any);
const { profile } = useProfile();
const { memberships } = useMemberships();
const router = useRouter();
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
const isAdminOrOwner = role === "admin" || role === "owner";
const availableTeams = memberships?.length;
const isLeaveTeamDisabled = availableTeams <= 1;
const handleOpenDeleteMemberModal = (e, member) => {
e.preventDefault();
setActiveMember(member);
setDeleteMemberModalOpen(true);
};
if (isLoadingTeam) {
return <LoadingSpinner />;
}
if (isErrorTeam || !team) {
console.error(isErrorTeam);
return <div>Error</div>;
}
const handleDeleteMember = async () => {
let result = false;
if (activeMember.accepted) {
result = await removeMember(team.teamId, activeMember.userId);
} else {
result = await deleteInvite(team.teamId, activeMember.inviteId);
}
if (result) {
toast.success("Member removed successfully");
} else {
toast.error("Something went wrong");
}
setDeleteMemberModalOpen(false);
mutateTeam();
};
const handleShareInvite = async (member) => {
const { inviteToken } = await shareInvite(team.teamId, member.inviteId);
setShareInviteToken(inviteToken);
setShowShareInviteModal(true);
};
const handleResendInvite = async (inviteId) => {
await resendInvite(team.teamId, inviteId);
};
const handleAddMember = async (data) => {
// TODO: handle http 409 user is already part of the team
const add = await addMember(team.teamId, data);
if (add) {
toast.success("Member invited successfully");
} else {
toast.error("Something went wrong");
}
mutateTeam();
};
const isExpired = (invite) => {
const now = new Date();
const expiresAt = new Date(invite.expiresAt);
return now > expiresAt;
};
const handleLeaveTeam = async () => {
setLoading(true);
const result = await removeMember(team.teamId, profile?.id);
setLeaveTeamModalOpen(false);
setLoading(false);
if (!result) {
toast.error("Something went wrong");
} else {
toast.success("You left the team successfully");
router.push("/");
}
};
return (
<>
<div className="mb-6 text-right">
{role !== "owner" && (
<Button variant="minimal" className="mr-2" onClick={() => setLeaveTeamModalOpen(true)}>
Leave Team
</Button>
)}
<Button
variant="secondary"
className="mr-2 hidden sm:inline-flex"
onClick={() => {
setCreateTeamModalOpen(true);
}}>
Create New Team
</Button>
{env.NEXT_PUBLIC_INVITE_DISABLED !== "1" && isAdminOrOwner && (
<Button
variant="darkCTA"
onClick={() => {
setAddMemberModalOpen(true);
}}>
Add Member
</Button>
)}
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2"></div>
<div className="col-span-5">Fullname</div>
<div className="col-span-5">Email</div>
<div className="hidden sm:col-span-3 sm:block">Role</div>
<div className="hidden sm:col-span-5 sm:block"></div>
</div>
<div className="grid-cols-20">
{[...team.members, ...team.invitees].map((member) => (
<div
className="grid-cols-20 grid h-auto w-full content-center rounded-lg p-0.5 py-2 text-left text-sm text-slate-900"
key={member.email}>
<div className="h-58 col-span-2 pl-4 ">
<div className="hidden sm:block">
<ProfileAvatar userId={member.userId || member.email} />
</div>
</div>
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
<p>{member.name}</p>
</div>
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
{member.email}
</div>
<div className="ph-no-capture col-span-3 hidden flex-col items-start justify-center break-all sm:flex">
<RoleElement
isAdminOrOwner={isAdminOrOwner}
memberRole={member.role}
memberId={member.userId}
memberName={member.name}
teamId={team.teamId}
environmentId={environmentId}
userId={profile?.id}
memberAccepted={member.accepted}
inviteId={member?.inviteId}
currentUserRole={role}
/>
</div>
<div className="col-span-5 ml-48 hidden items-center justify-end gap-x-2 pr-4 sm:ml-0 sm:gap-x-4 lg:flex">
{!member.accepted &&
(isExpired(member) ? (
<Badge className="mr-2" type="gray" text="Expired" size="tiny" />
) : (
<Badge className="mr-2" type="warning" text="Pending" size="tiny" />
))}
{isAdminOrOwner && member.role !== "owner" && member.userId !== profile?.id && (
<button onClick={(e) => handleOpenDeleteMemberModal(e, member)}>
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
)}
{!member.accepted && (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
handleShareInvite(member);
}}>
<ShareIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>
<TooltipContent className="TooltipContent" sideOffset={5}>
Share Invite Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
handleResendInvite(member.inviteId);
toast.success("Invitation sent once more.");
}}>
<PaperAirplaneIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>
<TooltipContent className="TooltipContent" sideOffset={5}>
Resend Invitation Email
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
))}
</div>
</div>
<CreateTeamModal open={isCreateTeamModalOpen} setOpen={(val) => setCreateTeamModalOpen(val)} />
<AddMemberModal
open={isAddMemberModalOpen}
setOpen={setAddMemberModalOpen}
onSubmit={handleAddMember}
/>
<DeleteDialog
open={isDeleteMemberModalOpen}
setOpen={setDeleteMemberModalOpen}
deleteWhat={activeMember.name + " from your team"}
onDelete={handleDeleteMember}
/>
<CustomDialog
open={isLeaveTeamModalOpen}
setOpen={setLeaveTeamModalOpen}
title="Are you sure?"
text="You wil leave this team and loose access to all surveys and responses. You can only rejoin if you are invited again."
onOk={handleLeaveTeam}
okBtnText="Yes, leave team"
disabled={isLeaveTeamDisabled}
isLoading={loading}>
{isLeaveTeamDisabled && (
<p className="mt-2 text-sm text-red-700">
You cannot leave this team as it is your only team. Create a new team first.
</p>
)}
</CustomDialog>
{showShareInviteModal && (
<ShareInviteModal
inviteToken={shareInviteToken}
open={showShareInviteModal}
setOpen={setShowShareInviteModal}
/>
)}
</>
);
}

View File

@@ -0,0 +1,50 @@
import { TTeam } from "@formbricks/types/v1/teams";
import React from "react";
import MembersInfo from "@/app/(app)/environments/[environmentId]/settings/members/EditMemberships/MembersInfo";
import { getMembersByTeamId } from "@formbricks/lib/services/membership";
import { getInviteesByTeamId } from "@formbricks/lib/services/invite";
import { TMembership } from "@formbricks/types/v1/memberships";
type EditMembershipsProps = {
team: TTeam;
currentUserId: string;
currentUserMembership: TMembership;
allMemberships: TMembership[];
};
export async function EditMemberships({
team,
currentUserId,
currentUserMembership: membership,
}: EditMembershipsProps) {
const members = await getMembersByTeamId(team.id);
const invites = await getInviteesByTeamId(team.id);
const currentUserRole = membership?.role;
const isUserAdminOrOwner = membership?.role === "admin" || membership?.role === "owner";
return (
<div>
<div className="rounded-lg border border-slate-200">
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2"></div>
<div className="col-span-5">Fullname</div>
<div className="col-span-5">Email</div>
<div className="col-span-3">Role</div>
<div className="col-span-5"></div>
</div>
{currentUserRole && (
<MembersInfo
team={team}
currentUserId={currentUserId}
invites={invites ?? []}
members={members ?? []}
isUserAdminOrOwner={isUserAdminOrOwner}
currentUserRole={currentUserRole}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import ShareInviteModal from "@/app/(app)/environments/[environmentId]/settings/members/ShareInviteModal";
import {
createInviteTokenAction,
deleteInviteAction,
deleteMembershipAction,
resendInviteAction,
} from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { TInvite } from "@formbricks/types/v1/invites";
import { TMember } from "@formbricks/types/v1/memberships";
import { TTeam } from "@formbricks/types/v1/teams";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
import toast from "react-hot-toast";
type MemberActionsProps = {
team: TTeam;
member?: TMember;
invite?: TInvite;
isAdminOrOwner: boolean;
showDeleteButton?: boolean;
};
export default function MemberActions({ team, member, invite, showDeleteButton }: MemberActionsProps) {
const router = useRouter();
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showShareInviteModal, setShowShareInviteModal] = useState(false);
const [shareInviteToken, setShareInviteToken] = useState("");
const handleDeleteMember = async () => {
try {
setIsDeleting(true);
if (!member && invite) {
// This is an invite
await deleteInviteAction(invite?.id, team.id);
toast.success("Invite deleted successfully");
}
if (member && !invite) {
// This is a member
await deleteMembershipAction(member.userId, team.id);
toast.success("Member deleted successfully");
}
setIsDeleting(false);
router.refresh();
} catch (err) {
console.log({ err });
setIsDeleting(false);
toast.error("Something went wrong");
}
};
const memberName = useMemo(() => {
if (member) {
return member.name;
}
if (invite) {
return invite.name;
}
return "";
}, [invite, member]);
const handleShareInvite = async () => {
try {
if (!invite) return;
const { inviteToken } = await createInviteTokenAction(invite.id);
setShareInviteToken(inviteToken);
setShowShareInviteModal(true);
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
const handleResendInvite = async () => {
try {
if (!invite) return;
await resendInviteAction(invite.id);
toast.success("Invitation sent once more.");
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
return (
<>
{showDeleteButton && (
<button onClick={() => setDeleteMemberModalOpen(true)}>
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
)}
{invite && (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
handleShareInvite();
}}>
<ShareIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>
<TooltipContent className="TooltipContent" sideOffset={5}>
Share Invite Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
handleResendInvite();
}}>
<PaperAirplaneIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>
<TooltipContent className="TooltipContent" sideOffset={5}>
Resend Invitation Email
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<DeleteDialog
open={isDeleteMemberModalOpen}
setOpen={setDeleteMemberModalOpen}
deleteWhat={memberName + " from your team"}
onDelete={handleDeleteMember}
isDeleting={isDeleting}
/>
{showShareInviteModal && (
<ShareInviteModal
inviteToken={shareInviteToken}
open={showShareInviteModal}
setOpen={setShowShareInviteModal}
/>
)}
</>
);
}

View File

@@ -0,0 +1,95 @@
import MemberActions from "@/app/(app)/environments/[environmentId]/settings/members/EditMemberships/MemberActions";
import MembershipRole from "@/app/(app)/environments/[environmentId]/settings/members/EditMemberships/MembershipRole";
import { isInviteExpired } from "@/lib/utils";
import { TInvite } from "@formbricks/types/v1/invites";
import { TMember, TMembershipRole } from "@formbricks/types/v1/memberships";
import { TTeam } from "@formbricks/types/v1/teams";
import { Badge, ProfileAvatar } from "@formbricks/ui";
import React from "react";
type MembersInfoProps = {
team: TTeam;
members: TMember[];
invites: TInvite[];
isUserAdminOrOwner: boolean;
currentUserId: string;
currentUserRole: TMembershipRole;
};
// Type guard to check if member is an invitee
function isInvitee(member: TMember | TInvite): member is TInvite {
return (member as TInvite).expiresAt !== undefined;
}
const MembersInfo = async ({
team,
invites,
isUserAdminOrOwner,
members,
currentUserId,
currentUserRole,
}: MembersInfoProps) => {
const allMembers = [...members, ...invites];
return (
<div className="grid-cols-20">
{allMembers.map((member) => (
<div
className="grid-cols-20 grid h-auto w-full content-center rounded-lg p-0.5 py-2 text-left text-sm text-slate-900"
key={member.email}>
<div className="h-58 col-span-2 pl-4">
{isInvitee(member) ? (
<ProfileAvatar userId={member.email} />
) : (
<ProfileAvatar userId={member.userId} />
)}
</div>
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
<p>{member.name}</p>
</div>
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
{member.email}
</div>
<div className="ph-no-capture col-span-3 flex flex-col items-start justify-center break-all">
{allMembers?.length > 0 && (
<MembershipRole
isAdminOrOwner={isUserAdminOrOwner}
memberRole={member.role}
memberId={!isInvitee(member) ? member.userId : ""}
memberName={member.name ?? ""}
teamId={team.id}
userId={currentUserId}
memberAccepted={member.accepted}
inviteId={isInvitee(member) ? member.id : ""}
currentUserRole={currentUserRole}
/>
)}
</div>
<div className="col-span-5 flex items-center justify-end gap-x-4 pr-4">
{!member.accepted &&
isInvitee(member) &&
(isInviteExpired(member) ? (
<Badge className="mr-2" type="gray" text="Expired" size="tiny" />
) : (
<Badge className="mr-2" type="warning" text="Pending" size="tiny" />
))}
<MemberActions
team={team}
member={!isInvitee(member) ? member : undefined}
invite={isInvitee(member) ? member : undefined}
isAdminOrOwner={isUserAdminOrOwner}
showDeleteButton={
isUserAdminOrOwner && member.role !== "owner" && (member as TMember).userId !== currentUserId
}
/>
</div>
</div>
))}
</div>
);
};
export default MembersInfo;

View File

@@ -0,0 +1,149 @@
"use client";
import TransferOwnershipModal from "@/app/(app)/environments/[environmentId]/settings/members/TransferOwnershipModal";
import {
transferOwnershipAction,
updateInviteAction,
updateMembershipAction,
} from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import { MEMBERSHIP_ROLES, capitalizeFirstLetter } from "@/lib/utils";
import { TMembershipRole } from "@formbricks/types/v1/memberships";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@formbricks/ui";
import { ChevronDownIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
interface Role {
isAdminOrOwner: boolean;
memberRole: TMembershipRole;
teamId: string;
memberId?: string;
memberName: string;
userId: string;
memberAccepted: boolean;
inviteId?: string;
currentUserRole: string;
}
export default function MembershipRole({
isAdminOrOwner,
memberRole,
teamId,
memberId,
memberName,
userId,
memberAccepted,
inviteId,
currentUserRole,
}: Role) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [isTransferOwnershipModalOpen, setTransferOwnershipModalOpen] = useState(false);
const disableRole =
memberRole && memberId && userId ? memberRole === "owner" || memberId === userId : false;
const handleMemberRoleUpdate = async (role: TMembershipRole) => {
setLoading(true);
try {
if (memberAccepted && memberId) {
await updateMembershipAction(memberId, teamId, { role });
}
if (inviteId) {
await updateInviteAction(inviteId, teamId, { role });
}
} catch (error) {
toast.error("Something went wrong");
}
setLoading(false);
router.refresh();
};
const handleOwnershipTransfer = async () => {
setLoading(true);
try {
if (memberId) {
await transferOwnershipAction(teamId, memberId);
}
setLoading(false);
setTransferOwnershipModalOpen(false);
toast.success("Ownership transferred successfully");
router.refresh();
} catch (err) {
toast.error(`Error: ${err.message}`);
setLoading(false);
setTransferOwnershipModalOpen(false);
}
};
const handleRoleChange = (role: TMembershipRole) => {
if (role === "owner") {
setTransferOwnershipModalOpen(true);
} else {
handleMemberRoleUpdate(role);
}
};
const getMembershipRoles = () => {
if (currentUserRole === "owner" && memberAccepted) {
return Object.keys(MEMBERSHIP_ROLES);
}
return Object.keys(MEMBERSHIP_ROLES).filter((role) => role !== "OWNER");
};
if (isAdminOrOwner) {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={disableRole}
variant="secondary"
className="flex items-center gap-1 p-1.5 text-xs"
loading={loading}
size="sm">
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
{!disableRole && (
<DropdownMenuContent>
<DropdownMenuRadioGroup
value={capitalizeFirstLetter(memberRole)}
onValueChange={(value) => handleRoleChange(value.toLowerCase() as TMembershipRole)}>
{getMembershipRoles().map((role) => (
<DropdownMenuRadioItem key={role} value={role} className="capitalize">
{role.toLowerCase()}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
)}
</DropdownMenu>
<TransferOwnershipModal
open={isTransferOwnershipModalOpen}
setOpen={setTransferOwnershipModalOpen}
memberName={memberName}
onSubmit={handleOwnershipTransfer}
isLoading={loading}
/>
</>
);
}
return <Badge text={capitalizeFirstLetter(memberRole)} type="gray" size="tiny" />;
}

View File

@@ -0,0 +1,107 @@
"use client";
import AddMemberModal from "@/app/(app)/environments/[environmentId]/settings/members/AddMemberModal";
import {
inviteUserAction,
leaveTeamAction,
} from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import CustomDialog from "@/components/shared/CustomDialog";
import CreateTeamModal from "@/components/team/CreateTeamModal";
import { env } from "@/env.mjs";
import { TMembershipRole } from "@formbricks/types/v1/memberships";
import { TTeam } from "@formbricks/types/v1/teams";
import { Button } from "@formbricks/ui";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import toast from "react-hot-toast";
type TeamActionsProps = {
role: string;
isAdminOrOwner: boolean;
isLeaveTeamDisabled: boolean;
team: TTeam;
};
export default function TeamActions({ isAdminOrOwner, role, team, isLeaveTeamDisabled }: TeamActionsProps) {
const router = useRouter();
const [isLeaveTeamModalOpen, setLeaveTeamModalOpen] = useState(false);
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const [isAddMemberModalOpen, setAddMemberModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleLeaveTeam = async () => {
setLoading(true);
try {
await leaveTeamAction(team.id);
toast.success("You left the team successfully");
router.refresh();
setLoading(false);
router.push("/");
} catch (err) {
toast.error(`Error: ${err.message}`);
setLoading(false);
}
};
const handleAddMember = async (data: { name: string; email: string; role: TMembershipRole }) => {
try {
await inviteUserAction(team.id, data.email, data.name, data.role);
toast.success("Member invited successfully");
} catch (err) {
toast.error(`Error: ${err.message}`);
}
router.refresh();
};
return (
<>
<div className="mb-6 text-right">
{role !== "owner" && (
<Button variant="minimal" className="mr-2" onClick={() => setLeaveTeamModalOpen(true)}>
Leave Team
</Button>
)}
<Button
variant="secondary"
className="mr-2"
onClick={() => {
setCreateTeamModalOpen(true);
}}>
Create New Team
</Button>
{env.NEXT_PUBLIC_INVITE_DISABLED !== "1" && isAdminOrOwner && (
<Button
variant="darkCTA"
onClick={() => {
setAddMemberModalOpen(true);
}}>
Add Member
</Button>
)}
</div>
<CreateTeamModal open={isCreateTeamModalOpen} setOpen={(val) => setCreateTeamModalOpen(val)} />
<AddMemberModal
open={isAddMemberModalOpen}
setOpen={setAddMemberModalOpen}
onSubmit={handleAddMember}
/>
<CustomDialog
open={isLeaveTeamModalOpen}
setOpen={setLeaveTeamModalOpen}
title="Are you sure?"
text="You wil leave this team and loose access to all surveys and responses. You can only rejoin if you are invited again."
onOk={handleLeaveTeam}
okBtnText="Yes, leave team"
disabled={isLeaveTeamDisabled}
isLoading={loading}>
{isLeaveTeamDisabled && (
<p className="mt-2 text-sm text-red-700">
You cannot leave this team as it is your only team. Create a new team first.
</p>
)}
</CustomDialog>
</>
);
}

View File

@@ -0,0 +1,2 @@
// export { EditMembershipsClient } from "./EditMembershipscClient";
export { EditMemberships } from "./EditMemberships";

View File

@@ -1,69 +1,82 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useTeamMutation } from "@/lib/teams/mutateTeams";
import { useTeam } from "@/lib/teams/teams";
import { Button, ErrorComponent, Input, Label } from "@formbricks/ui";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { updateTeamAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import { TTeam } from "@formbricks/types/v1/teams";
import { Button, Input, Label } from "@formbricks/ui";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { SubmitHandler, useForm, useWatch } from "react-hook-form";
import toast from "react-hot-toast";
export default function EditTeamName({ environmentId }) {
const { team, isLoadingTeam, isErrorTeam, mutateTeam } = useTeam(environmentId);
const { register, control, handleSubmit, setValue } = useForm();
const [teamId, setTeamId] = useState("");
type TEditTeamNameForm = {
name: string;
};
type TEditTeamNameProps = {
environmentId: string;
team: TTeam;
};
export default function EditTeamName({ team }: TEditTeamNameProps) {
const router = useRouter();
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<TEditTeamNameForm>({
defaultValues: {
name: team.name,
},
});
const [isUpdatingTeam, setIsUpdatingTeam] = useState(false);
const teamName = useWatch({
control,
name: "name",
});
const isTeamNameInputEmpty = !teamName?.trim();
const currentTeamName = teamName?.trim().toLowerCase() ?? "";
const previousTeamName = team?.name?.trim().toLowerCase() ?? "";
useEffect(() => {
if (team && team.id !== "") {
setTeamId(team.id);
const handleUpdateTeamName: SubmitHandler<TEditTeamNameForm> = async (data) => {
try {
setIsUpdatingTeam(true);
await updateTeamAction(team.id, data);
setIsUpdatingTeam(false);
toast.success("Team name updated successfully.");
router.refresh();
} catch (err) {
setIsUpdatingTeam(false);
toast.error(`Error: ${err.message}`);
}
setValue("name", team?.name ?? "");
}, [team]);
const { isMutatingTeam, triggerTeamMutate } = useTeamMutation(teamId);
if (isLoadingTeam) {
return <LoadingSpinner />;
}
if (isErrorTeam) {
return <ErrorComponent />;
}
};
return (
<form
className="w-full max-w-sm items-center"
onSubmit={handleSubmit((data) => {
triggerTeamMutate({ ...data })
.catch((error) => {
toast.error(`Error: ${error.message}`);
})
.then(() => {
toast.success("Team name updated successfully.");
mutateTeam(); // Added to trigger SWR to update the team name in menus
});
})}>
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(handleUpdateTeamName)}>
<Label htmlFor="teamname">Team Name</Label>
<Input
type="text"
id="teamname"
defaultValue={team?.name ?? ""}
{...register("name")}
className={isTeamNameInputEmpty ? "border-red-300 focus:border-red-300" : ""}
{...register("name", {
required: {
message: "Team name is required.",
value: true,
},
})}
/>
{errors?.name?.message && <p className="text-xs text-red-500">{errors.name.message}</p>}
<Button
type="submit"
className="mt-4"
variant="darkCTA"
loading={isMutatingTeam}
loading={isUpdatingTeam}
disabled={isTeamNameInputEmpty || currentTeamName === previousTeamName}>
Update
</Button>

View File

@@ -0,0 +1,218 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { createInviteToken } from "@formbricks/lib/jwt";
import { AuthenticationError, AuthorizationError, ValidationError } from "@formbricks/types/v1/errors";
import {
deleteInvite,
getInviteToken,
inviteUser,
resendInvite,
updateInvite,
} from "@formbricks/lib/services/invite";
import {
deleteMembership,
getMembershipsByUserId,
getMembershipByUserIdTeamId,
transferOwnership,
updateMembership,
} from "@formbricks/lib/services/membership";
import { deleteTeam, updateTeam } from "@formbricks/lib/services/team";
import { TInviteUpdateInput } from "@formbricks/types/v1/invites";
import { TMembershipRole, TMembershipUpdateInput } from "@formbricks/types/v1/memberships";
import { TTeamUpdateInput } from "@formbricks/types/v1/teams";
import { getServerSession } from "next-auth";
import { hasTeamAccess, hasTeamAuthority, hasTeamOwnership, isOwner } from "@formbricks/lib/auth";
import { env } from "@/env.mjs";
export const updateTeamAction = async (teamId: string, data: TTeamUpdateInput) => {
return await updateTeam(teamId, data);
};
export const updateMembershipAction = async (
userId: string,
teamId: string,
data: TMembershipUpdateInput
) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
return await updateMembership(userId, teamId, data);
};
export const updateInviteAction = async (inviteId: string, teamId: string, data: TInviteUpdateInput) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
return await updateInvite(inviteId, data);
};
export const deleteInviteAction = async (inviteId: string, teamId: string) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
return await deleteInvite(inviteId);
};
export const deleteMembershipAction = async (userId: string, teamId: string) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
if (userId === session.user.id) {
throw new AuthenticationError("You cannot delete yourself from the team");
}
return await deleteMembership(userId, teamId);
};
export const leaveTeamAction = async (teamId: string) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const membership = await getMembershipByUserIdTeamId(session.user.id, teamId);
if (!membership) {
throw new AuthenticationError("Not a member of this team");
}
if (membership.role === "owner") {
throw new ValidationError("You cannot leave a team you own");
}
const memberships = await getMembershipsByUserId(session.user.id);
if (!memberships || memberships?.length <= 1) {
throw new ValidationError("You cannot leave the only team you are a member of");
}
await deleteMembership(session.user.id, teamId);
};
export const createInviteTokenAction = async (inviteId: string) => {
const { email } = await getInviteToken(inviteId);
const inviteToken = createInviteToken(inviteId, email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
};
export const resendInviteAction = async (inviteId: string) => {
return await resendInvite(inviteId);
};
export const inviteUserAction = async (
teamId: string,
email: string,
name: string,
role: TMembershipRole
) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
if (env.NEXT_PUBLIC_INVITE_DISABLED === "1") {
throw new AuthenticationError("Invite disabled");
}
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
const invite = await inviteUser({
teamId,
currentUser: { id: session.user.id, name: session.user.name },
invitee: {
email,
name,
role,
},
});
return invite;
};
export const transferOwnershipAction = async (teamId: string, newOwnerId: string) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const hasAccess = await hasTeamAccess(session.user.id, teamId);
if (!hasAccess) {
throw new AuthorizationError("Not authorized");
}
const isUserOwner = await isOwner(session.user.id, teamId);
if (!isUserOwner) {
throw new AuthorizationError("Not authorized");
}
if (newOwnerId === session.user.id) {
throw new ValidationError("You are already the owner of this team");
}
const membership = await getMembershipByUserIdTeamId(newOwnerId, teamId);
if (!membership) {
throw new ValidationError("User is not a member of this team");
}
await transferOwnership(session.user.id, newOwnerId, teamId);
};
export const deleteTeamAction = async (teamId: string) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserTeamOwner = await hasTeamOwnership(session.user.id, teamId);
if (!isUserTeamOwner) {
throw new AuthorizationError("Not authorized");
}
return await deleteTeam(teamId);
};

View File

@@ -0,0 +1,80 @@
import { Skeleton } from "@formbricks/ui";
function LoadingCard({
title,
description,
skeleton,
}: {
title: string;
description: string;
skeleton: React.ReactNode;
}) {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">{skeleton}</div>
</div>
);
}
export default function Loading() {
const cards = [
{
title: "Manage members",
description: "Add or remove members in your team",
skeleton: (
<div className="flex flex-col space-y-4 p-4">
<div className="flex items-center justify-end gap-4">
<Skeleton className="h-12 w-40 rounded-lg" />
<Skeleton className="h-12 w-40 rounded-lg" />
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2"></div>
<div className="col-span-5">Fullname</div>
<div className="col-span-5">Email</div>
<div className="col-span-3">Role</div>
<div className="col-span-5"></div>
</div>
<div className="h-10"></div>
</div>
</div>
),
},
{
title: "Team Name",
description: "Give your team a descriptive name",
skeleton: (
<div className="flex flex-col p-4">
<Skeleton className="mb-2 h-5 w-32" />
<Skeleton className="mb-4 h-12 w-96 rounded-lg" />
<Skeleton className="h-12 w-36 rounded-lg" />
</div>
),
},
{
title: "Delete account",
description: "Delete your account with all of your personal information and data.",
skeleton: (
<div className="flex flex-col p-4">
<Skeleton className="mb-2 h-5 w-full" />
<Skeleton className="h-12 w-36 rounded-lg" />
</div>
),
},
];
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</div>
);
}

View File

@@ -1,23 +1,95 @@
import TeamActions from "@/app/(app)/environments/[environmentId]/settings/members/EditMemberships/TeamActions";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getMembershipsByUserId, getMembershipByUserIdTeamId } from "@formbricks/lib/services/membership";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
import { Skeleton } from "@formbricks/ui";
import { getServerSession } from "next-auth";
import { Suspense } from "react";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import DeleteTeam from "./DeleteTeam";
import { EditMemberships } from "./EditMemberships";
import EditTeamName from "./EditTeamName";
import DeleteTeam from "./DeleteTeam";
export default function MembersSettingsPage({ params }) {
const MembersLoading = () => (
<div className="rounded-lg border border-slate-200">
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2"></div>
<div className="col-span-5">Fullname</div>
<div className="col-span-5">Email</div>
<div className="col-span-3">Role</div>
<div className="col-span-5"></div>
</div>
<div className="p-4">
{[1, 2, 3].map((_) => (
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-white p-4 text-left text-sm font-semibold text-slate-900">
<Skeleton className="col-span-2 h-10 w-10 rounded-full" />
<Skeleton className="col-span-5 h-8 w-24" />
<Skeleton className="col-span-5 h-8 w-24" />
<Skeleton className="col-span-3 h-8 w-24" />
</div>
))}
</div>
</div>
);
export default async function MembersSettingsPage({ params }: { params: { environmentId: string } }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthenticated");
}
const team = await getTeamByEnvironmentId(params.environmentId);
if (!team) {
throw new Error("Team not found");
}
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const userMemberships = await getMembershipsByUserId(session.user.id);
const isDeleteDisabled = userMemberships.length <= 1;
const currentUserRole = currentUserMembership?.role;
const isLeaveTeamDisabled = userMemberships.length <= 1;
const isUserAdminOrOwner = currentUserRole === "admin" || currentUserRole === "owner";
return (
<div>
<SettingsTitle title="Team" />
<SettingsCard title="Manage members" description="Add or remove members in your team.">
<EditMemberships environmentId={params.environmentId} />
{currentUserRole && (
<TeamActions
team={team}
isAdminOrOwner={isUserAdminOrOwner}
role={currentUserRole}
isLeaveTeamDisabled={isLeaveTeamDisabled}
/>
)}
{currentUserMembership && (
<Suspense fallback={<MembersLoading />}>
<EditMemberships
team={team}
currentUserId={session.user?.id}
allMemberships={userMemberships}
currentUserMembership={currentUserMembership}
/>
</Suspense>
)}
</SettingsCard>
<SettingsCard title="Team Name" description="Give your team a descriptive name.">
<EditTeamName environmentId={params.environmentId} />
<EditTeamName team={team} environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
title="Delete Team"
description="Delete team with all its products including all surveys, responses, people, actions and attributes">
<DeleteTeam environmentId={params.environmentId} />
<DeleteTeam
team={team}
isDeleteDisabled={isDeleteDisabled}
isUserOwner={currentUserRole === "owner"}
/>
</SettingsCard>
</div>
);

View File

@@ -0,0 +1,32 @@
import { getProducts } from "@formbricks/lib/services/product";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
import { TProduct } from "@formbricks/types/v1/product";
import DeleteProductRender from "@/app/(app)/environments/[environmentId]/settings/product/DeleteProductRender";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
type DeleteProductProps = {
environmentId: string;
product: TProduct;
};
export default async function DeleteProduct({ environmentId, product }: DeleteProductProps) {
const session = await getServerSession(authOptions);
const team = await getTeamByEnvironmentId(environmentId);
const availableProducts = team ? await getProducts(team.id) : null;
const role = team ? session?.user.teams.find((foundTeam) => foundTeam.id === team.id)?.role : null;
const availableProductsLength = availableProducts ? availableProducts.length : 0;
const isUserAdminOrOwner = role === "admin" || role === "owner";
const isDeleteDisabled = availableProductsLength <= 1 || !isUserAdminOrOwner;
return (
<DeleteProductRender
isDeleteDisabled={isDeleteDisabled}
isUserAdminOrOwner={isUserAdminOrOwner}
product={product}
environmentId={environmentId}
userId={session?.user.id ?? ""}
/>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { deleteProductAction } from "@/app/(app)/environments/[environmentId]/settings/product/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { truncate } from "@/lib/utils";
import { TProduct } from "@formbricks/types/v1/product";
import { Button } from "@formbricks/ui";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import toast from "react-hot-toast";
type DeleteProductRenderProps = {
environmentId: string;
isDeleteDisabled: boolean;
isUserAdminOrOwner: boolean;
product: TProduct;
userId: string;
};
const DeleteProductRender: React.FC<DeleteProductRenderProps> = ({
environmentId,
isDeleteDisabled,
isUserAdminOrOwner,
product,
userId,
}) => {
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const handleDeleteProduct = async () => {
try {
const deletedProduct = await deleteProductAction(environmentId, userId, product.id);
if (!!deletedProduct?.id) {
toast.success("Product deleted successfully.");
router.push("/");
}
} catch (err) {
toast.error("Could not delete product.");
setIsDeleteDialogOpen(false);
}
};
return (
<div>
{!isDeleteDisabled && (
<div>
<p className="text-sm text-slate-900">
Delete {truncate(product.name, 30)}
&nbsp;incl. all surveys, responses, people, actions and attributes.{" "}
<strong>This action cannot be undone.</strong>
</p>
<Button
disabled={isDeleteDisabled}
variant="warn"
className={`mt-4 ${isDeleteDisabled ? "ring-grey-500 ring-1 ring-offset-1" : ""}`}
onClick={() => setIsDeleteDialogOpen(true)}>
Delete
</Button>
</div>
)}
{isDeleteDisabled && (
<p className="text-sm text-red-700">
{!isUserAdminOrOwner
? "Only Admin or Owners can delete products."
: "This is your only product, it cannot be deleted. Create a new product first."}
</p>
)}
<DeleteDialog
deleteWhat="Product"
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteProduct}
text={`Are you sure you want to delete "${truncate(
product.name,
30
)}"? This action cannot be undone.`}
/>
</div>
);
};
export default DeleteProductRender;

View File

@@ -0,0 +1,65 @@
"use client";
import { updateProductAction } from "./actions";
import { TProduct } from "@formbricks/types/v1/product";
import { useRouter } from "next/navigation";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { Button, Input, Label } from "@formbricks/ui";
type TEditProductName = {
name: string;
};
type EditProductNameProps = {
product: TProduct;
environmentId: string;
};
const EditProductName: React.FC<EditProductNameProps> = ({ product, environmentId }) => {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TEditProductName>({
defaultValues: {
name: product.name,
},
});
const updateProduct: SubmitHandler<TEditProductName> = async (data) => {
try {
await updateProductAction(environmentId, product.id, data);
toast.success("Product name updated successfully.");
router.refresh();
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
return (
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(updateProduct)}>
<Label htmlFor="fullname">What&apos;s your product called?</Label>
<Input
type="text"
id="fullname"
defaultValue={product.name}
{...register("name", { required: { value: true, message: "Product name can't be empty" } })}
/>
{errors?.name ? (
<div className="my-2">
<p className="text-xs text-red-500">{errors?.name?.message}</p>
</div>
) : null}
<Button type="submit" variant="darkCTA" className="mt-4">
Update
</Button>
</form>
);
};
export default EditProductName;

View File

@@ -0,0 +1,74 @@
"use client";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { Button, Input, Label } from "@formbricks/ui";
import { TProduct } from "@formbricks/types/v1/product";
import { updateProductAction } from "./actions";
type EditWaitingTimeFormValues = {
recontactDays: number;
};
type EditWaitingTimeProps = {
environmentId: string;
product: TProduct;
};
const EditWaitingTime: React.FC<EditWaitingTimeProps> = ({ product, environmentId }) => {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<EditWaitingTimeFormValues>({
defaultValues: {
recontactDays: product.recontactDays,
},
});
const updateWaitingTime: SubmitHandler<EditWaitingTimeFormValues> = async (data) => {
try {
await updateProductAction(environmentId, product.id, data);
toast.success("Waiting period updated successfully.");
router.refresh();
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
return (
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(updateWaitingTime)}>
<Label htmlFor="recontactDays">Wait X days before showing next survey:</Label>
<Input
type="number"
id="recontactDays"
defaultValue={product.recontactDays}
{...register("recontactDays", {
min: { value: 0, message: "Must be a positive number" },
max: { value: 365, message: "Must be less than 365" },
valueAsNumber: true,
required: {
value: true,
message: "Required",
},
})}
/>
{errors?.recontactDays ? (
<div className="my-2">
<p className="text-xs text-red-500">{errors?.recontactDays?.message}</p>
</div>
) : null}
<Button type="submit" variant="darkCTA" className="mt-4">
Update
</Button>
</form>
);
};
export default EditWaitingTime;

View File

@@ -0,0 +1,84 @@
"use server";
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/services/product";
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/services/membership";
export const updateProductAction = async (
environmentId: string,
productId: string,
data: Partial<TProductUpdateInput>
): Promise<TProduct> => {
const session = await getServerSession();
if (!session?.user) {
throw new AuthenticationError("Not authenticated");
}
// get the environment from service and check if the user is allowed to update the product
let environment: TEnvironment | null = null;
try {
environment = await getEnvironment(environmentId);
if (!environment) {
throw new ResourceNotFoundError("Environment", "Environment not found");
}
} catch (err) {
throw err;
}
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
throw new AuthenticationError("You don't have access to this environment");
}
const updatedProduct = await updateProduct(productId, data);
return updatedProduct;
};
export const deleteProductAction = async (environmentId: string, userId: string, productId: string) => {
const session = await getServerSession();
if (!session?.user) {
throw new AuthenticationError("Not authenticated");
}
// get the environment from service and check if the user is allowed to update the product
let environment: TEnvironment | null = null;
try {
environment = await getEnvironment(environmentId);
if (!environment) {
throw new ResourceNotFoundError("Environment", "Environment not found");
}
} catch (err) {
throw err;
}
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
throw new AuthenticationError("You don't have access to this environment");
}
const team = await getTeamByEnvironmentId(environmentId);
const membership = team ? await getMembershipByUserIdTeamId(userId, team.id) : null;
if (membership?.role !== "admin" && membership?.role !== "owner") {
throw new AuthenticationError("You are not allowed to delete products.");
}
const availableProducts = team ? await getProducts(team.id) : null;
if (!!availableProducts && availableProducts?.length <= 1) {
throw new Error("You can't delete the last product in the environment.");
}
const deletedProduct = await deleteProduct(productId);
return deletedProduct;
};

View File

@@ -1,206 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { deleteProduct, useProduct } from "@/lib/products/products";
import { truncate } from "@/lib/utils";
import { useEnvironment } from "@/lib/environments/environments";
import { useProductMutation } from "@/lib/products/mutateProducts";
import { Button, ErrorComponent, Input, Label } from "@formbricks/ui";
import { useProfile } from "@/lib/profile";
import { useMembers } from "@/lib/members";
export function EditProductName({ environmentId }) {
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { isMutatingProduct, triggerProductMutate } = useProductMutation(environmentId);
const { mutateEnvironment } = useEnvironment(environmentId);
const { register, handleSubmit, control, setValue } = useForm();
const productName = useWatch({
control,
name: "name",
});
const isProductNameInputEmpty = !productName?.trim();
const currentProductName = productName?.trim().toLowerCase() ?? "";
const previousProductName = product?.name?.trim().toLowerCase() ?? "";
useEffect(() => {
setValue("name", product?.name ?? "");
}, [product?.name]);
if (isLoadingProduct) {
return <LoadingSpinner />;
}
if (isErrorProduct) {
return <ErrorComponent />;
}
return (
<form
className="w-full max-w-sm items-center"
onSubmit={handleSubmit((data) => {
triggerProductMutate(data)
.then(() => {
toast.success("Product name updated successfully.");
mutateEnvironment();
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
})}>
<Label htmlFor="fullname">What&apos;s your product called?</Label>
<Input
type="text"
id="fullname"
defaultValue={product.name}
{...register("name")}
className={isProductNameInputEmpty ? "border-red-300 focus:border-red-300" : ""}
/>
<Button
type="submit"
variant="darkCTA"
className="mt-4"
loading={isMutatingProduct}
disabled={isProductNameInputEmpty || currentProductName === previousProductName}>
Update
</Button>
</form>
);
}
export function EditWaitingTime({ environmentId }) {
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { isMutatingProduct, triggerProductMutate } = useProductMutation(environmentId);
const { register, handleSubmit } = useForm();
if (isLoadingProduct) {
return <LoadingSpinner />;
}
if (isErrorProduct) {
return <ErrorComponent />;
}
return (
<form
className="w-full max-w-sm items-center"
onSubmit={handleSubmit((data) => {
triggerProductMutate(data)
.then(() => {
toast.success("Waiting period updated successfully.");
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
})}>
<Label htmlFor="recontactDays">Wait X days before showing next survey:</Label>
<Input
type="number"
id="recontactDays"
defaultValue={product.recontactDays}
{...register("recontactDays", {
min: 0,
max: 365,
valueAsNumber: true,
})}
/>
<Button type="submit" variant="darkCTA" className="mt-4" loading={isMutatingProduct}>
Update
</Button>
</form>
);
}
export function DeleteProduct({ environmentId }) {
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingProduct, setDeletingProduct] = useState(false);
const { profile } = useProfile();
const { team } = useMembers(environmentId);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { environment } = useEnvironment(environmentId);
const availableProducts = environment?.availableProducts?.length;
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
const isUserAdminOrOwner = role === "admin" || role === "owner";
const isDeleteDisabled = availableProducts <= 1 || !isUserAdminOrOwner;
if (isLoadingProduct) {
return <LoadingSpinner />;
}
if (isErrorProduct) {
return <ErrorComponent />;
}
const handleDeleteProduct = async () => {
if (environment?.availableProducts?.length <= 1) {
toast.error("Cannot delete product. Your team needs at least 1.");
setIsDeleteDialogOpen(false);
return;
}
setDeletingProduct(true);
const deleteProductRes = await deleteProduct(environmentId);
setDeletingProduct(false);
if (deleteProductRes?.id?.length > 0) {
toast.success("Product deleted successfully.");
router.push("/");
} else if (deleteProductRes?.message?.length > 0) {
toast.error(deleteProductRes.message);
setIsDeleteDialogOpen(false);
} else {
toast.error("Error deleting product. Please try again.");
}
};
return (
<div>
{!isDeleteDisabled && (
<div>
<p className="text-sm text-slate-900">
Delete {truncate(product?.name, 30)}
&nbsp;incl. all surveys, responses, people, actions and attributes.{" "}
<strong>This action cannot be undone.</strong>
</p>
<Button
disabled={isDeleteDisabled}
variant="warn"
className={`mt-4 ${isDeleteDisabled ? "ring-grey-500 ring-1 ring-offset-1" : ""}`}
onClick={() => setIsDeleteDialogOpen(true)}>
Delete
</Button>
</div>
)}
{isDeleteDisabled && (
<p className="text-sm text-red-700">
{!isUserAdminOrOwner
? "Only Admin or Owners can delete products."
: "This is your only product, it cannot be deleted. Create a new product first."}
</p>
)}
<DeleteDialog
deleteWhat="Product"
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
isDeleting={deletingProduct}
onDelete={handleDeleteProduct}
text={`Are you sure you want to delete "${truncate(
product?.name,
30
)}"? This action cannot be undone.`}
/>
</div>
);
}

View File

@@ -0,0 +1,49 @@
function LoadingCard({ title, description, skeletonLines }) {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
{skeletonLines.map((line, index) => (
<div key={index} className="mt-4">
<div className={`animate-pulse rounded-full bg-gray-200 ${line.classes}`}></div>
</div>
))}
</div>
</div>
</div>
);
}
export default function Loading() {
const cards = [
{
title: "Product Name",
description: "Change your products name.",
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
},
{
title: "Recontact Waiting Time",
description: "Control how frequently users can be surveyed across all surveys.",
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
},
{
title: "Delete Product",
description:
"Delete product with all surveys, responses, people, actions and attributes. This cannot be undone.",
skeletonLines: [{ classes: "h-4 w-96" }, { classes: "h-8 w-24" }],
},
];
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Product Settings</h2>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</div>
);
}

View File

@@ -1,23 +1,38 @@
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { EditProductName, EditWaitingTime, DeleteProduct } from "./editProduct";
export default function ProfileSettingsPage({ params }) {
import EditProductName from "./EditProductName";
import EditWaitingTime from "./EditWaitingTime";
import DeleteProduct from "./DeleteProduct";
import { getEnvironment } from "@formbricks/lib/services/environment";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const [, product] = await Promise.all([
getEnvironment(params.environmentId),
getProductByEnvironmentId(params.environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
return (
<div>
<SettingsTitle title="Product Settings" />
<SettingsCard title="Product Name" description="Change your products name.">
<EditProductName environmentId={params.environmentId} />
<EditProductName environmentId={params.environmentId} product={product} />
</SettingsCard>
<SettingsCard
title="Recontact Waiting Time"
description="Control how frequently users can be surveyed across all surveys.">
<EditWaitingTime environmentId={params.environmentId} />
<EditWaitingTime environmentId={params.environmentId} product={product} />
</SettingsCard>
<SettingsCard
title="Delete Product"
description="Delete product with all surveys, responses, people, actions and attributes. This cannot be undone.">
<DeleteProduct environmentId={params.environmentId} />
<DeleteProduct environmentId={params.environmentId} product={product} />
</SettingsCard>
</div>
);

View File

@@ -1,5 +1,4 @@
"use client";
import Modal from "@/components/preview/Modal";
import TabOption from "@/components/preview/TabOption";
import { SurveyInline } from "@/components/shared/Survey";
@@ -16,7 +15,6 @@ interface PreviewSurveyProps {
survey: TSurvey | Survey;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
environmentId: string;
previewType?: "modal" | "fullwidth" | "email";
product: TProduct;
environment: TEnvironment;

View File

@@ -19,9 +19,12 @@ export default async function SurveysList({ environmentId }: { environmentId: st
}
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
const environments: TEnvironment[] = await getEnvironments(product.id);
const otherEnvironment = environments.find((e) => e.type !== environment.type);
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
const totalSubmissions = surveys.reduce((acc, survey) => acc + (survey.analytics?.numResponses || 0), 0);
if (surveys.length === 0) {

View File

@@ -1,16 +1,20 @@
"use client";
import { updateResponseNoteAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
import {
resolveResponseNoteAction,
updateResponseNoteAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
import { useProfile } from "@/lib/profile";
import { addResponseNote } from "@/lib/responseNotes/responsesNotes";
import { cn } from "@formbricks/lib/cn";
import { timeSince } from "@formbricks/lib/time";
import { TResponseNote } from "@formbricks/types/v1/responses";
import { Button } from "@formbricks/ui";
import { MinusIcon, PencilIcon, PlusIcon } from "@heroicons/react/24/solid";
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { CheckIcon, PencilIcon, PlusIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { Maximize2Icon } from "lucide-react";
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useRef, useState } from "react";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
interface ResponseNotesProps {
@@ -35,6 +39,7 @@ export default function ResponseNotes({
const [noteText, setNoteText] = useState("");
const [isCreatingNote, setIsCreatingNote] = useState(false);
const [isUpdatingNote, setIsUpdatingNote] = useState(false);
const [isTextAreaOpen, setIsTextAreaOpen] = useState(true);
const [noteId, setNoteId] = useState("");
const divRef = useRef<HTMLDivElement>(null);
@@ -52,7 +57,22 @@ export default function ResponseNotes({
}
};
const handleResolveNote = (note: TResponseNote) => {
try {
resolveResponseNoteAction(note.id);
// when this was the last note, close the notes panel
if (unresolvedNotes.length === 1) {
setIsOpen(false);
}
router.refresh();
} catch (e) {
toast.error("An error occurred resolving a note");
setIsUpdatingNote(false);
}
};
const handleEditPencil = (note: TResponseNote) => {
setIsTextAreaOpen(true);
setNoteText(note.text);
setIsUpdatingNote(true);
setNoteId(note.id);
@@ -62,7 +82,7 @@ export default function ResponseNotes({
e.preventDefault();
setIsUpdatingNote(true);
try {
await updateResponseNoteAction(responseId, noteId, noteText);
await updateResponseNoteAction(noteId, noteText);
router.refresh();
setIsUpdatingNote(false);
setNoteText("");
@@ -78,15 +98,17 @@ export default function ResponseNotes({
}
}, [notes]);
const unresolvedNotes = useMemo(() => notes.filter((note) => !note.isResolved), [notes]);
return (
<div
className={clsx(
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
!isOpen && notes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !notes.length && "cursor-pointer bg-slate-50",
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
isOpen
? "-right-5 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
: notes.length
: unresolvedNotes.length
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
)}
@@ -98,9 +120,9 @@ export default function ResponseNotes({
<div
className={clsx(
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
notes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
)}>
{!notes.length ? (
{!unresolvedNotes.length ? (
<div className="flex items-center justify-end">
<div className="group flex items-center">
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">Note</h3>
@@ -112,7 +134,7 @@ export default function ResponseNotes({
</div>
)}
</div>
{!notes.length ? (
{!unresolvedNotes.length ? (
<div className="flex flex-1 items-center justify-end pr-3">
<span>
<PlusIcon className=" h-5 w-5 text-slate-400" />
@@ -125,20 +147,20 @@ export default function ResponseNotes({
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
<div className="flex items-center justify-between">
<div className="group flex items-center">
<h3 className="pb-1 text-sm text-slate-500">Note</h3>
<h3 className="pb-1 text-sm text-amber-500">Note</h3>
</div>
<button
className="h-6 w-6 cursor-pointer"
onClick={() => {
setIsOpen(!isOpen);
}}>
<MinusIcon className="h-5 w-5 text-amber-500 hover:text-amber-600" />
<Minimize2Icon className="h-5 w-5 text-amber-500 hover:text-amber-600" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto px-4 pt-2" ref={divRef}>
{notes.map((note) => (
<div className="mb-3" key={note.id}>
{unresolvedNotes.map((note) => (
<div className="group/notetext mb-3" key={note.id}>
<span className="block font-semibold text-slate-700">
{note.user.name}
<time
@@ -146,25 +168,62 @@ export default function ResponseNotes({
dateTime={timeSince(note.updatedAt.toISOString())}>
{timeSince(note.updatedAt.toISOString())}
</time>
{note.isEdited && (
<span className="ml-1 text-[12px] font-normal text-slate-500">{"(edited)"}</span>
)}
</span>
<div className="group/notetext flex items-center">
<span className="block pr-1 text-slate-700">{note.text}</span>
<div className="flex items-center">
<span className="block text-slate-700">{note.text}</span>
{profile.id === note.user.id && (
<button onClick={() => handleEditPencil(note)}>
<PencilIcon className=" h-3 w-3 text-gray-500 opacity-0 group-hover/notetext:opacity-100" />
<button
className="ml-auto hidden group-hover/notetext:block"
onClick={() => {
handleEditPencil(note);
}}>
<PencilIcon className="h-3 w-3 text-gray-500" />
</button>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
className="ml-2 hidden group-hover/notetext:block"
onClick={() => {
handleResolveNote(note);
}}>
<CheckIcon className="h-4 w-4 text-gray-500" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[45rem] break-all" side="left" sideOffset={5}>
<span className="text-slate-700">Resolve</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
</div>
<div className="h-[120px]">
<div className={clsx("absolute bottom-0 w-full px-3 pb-3", !notes.length && "absolute bottom-0")}>
<div
className={cn(
"h-[120px] transition-all duration-300",
!isTextAreaOpen && "pointer-events-none h-14"
)}>
<div
className={clsx(
"absolute bottom-0 w-full px-3 pb-3",
!unresolvedNotes.length && "absolute bottom-0"
)}>
<form onSubmit={isUpdatingNote ? handleNoteUpdate : handleNoteSubmission}>
<div className="mt-4">
<textarea
rows={2}
className="block w-full resize-none rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm"
className={cn(
"block w-full resize-none rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm",
!isTextAreaOpen && "scale-y-0 transition-all duration-1000",
!isTextAreaOpen && "translate-y-8 transition-all duration-300",
isTextAreaOpen && "scale-y-1 transition-all duration-1000",
isTextAreaOpen && "translate-y-0 transition-all duration-300"
)}
onChange={(e) => setNoteText(e.target.value)}
value={noteText}
autoFocus
@@ -178,10 +237,22 @@ export default function ResponseNotes({
}}
required></textarea>
</div>
<div className="mt-2 flex w-full justify-end">
<Button variant="darkCTA" size="sm" type="submit" loading={isCreatingNote}>
{isUpdatingNote ? "Save" : "Send"}
<div className="pointer-events-auto z-10 mt-2 flex w-full items-center justify-end">
<Button
variant="minimal"
type="button"
size="sm"
className={cn("mr-auto duration-300 ")}
onClick={() => {
setIsTextAreaOpen(!isTextAreaOpen);
}}>
{isTextAreaOpen ? "Hide" : "Show"}
</Button>
{isTextAreaOpen && (
<Button variant="darkCTA" size="sm" type="submit" loading={isCreatingNote}>
{isUpdatingNote ? "Save" : "Send"}
</Button>
)}
</div>
</form>
</div>

View File

@@ -167,7 +167,9 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
<RatingResponse scale={response.scale} answer={response.answer} range={response.range} />
</div>
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">{response.answer}</p>
<p className="ph-no-capture my-1 whitespace-pre-wrap font-semibold text-slate-700">
{response.answer}
</p>
)
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">

View File

@@ -1,7 +1,11 @@
"use server";
import { updateResponseNote } from "@formbricks/lib/services/responseNote";
import { updateResponseNote, resolveResponseNote } from "@formbricks/lib/services/responseNote";
export const updateResponseNoteAction = async (responseId: string, noteId: string, text: string) => {
await updateResponseNote(responseId, noteId, text);
export const updateResponseNoteAction = async (responseNoteId: string, text: string) => {
await updateResponseNote(responseNoteId, text);
};
export const resolveResponseNoteAction = async (responseNoteId: string) => {
await resolveResponseNote(responseNoteId);
};

View File

@@ -1,11 +1,11 @@
import { CTAQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyCTAQuestion } from "@formbricks/types/v1/surveys";
import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
interface CTASummaryProps {
questionSummary: QuestionSummary<CTAQuestion>;
questionSummary: QuestionSummary<TSurveyCTAQuestion>;
}
interface ChoiceResult {

View File

@@ -1,11 +1,11 @@
import { ConsentQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { TSurveyConsentQuestion } from "@formbricks/types/v1/surveys";
interface ConsentSummaryProps {
questionSummary: QuestionSummary<ConsentQuestion>;
questionSummary: QuestionSummary<TSurveyConsentQuestion>;
}
interface ChoiceResult {

View File

@@ -1,17 +1,17 @@
import {
MultipleChoiceMultiQuestion,
MultipleChoiceSingleQuestion,
QuestionType,
} from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { PersonAvatar, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import Link from "next/link";
import { truncate } from "@/lib/utils";
import {
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
} from "@formbricks/types/v1/surveys";
interface MultipleChoiceSummaryProps {
questionSummary: QuestionSummary<MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion>;
questionSummary: QuestionSummary<TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion>;
environmentId: string;
surveyType: string;
}

View File

@@ -1,11 +1,11 @@
import { NPSQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyNPSQuestion } from "@formbricks/types/v1/surveys";
import { HalfCircle, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
interface NPSSummaryProps {
questionSummary: QuestionSummary<NPSQuestion>;
questionSummary: QuestionSummary<TSurveyNPSQuestion>;
}
interface Result {

View File

@@ -1,13 +1,13 @@
import { truncate } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { OpenTextQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyOpenTextQuestion } from "@formbricks/types/v1/surveys";
import { PersonAvatar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
interface OpenTextSummaryProps {
questionSummary: QuestionSummary<OpenTextQuestion>;
questionSummary: QuestionSummary<TSurveyOpenTextQuestion>;
environmentId: string;
}

View File

@@ -3,10 +3,11 @@ import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { RatingResponse } from "../RatingResponse";
import { QuestionType, RatingQuestion } from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import { TSurveyRatingQuestion } from "@formbricks/types/v1/surveys";
interface RatingSummaryProps {
questionSummary: QuestionSummary<RatingQuestion>;
questionSummary: QuestionSummary<TSurveyRatingQuestion>;
}
interface ChoiceResult {

View File

@@ -1,18 +1,19 @@
import ConsentSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/ConsentSummary";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import {
QuestionType,
type CTAQuestion,
type ConsentQuestion,
type MultipleChoiceMultiQuestion,
type MultipleChoiceSingleQuestion,
type NPSQuestion,
type OpenTextQuestion,
type RatingQuestion,
} from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys";
import {
TSurvey,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyRatingQuestion,
} from "@formbricks/types/v1/surveys";
import CTASummary from "./CTASummary";
import MultipleChoiceSummary from "./MultipleChoiceSummary";
import NPSSummary from "./NPSSummary";
@@ -58,7 +59,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<OpenTextSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<OpenTextQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyOpenTextQuestion>}
environmentId={environmentId}
/>
);
@@ -72,7 +73,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
key={questionSummary.question.id}
questionSummary={
questionSummary as QuestionSummary<
MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
>
}
environmentId={environmentId}
@@ -84,7 +85,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<NPSSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<NPSQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyNPSQuestion>}
/>
);
}
@@ -92,7 +93,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<CTASummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<CTAQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyCTAQuestion>}
/>
);
}
@@ -100,7 +101,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<RatingSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<RatingQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyRatingQuestion>}
/>
);
}
@@ -108,7 +109,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<ConsentSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<ConsentQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyConsentQuestion>}
/>
);
}

View File

@@ -318,7 +318,7 @@ const CustomFilter = ({ environmentId, responses, survey, totalResponses }: Cust
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {

View File

@@ -99,7 +99,6 @@ const SummaryHeader = ({ surveyId, environmentId, survey, surveyBaseUrl }: Summa
{survey.status === "inProgress" && "In-progress"}
{survey.status === "paused" && "Paused"}
{survey.status === "completed" && "Completed"}
{survey.status === "archived" && "Archived"}
</span>
</div>
</DropdownMenuSubTrigger>

View File

@@ -1,13 +1,12 @@
import React from "react";
import LogicEditor from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor";
import UpdateQuestionId from "./UpdateQuestionId";
import { Question } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface AdvancedSettingsProps {
question: Question;
question: TSurveyQuestion;
questionIdx: number;
localSurvey: Survey;
localSurvey: TSurveyWithAnalytics;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
}

View File

@@ -1,15 +1,14 @@
"use client";
import { md } from "@formbricks/lib/markdownIt";
import type { CTAQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyCTAQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Editor, Input, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { useState } from "react";
import { BackButtonInput } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard";
interface CTAQuestionFormProps {
localSurvey: Survey;
question: CTAQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyCTAQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,14 +1,13 @@
"use client";
import { md } from "@formbricks/lib/markdownIt";
import type { ConsentQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyConsentQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Editor, Input, Label } from "@formbricks/ui";
import { useState } from "react";
interface ConsentQuestionFormProps {
localSurvey: Survey;
question: ConsentQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyConsentQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
isInValid: boolean;

View File

@@ -1,13 +1,13 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import * as Collapsible from "@radix-ui/react-collapsible";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface EditThankYouCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId: string | null;
}
@@ -41,7 +41,7 @@ export default function EditThankYouCard({
return (
<div
className={cn(
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
open ? "scale-100 shadow-lg " : "scale-97 shadow-md",
"flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
)}>
<div
@@ -86,7 +86,7 @@ export default function EditThankYouCard({
)}
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="px-4 pb-4">
<Collapsible.CollapsibleContent className="px-4 pb-6">
<form>
<div className="mt-3">
<Label htmlFor="headline">Headline</Label>

View File

@@ -1,8 +1,5 @@
"use client";
import { useEnvironment } from "@/lib/environments/environments";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { Badge, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import {
CheckCircleIcon,
@@ -15,17 +12,18 @@ import {
import * as Collapsible from "@radix-ui/react-collapsible";
import Link from "next/link";
import { useEffect, useState } from "react";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
interface HowToSendCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
environmentId: string;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environment: TEnvironment;
}
export default function HowToSendCard({ localSurvey, setLocalSurvey, environmentId }: HowToSendCardProps) {
export default function HowToSendCard({ localSurvey, setLocalSurvey, environment }: HowToSendCardProps) {
const [open, setOpen] = useState(localSurvey.type === "web" ? false : true);
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const { environment } = useEnvironment(environmentId);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
@@ -150,7 +148,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
<p className="text-xs font-normal">
Follow the{" "}
<Link
href={`/environments/${environmentId}/settings/setup`}
href={`/environments/${environment.id}/settings/setup`}
className="underline hover:text-amber-900"
target="_blank">
set up guide

View File

@@ -1,5 +1,5 @@
import { Logic, LogicCondition, Question, QuestionType } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { LogicCondition, QuestionType } from "@formbricks/types/questions";
import { TSurveyLogic, TSurveyQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import {
Button,
DropdownMenu,
@@ -23,9 +23,9 @@ import { useMemo } from "react";
import { BsArrowDown, BsArrowReturnRight } from "react-icons/bs";
interface LogicEditorProps {
localSurvey: Survey;
localSurvey: TSurveyWithAnalytics;
questionIdx: number;
question: Question;
question: TSurveyQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
}
@@ -48,7 +48,7 @@ export default function LogicEditor({
if ("choices" in question) {
return question.choices.map((choice) => choice.label);
} else if ("range" in question) {
return Array.from({ length: question.range }, (_, i) => (i + 1).toString());
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
} else if (question.type === QuestionType.NPS) {
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
}
@@ -141,7 +141,7 @@ export default function LogicEditor({
};
const addLogic = () => {
const newLogic: Logic[] = !question.logic ? [] : question.logic;
const newLogic: TSurveyLogic[] = !question.logic ? [] : question.logic;
newLogic.push({
condition: undefined,
value: undefined,

View File

@@ -1,5 +1,3 @@
import type { MultipleChoiceMultiQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import {
Button,
Input,
@@ -14,10 +12,11 @@ import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
import { TSurveyMultipleChoiceMultiQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface OpenQuestionFormProps {
localSurvey: Survey;
question: MultipleChoiceMultiQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyMultipleChoiceMultiQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,5 +1,3 @@
import type { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import {
Button,
Input,
@@ -14,10 +12,11 @@ import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
import { TSurveyMultipleChoiceSingleQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface OpenQuestionFormProps {
localSurvey: Survey;
question: MultipleChoiceSingleQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyMultipleChoiceSingleQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,12 +1,11 @@
import type { NPSQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyNPSQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
interface NPSQuestionFormProps {
localSurvey: Survey;
question: NPSQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyNPSQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,12 +1,11 @@
import type { OpenTextQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyOpenTextQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
interface OpenQuestionFormProps {
localSurvey: Survey;
question: OpenTextQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyOpenTextQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -4,7 +4,6 @@ import AdvancedSettings from "@/app/(app)/environments/[environmentId]/surveys/[
import { getQuestionTypeName } from "@/lib/questions";
import { cn } from "@formbricks/lib/cn";
import { QuestionType } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import {
ChatBubbleBottomCenterTextIcon,
@@ -28,9 +27,10 @@ import NPSQuestionForm from "./NPSQuestionForm";
import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionMenu";
import RatingQuestionForm from "./RatingQuestionForm";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface QuestionCardProps {
localSurvey: Survey;
localSurvey: TSurveyWithAnalytics;
questionIdx: number;
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;

View File

@@ -1,6 +1,6 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import React from "react";
import { createId } from "@paralleldrive/cuid2";
import { useMemo, useState } from "react";
import { DragDropContext } from "react-beautiful-dnd";
@@ -11,10 +11,11 @@ import QuestionCard from "./QuestionCard";
import { StrictModeDroppable } from "./StrictModeDroppable";
import { Question } from "@formbricks/types/questions";
import { validateQuestion } from "./Validation";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface QuestionsViewProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
activeQuestionId: string | null;
setActiveQuestionId: (questionId: string | null) => void;
environmentId: string;
@@ -40,7 +41,11 @@ export default function QuestionsView({
const [backButtonLabel, setbackButtonLabel] = useState(null);
const handleQuestionLogicChange = (survey: Survey, compareId: string, updatedId: string): Survey => {
const handleQuestionLogicChange = (
survey: TSurveyWithAnalytics,
compareId: string,
updatedId: string
): TSurveyWithAnalytics => {
survey.questions.forEach((question) => {
if (!question.logic) return;
question.logic.forEach((rule) => {
@@ -105,7 +110,7 @@ export default function QuestionsView({
const deleteQuestion = (questionIdx: number) => {
const questionId = localSurvey.questions[questionIdx].id;
let updatedSurvey: Survey = JSON.parse(JSON.stringify(localSurvey));
let updatedSurvey: TSurveyWithAnalytics = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions.splice(questionIdx, 1);
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");

View File

@@ -1,14 +1,13 @@
import type { RatingQuestion } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { FaceSmileIcon, HashtagIcon, StarIcon } from "@heroicons/react/24/outline";
import Dropdown from "./RatingTypeDropdown";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { TSurveyRatingQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface RatingQuestionFormProps {
localSurvey: Survey;
question: RatingQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyRatingQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { AdvancedOptionToggle, Badge, Input, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
@@ -33,8 +33,8 @@ const displayOptions: DisplayOption[] = [
];
interface RecontactOptionsCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environmentId: string;
}

View File

@@ -1,6 +1,6 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { AdvancedOptionToggle, DatePicker, Input, Label } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
@@ -8,8 +8,8 @@ import { useEffect, useState } from "react";
import toast from "react-hot-toast";
interface ResponseOptionsCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
}
export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: ResponseOptionsCardProps) {
@@ -17,7 +17,7 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const autoComplete = localSurvey.autoComplete !== null;
const [redirectToggle, setRedirectToggle] = useState(false);
const [surveyCloseOnDateToggle, setSurveyCloseOnDateToggle] = useState(false);
useState;
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
@@ -95,6 +95,7 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
subheading?: string;
}) => {
const message = {
enabled: surveyCloseOnDateToggle,
heading: heading ?? surveyClosedMessage.heading,
subheading: subheading ?? surveyClosedMessage.subheading,
};
@@ -149,16 +150,16 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const handleCheckMark = () => {
if (autoComplete) {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: null };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoComplete: null };
setLocalSurvey(updatedSurvey);
} else {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: 25 };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoComplete: 25 };
setLocalSurvey(updatedSurvey);
}
};
const handleInputResponse = (e) => {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: parseInt(e.target.value) };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoComplete: parseInt(e.target.value) };
setLocalSurvey(updatedSurvey);
};
@@ -168,11 +169,11 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
return;
}
const inputResponses = localSurvey?._count?.responses || 0;
const inputResponses = localSurvey.analytics.numResponses || 0;
if (parseInt(e.target.value) <= inputResponses) {
toast.error(
`Response limit needs to exceed number of received responses (${localSurvey?._count?.responses}).`
`Response limit needs to exceed number of received responses (${localSurvey.analytics.numResponses}).`
);
return;
}
@@ -211,7 +212,11 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
<Input
autoFocus
type="number"
min={localSurvey?._count?.responses ? (localSurvey?._count?.responses + 1).toString() : "1"}
min={
localSurvey?.analytics?.numResponses
? (localSurvey?.analytics?.numResponses + 1).toString()
: "1"
}
id="autoCompleteResponses"
value={localSurvey.autoComplete?.toString()}
onChange={handleInputResponse}

View File

@@ -1,35 +1,44 @@
import type { Survey } from "@formbricks/types/surveys";
import HowToSendCard from "./HowToSendCard";
import RecontactOptionsCard from "./RecontactOptionsCard";
import ResponseOptionsCard from "./ResponseOptionsCard";
import WhenToSendCard from "./WhenToSendCard";
import WhoToSendCard from "./WhoToSendCard";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
interface SettingsViewProps {
environmentId: string;
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
environment: TEnvironment;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
}
export default function SettingsView({ environmentId, localSurvey, setLocalSurvey }: SettingsViewProps) {
export default function SettingsView({
environment,
localSurvey,
setLocalSurvey,
actionClasses,
attributeClasses,
}: SettingsViewProps) {
return (
<div className="mt-12 space-y-3 p-5">
<HowToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
<HowToSendCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} environment={environment} />
<WhoToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
environmentId={environment.id}
attributeClasses={attributeClasses}
/>
<WhenToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
environmentId={environment.id}
actionClasses={actionClasses}
/>
<ResponseOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
@@ -37,7 +46,7 @@ export default function SettingsView({ environmentId, localSurvey, setLocalSurve
<RecontactOptionsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
environmentId={environment.id}
/>
</div>
);

View File

@@ -1,10 +1,6 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProduct } from "@/lib/products/products";
import { useSurvey } from "@/lib/surveys/surveys";
import type { Survey } from "@formbricks/types/surveys";
import { ErrorComponent } from "@formbricks/ui";
import React from "react";
import { useEffect, useState } from "react";
import PreviewSurvey from "../../PreviewSurvey";
import QuestionsAudienceTabs from "./QuestionsSettingsTabs";
@@ -12,24 +8,31 @@ import QuestionsView from "./QuestionsView";
import SettingsView from "./SettingsView";
import SurveyMenuBar from "./SurveyMenuBar";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TProduct } from "@formbricks/types/v1/product";
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
import { ErrorComponent } from "@formbricks/ui";
interface SurveyEditorProps {
environmentId: string;
surveyId: string;
survey: TSurveyWithAnalytics;
product: TProduct;
environment: TEnvironment;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
}
export default function SurveyEditor({
environmentId,
surveyId,
survey,
product,
environment,
actionClasses,
attributeClasses,
}: SurveyEditorProps): JSX.Element {
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<Survey | null>();
const [localSurvey, setLocalSurvey] = useState<TSurveyWithAnalytics | null>();
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId, true);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
useEffect(() => {
if (survey) {
@@ -41,59 +44,65 @@ export default function SurveyEditor({
}
}, [survey]);
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
return <LoadingSpinner />;
}
// when the survey type changes, we need to reset the active question id to the first question
useEffect(() => {
if (survey?.questions?.length > 0) {
setActiveQuestionId(survey.questions[0].id);
}
}, [localSurvey?.type]);
if (isErrorSurvey || isErrorProduct) {
if (!localSurvey) {
return <ErrorComponent />;
}
return (
<div className="flex h-full flex-col">
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
environmentId={environmentId}
environment={environment}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none">
<QuestionsAudienceTabs activeId={activeView} setActiveId={setActiveView} />
{activeView === "questions" ? (
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
<>
<div className="flex h-full flex-col">
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
environment={environment}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
product={product}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none">
<QuestionsAudienceTabs activeId={activeView} setActiveId={setActiveView} />
{activeView === "questions" ? (
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
environmentId={environment.id}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
/>
) : (
<SettingsView
environment={environment}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
activeQuestionId={activeQuestionId}
product={product}
environment={environment}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
/>
) : (
<SettingsView
environmentId={environmentId}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
product={product}
environment={environment}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
/>
</aside>
</aside>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,11 +1,9 @@
"use client";
import React from "react";
import AlertDialog from "@/components/shared/AlertDialog";
import DeleteDialog from "@/components/shared/DeleteDialog";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { useProduct } from "@/lib/products/products";
import { useSurveyMutation } from "@/lib/surveys/mutateSurveys";
import { deleteSurvey } from "@/lib/surveys/surveys";
import type { Survey } from "@formbricks/types/surveys";
import { Button, Input } from "@formbricks/ui";
import { ArrowLeftIcon, Cog8ToothIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
@@ -14,35 +12,37 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { validateQuestion } from "./Validation";
import { TSurvey, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { deleteSurveyAction, surveyMutateAction } from "./actions";
import { TProduct } from "@formbricks/types/v1/product";
import { TEnvironment } from "@formbricks/types/v1/environment";
interface SurveyMenuBarProps {
localSurvey: Survey;
survey: Survey;
setLocalSurvey: (survey: Survey) => void;
environmentId: string;
localSurvey: TSurveyWithAnalytics;
survey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environment: TEnvironment;
activeId: "questions" | "settings";
setActiveId: (id: "questions" | "settings") => void;
setInvalidQuestions: (invalidQuestions: String[]) => void;
product: TProduct;
}
export default function SurveyMenuBar({
localSurvey,
survey,
environmentId,
environment,
setLocalSurvey,
activeId,
setActiveId,
setInvalidQuestions,
product,
}: SurveyMenuBarProps) {
const router = useRouter();
const { triggerSurveyMutate, isMutatingSurvey } = useSurveyMutation(environmentId, localSurvey.id);
const [audiencePrompt, setAudiencePrompt] = useState(true);
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const { product } = useProduct(environmentId);
const [isMutatingSurvey, setIsMutatingSurvey] = useState(false);
let faultyQuestions: String[] = [];
useEffect(() => {
@@ -73,9 +73,10 @@ export default function SurveyMenuBar({
setLocalSurvey(updatedSurvey);
};
const deleteSurveyAction = async (survey) => {
const deleteSurvey = async (surveyId) => {
try {
await deleteSurvey(environmentId, survey.id);
await deleteSurveyAction(surveyId);
router.refresh();
setDeleteDialogOpen(false);
router.back();
} catch (error) {
@@ -84,7 +85,10 @@ export default function SurveyMenuBar({
};
const handleBack = () => {
if (localSurvey.createdAt === localSurvey.updatedAt && localSurvey.status === "draft") {
const createdAt = new Date(localSurvey.createdAt).getTime();
const updatedAt = new Date(localSurvey.updatedAt).getTime();
if (createdAt === updatedAt && localSurvey.status === "draft") {
setDeleteDialogOpen(true);
} else if (!isEqual(localSurvey, survey)) {
setConfirmDialogOpen(true);
@@ -121,21 +125,13 @@ export default function SurveyMenuBar({
return false;
}
if (
survey.redirectUrl &&
!survey.redirectUrl.includes("https://") &&
!survey.redirectUrl.includes("http://")
) {
toast.error("Please enter a valid URL for redirecting respondents");
return false;
}
return true;
};
const saveSurveyAction = (shouldNavigateBack = false) => {
// variable named strippedSurvey that is a copy of localSurvey with isDraft removed from every question
const strippedSurvey = {
const saveSurveyAction = async (shouldNavigateBack = false) => {
setIsMutatingSurvey(true);
// Create a copy of localSurvey with isDraft removed from every question
const strippedSurvey: TSurvey = {
...localSurvey,
questions: localSurvey.questions.map((question) => {
const { isDraft, ...rest } = question;
@@ -147,28 +143,26 @@ export default function SurveyMenuBar({
return;
}
triggerSurveyMutate({ ...strippedSurvey })
.then(async (response) => {
if (!response?.ok) {
throw new Error(await response?.text());
}
const updatedSurvey = await response.json();
setLocalSurvey(updatedSurvey);
toast.success("Changes saved.");
if (shouldNavigateBack) {
router.back();
try {
await surveyMutateAction({ ...strippedSurvey });
router.refresh();
setIsMutatingSurvey(false);
toast.success("Changes saved.");
if (shouldNavigateBack) {
router.back();
} else {
if (localSurvey.status !== "draft") {
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary`);
} else {
if (localSurvey.status !== "draft") {
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary`);
} else {
router.push(`/environments/${environmentId}/surveys`);
}
router.push(`/environments/${environment.id}/surveys`);
}
})
.catch((error) => {
console.log(error);
toast.error(`Error saving changes`);
});
}
} catch (e) {
console.error(e);
setIsMutatingSurvey(false);
toast.error(`Error saving changes`);
return;
}
};
return (
@@ -200,7 +194,7 @@ export default function SurveyMenuBar({
className="w-72 border-white hover:border-slate-200 "
/>
</div>
{!!localSurvey?.responseRate && (
{!!localSurvey.analytics.responseRate && (
<div className="mx-auto flex items-center rounded-full border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm">
<ExclamationTriangleIcon className=" h-5 w-5 text-amber-400" />
<p className="max-w-[90%] pl-1 text-xs lg:text-sm">
@@ -212,7 +206,7 @@ export default function SurveyMenuBar({
<div className="mr-4 flex items-center">
<SurveyStatusDropdown
surveyId={localSurvey.id}
environmentId={environmentId}
environmentId={environment.id}
updateLocalSurveyStatus={updateLocalSurveyStatus}
/>
</div>
@@ -239,16 +233,19 @@ export default function SurveyMenuBar({
disabled={
localSurvey.type === "web" &&
localSurvey.triggers &&
(localSurvey.triggers[0] === "" || localSurvey.triggers.length === 0)
(localSurvey.triggers[0]?.id === "" || localSurvey.triggers.length === 0)
}
variant="darkCTA"
loading={isMutatingSurvey}
onClick={async () => {
setIsMutatingSurvey(true);
if (!validateSurvey(localSurvey)) {
return;
}
await triggerSurveyMutate({ ...localSurvey, status: "inProgress" });
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
await surveyMutateAction({ ...localSurvey, status: "inProgress" });
router.refresh();
setIsMutatingSurvey(false);
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`);
}}>
Publish
</Button>
@@ -258,7 +255,7 @@ export default function SurveyMenuBar({
deleteWhat="Draft"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => deleteSurveyAction(localSurvey)}
onDelete={() => deleteSurvey(localSurvey.id)}
text="Do you want to delete this draft?"
useSaveInsteadOfCancel={true}
onSave={() => saveSurveyAction(true)}

View File

@@ -1,19 +1,19 @@
// extend this object in order to add more validation rules
import {
MultipleChoiceMultiQuestion,
MultipleChoiceSingleQuestion,
Question,
} from "@formbricks/types/questions";
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyQuestion,
} from "@formbricks/types/v1/surveys";
const validationRules = {
multipleChoiceMulti: (question: MultipleChoiceMultiQuestion) => {
multipleChoiceMulti: (question: TSurveyMultipleChoiceMultiQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
},
multipleChoiceSingle: (question: MultipleChoiceSingleQuestion) => {
multipleChoiceSingle: (question: TSurveyMultipleChoiceSingleQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
},
defaultValidation: (question: Question) => {
defaultValidation: (question: TSurveyQuestion) => {
return question.headline.trim() !== "";
},
};

View File

@@ -1,10 +1,7 @@
"use client";
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/AddNoCodeActionModal";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEventClasses } from "@/lib/eventClasses/eventClasses";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import {
AdvancedOptionToggle,
Badge,
@@ -20,29 +17,51 @@ import {
import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
interface WhenToSendCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environmentId: string;
actionClasses: TActionClass[];
}
export default function WhenToSendCard({ environmentId, localSurvey, setLocalSurvey }: WhenToSendCardProps) {
export default function WhenToSendCard({
environmentId,
localSurvey,
setLocalSurvey,
actionClasses,
}: WhenToSendCardProps) {
const [open, setOpen] = useState(localSurvey.type === "web" ? true : false);
const { eventClasses, isLoadingEventClasses, isErrorEventClasses } = useEventClasses(environmentId);
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [actionClassArray, setActionClassArray] = useState<TActionClass[]>(actionClasses);
const autoClose = localSurvey.autoClose !== null;
let newTrigger = {
id: "", // Set the appropriate value for the id
createdAt: new Date(),
updatedAt: new Date(),
name: "",
type: "code" as const, // Set the appropriate value for the type
environmentId: "",
description: null,
noCodeConfig: null,
};
const addTriggerEvent = () => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers = [...localSurvey.triggers, ""];
updatedSurvey.triggers = [...localSurvey.triggers, newTrigger];
setLocalSurvey(updatedSurvey);
};
const setTriggerEvent = (idx: number, eventClassId: string) => {
const setTriggerEvent = (idx: number, actionClassId: string) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers[idx] = eventClassId;
updatedSurvey.triggers[idx] = actionClassArray!.find((actionClass) => {
return actionClass.id === actionClassId;
})!;
setLocalSurvey(updatedSurvey);
};
@@ -54,10 +73,10 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
const handleCheckMark = () => {
if (autoClose) {
const updatedSurvey: Survey = { ...localSurvey, autoClose: null };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoClose: null };
setLocalSurvey(updatedSurvey);
} else {
const updatedSurvey: Survey = { ...localSurvey, autoClose: 10 };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoClose: 10 };
setLocalSurvey(updatedSurvey);
}
};
@@ -67,15 +86,21 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
if (value < 1) value = 1;
const updatedSurvey: Survey = { ...localSurvey, autoClose: value };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoClose: value };
setLocalSurvey(updatedSurvey);
};
const handleTriggerDelay = (e: any) => {
let value = parseInt(e.target.value);
const updatedSurvey: Survey = { ...localSurvey, delay: value };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, delay: value };
setLocalSurvey(updatedSurvey);
};
useEffect(() => {
console.log(actionClassArray);
if (activeIndex !== null) {
setTriggerEvent(activeIndex, actionClassArray[actionClassArray.length - 1].id);
}
}, [actionClassArray]);
useEffect(() => {
if (localSurvey.type === "link") {
@@ -90,14 +115,6 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
}
}, []);
if (isLoadingEventClasses) {
return <LoadingSpinner />;
}
if (isErrorEventClasses) {
return <div>Error</div>;
}
return (
<>
<Collapsible.Root
@@ -118,9 +135,7 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
)}>
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
{!localSurvey.triggers ||
localSurvey.triggers.length === 0 ||
localSurvey.triggers[0] === "" ? (
{!localSurvey.triggers || localSurvey.triggers.length === 0 || !localSurvey.triggers[0]?.id ? (
<div
className={cn(
localSurvey.type !== "link"
@@ -152,13 +167,13 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="">
<hr className="py-1 text-slate-600" />
{localSurvey.triggers?.map((triggerEventClassId, idx) => (
{localSurvey.triggers?.map((triggerEventClass, idx) => (
<div className="mt-2" key={idx}>
<div className="inline-flex items-center">
<p className="mr-2 w-14 text-right text-sm">{idx === 0 ? "When" : "or"}</p>
<Select
value={triggerEventClassId}
onValueChange={(eventClassId) => setTriggerEvent(idx, eventClassId)}>
value={triggerEventClass.id}
onValueChange={(actionClassId) => setTriggerEvent(idx, actionClassId)}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
@@ -168,14 +183,18 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
value="none"
onClick={() => {
setAddEventModalOpen(true);
setActiveIndex(idx);
}}>
<PlusIcon className="mr-1 h-5 w-5" />
Add Action
</button>
<SelectSeparator />
{eventClasses.map((eventClass) => (
<SelectItem value={eventClass.id} key={eventClass.id}>
{eventClass.name}
{actionClassArray.map((actionClass) => (
<SelectItem
value={actionClass.id}
key={actionClass.id}
title={actionClass.description ? actionClass.description : ""}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>
@@ -212,7 +231,7 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
id="triggerDelay"
value={localSurvey.delay.toString()}
onChange={(e) => handleTriggerDelay(e)}
className="ml-2 mr-2 inline w-16 text-center text-sm"
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
/>
seconds before showing the survey.
</p>
@@ -249,6 +268,7 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
environmentId={environmentId}
open={isAddEventModalOpen}
setOpen={setAddEventModalOpen}
setActionClassArray={setActionClassArray}
/>
</>
);

View File

@@ -1,9 +1,6 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useAttributeClasses } from "@/lib/attributeClasses/attributeClasses";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import {
Badge,
Button,
@@ -17,6 +14,8 @@ import {
import { CheckCircleIcon, FunnelIcon, PlusIcon, TrashIcon, UserGroupIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react"; /* */
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
const filterConditions = [
{ id: "equals", name: "equals" },
@@ -24,23 +23,15 @@ const filterConditions = [
];
interface WhoToSendCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environmentId: string;
attributeClasses: TAttributeClass[];
}
export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurvey }: WhoToSendCardProps) {
export default function WhoToSendCard({ localSurvey, setLocalSurvey, attributeClasses }: WhoToSendCardProps) {
const [open, setOpen] = useState(false);
const { attributeClasses, isLoadingAttributeClasses, isErrorAttributeClasses } =
useAttributeClasses(environmentId);
useEffect(() => {
if (!isLoadingAttributeClasses) {
if (localSurvey.attributeFilters?.length > 0) {
setOpen(true);
}
}
}, [isLoadingAttributeClasses]);
const condition = filterConditions[0].id === "equals" ? "equals" : "notEquals";
useEffect(() => {
if (localSurvey.type === "link") {
@@ -52,14 +43,18 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
const updatedSurvey = { ...localSurvey };
updatedSurvey.attributeFilters = [
...localSurvey.attributeFilters,
{ attributeClassId: "", condition: filterConditions[0].id, value: "" },
{ attributeClassId: "", condition: condition, value: "" },
];
setLocalSurvey(updatedSurvey);
};
const setAttributeFilter = (idx: number, attributeClassId: string, condition: string, value: string) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.attributeFilters[idx] = { attributeClassId, condition, value };
updatedSurvey.attributeFilters[idx] = {
attributeClassId,
condition: condition === "equals" ? "equals" : "notEquals",
value,
};
setLocalSurvey(updatedSurvey);
};
@@ -72,14 +67,6 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
setLocalSurvey(updatedSurvey);
};
if (isLoadingAttributeClasses) {
return <LoadingSpinner />;
}
if (isErrorAttributeClasses) {
return <div>Error</div>;
}
return (
<>
<Collapsible.Root
@@ -187,14 +174,15 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
</Select>
<Input
value={attributeFilter.value}
onChange={(e) =>
onChange={(e) => {
e.preventDefault();
setAttributeFilter(
idx,
attributeFilter.attributeClassId,
attributeFilter.condition,
e.target.value
)
}
);
}}
/>
<button onClick={() => removeAttributeFilter(idx)}>
<TrashIcon className="h-4 w-4 text-slate-400" />

View File

@@ -0,0 +1,12 @@
"use server";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { deleteSurvey, updateSurvey } from "@formbricks/lib/services/survey";
export async function surveyMutateAction(survey: TSurvey): Promise<TSurvey> {
return await updateSurvey(survey);
}
export async function deleteSurveyAction(surveyId: string) {
await deleteSurvey(surveyId);
}

View File

@@ -0,0 +1,27 @@
export default function Loading() {
return (
<div className="flex h-full w-full flex-col items-center justify-between p-6">
{/* Top Part - Loading Navbar */}
<div className="flex h-[10vh] w-full animate-pulse rounded-lg bg-gray-200 font-medium text-slate-900"></div>
{/* Bottom Part - Divided into Left and Right */}
<div className="mt-4 flex h-[85%] w-full flex-row">
{/* Left Part - 7 Horizontal Bars */}
<div className="flex h-full w-1/2 flex-col justify-between space-y-2">
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
</div>
{/* Right Part - Simple Box */}
<div className="ml-4 flex h-full w-1/2 flex-col">
<div className="ph-no-capture h-full animate-pulse rounded-lg bg-gray-200"></div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,35 @@
export const revalidate = REVALIDATION_INTERVAL;
import React from "react";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import SurveyEditor from "./SurveyEditor";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { getAttributeClasses } from "@formbricks/lib/services/attributeClass";
import { ErrorComponent } from "@formbricks/ui";
export default async function SurveysEditPage({ params }) {
const environment = await getEnvironment(params.environmentId);
const [survey, product, environment, actionClasses, attributeClasses] = await Promise.all([
getSurveyWithAnalytics(params.surveyId),
getProductByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
getActionClasses(params.environmentId),
getAttributeClasses(params.environmentId),
]);
if (!survey || !environment || !actionClasses || !attributeClasses || !product) {
return <ErrorComponent />;
}
return (
<SurveyEditor environmentId={params.environmentId} surveyId={params.surveyId} environment={environment} />
<>
<SurveyEditor
survey={survey}
product={product}
environment={environment}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
/>
</>
);
}

View File

@@ -73,7 +73,6 @@ export default function TemplateContainerWithPreview({
product={product}
environment={environment}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
/>
</div>
)}

View File

@@ -11,6 +11,10 @@ export default async function SurveyTemplatesPage({ params }) {
throw new Error("Product not found");
}
if (!environment) {
throw new Error("Environment not found");
}
return (
<TemplateContainerWithPreview environmentId={environmentId} environment={environment} product={product} />
);

View File

@@ -2059,5 +2059,7 @@ export const minimalSurvey: TSurvey = {
delay: 0, // No delay
autoComplete: null,
closeOnDate: null,
surveyClosedMessage: {},
surveyClosedMessage: {
enabled: false,
},
};

View File

@@ -0,0 +1,14 @@
"use server";
import { updateProduct } from "@formbricks/lib/services/product";
import { updateProfile } from "@formbricks/lib/services/profile";
import { TProductUpdateInput } from "@formbricks/types/v1/product";
import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
export async function updateProfileAction(personId: string, updatedProfile: Partial<TProfileUpdateInput>) {
return await updateProfile(personId, updatedProfile);
}
export async function updateProductAction(productId: string, updatedProduct: Partial<TProductUpdateInput>) {
return await updateProduct(productId, updatedProduct);
}

View File

@@ -1,12 +1,12 @@
"use client";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@/env.mjs";
import { formbricksEnabled, updateResponse } from "@/lib/formbricks";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { ResponseId } from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { Objective } from "@formbricks/types/templates";
import { TProfile } from "@formbricks/types/v1/profile";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -15,6 +15,7 @@ type ObjectiveProps = {
next: () => void;
skip: () => void;
formbricksResponseId?: ResponseId;
profile: TProfile;
};
type ObjectiveChoice = {
@@ -22,7 +23,7 @@ type ObjectiveChoice = {
id: Objective;
};
const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId }) => {
const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId, profile }) => {
const objectives: Array<ObjectiveChoice> = [
{ label: "Increase conversion", id: "increase_conversion" },
{ label: "Improve user retention", id: "improve_user_retention" },
@@ -32,19 +33,20 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId
{ label: "Other", id: "other" },
];
const { profile } = useProfile();
const { triggerProfileMutate, isMutatingProfile } = useProfileMutation();
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isProfileUpdating, setIsProfileUpdating] = useState(false);
const handleNextClick = async () => {
if (selectedChoice) {
const selectedObjective = objectives.find((objective) => objective.label === selectedChoice);
if (selectedObjective) {
try {
setIsProfileUpdating(true);
const updatedProfile = { ...profile, objective: selectedObjective.id };
await triggerProfileMutate(updatedProfile);
await updateProfileAction(profile.id, updatedProfile);
setIsProfileUpdating(false);
} catch (e) {
setIsProfileUpdating(false);
console.error(e);
toast.error("An error occured saving your settings");
}
@@ -116,7 +118,7 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId
<Button
size="lg"
variant="darkCTA"
loading={isMutatingProfile}
loading={isProfileUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="objective-next">

View File

@@ -1,38 +1,30 @@
"use client";
import { Logo } from "@/components/Logo";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { fetcher } from "@formbricks/lib/fetcher";
import { ProgressBar } from "@formbricks/ui";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import useSWR from "swr";
import Greeting from "./Greeting";
import Objective from "./Objective";
import Product from "./Product";
import Role from "./Role";
import { ResponseId } from "@formbricks/js";
import { TProfile } from "@formbricks/types/v1/profile";
import { TProduct } from "@formbricks/types/v1/product";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
const MAX_STEPS = 6;
interface OnboardingProps {
session: Session | null;
environmentId: string;
profile: TProfile;
product: TProduct;
}
export default function Onboarding({ session }: OnboardingProps) {
const {
data: environment,
error: isErrorEnvironment,
isLoading: isLoadingEnvironment,
} = useSWR(`/api/v1/environments/find-first`, fetcher);
const { profile } = useProfile();
const { triggerProfileMutate } = useProfileMutation();
export default function Onboarding({ session, environmentId, profile, product }: OnboardingProps) {
const [formbricksResponseId, setFormbricksResponseId] = useState<ResponseId | undefined>();
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
@@ -42,18 +34,6 @@ export default function Onboarding({ session }: OnboardingProps) {
return currentStep / MAX_STEPS;
}, [currentStep]);
if (!profile || isLoadingEnvironment) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorEnvironment) {
return <div className="flex h-full w-full items-center justify-center">An error occurred</div>;
}
const skipStep = () => {
setCurrentStep(currentStep + 1);
};
@@ -75,10 +55,10 @@ export default function Onboarding({ session }: OnboardingProps) {
try {
const updatedProfile = { ...profile, onboardingCompleted: true };
await triggerProfileMutate(updatedProfile);
await updateProfileAction(profile.id, updatedProfile);
if (environment) {
router.push(`/environments/${environment.id}/surveys`);
if (environmentId) {
router.push(`/environments/${environmentId}/surveys`);
return;
}
} catch (e) {
@@ -105,14 +85,28 @@ export default function Onboarding({ session }: OnboardingProps) {
<div className="col-span-2" />
</div>
<div className="flex grow items-center justify-center">
{currentStep === 1 && <Greeting next={next} skip={doLater} name={profile.name} session={session} />}
{currentStep === 1 && (
<Greeting next={next} skip={doLater} name={profile.name ? profile.name : ""} session={session} />
)}
{currentStep === 2 && (
<Role next={next} skip={skipStep} setFormbricksResponseId={setFormbricksResponseId} />
<Role
next={next}
skip={skipStep}
setFormbricksResponseId={setFormbricksResponseId}
profile={profile}
/>
)}
{currentStep === 3 && (
<Objective next={next} skip={skipStep} formbricksResponseId={formbricksResponseId} />
<Objective
next={next}
skip={skipStep}
formbricksResponseId={formbricksResponseId}
profile={profile}
/>
)}
{currentStep === 4 && (
<Product done={done} environmentId={environmentId} isLoading={isLoading} product={product} />
)}
{currentStep === 4 && <Product done={done} environmentId={environment.id} isLoading={isLoading} />}
</div>
</div>
);

View File

@@ -1,8 +1,8 @@
"use client";
import { updateProductAction } from "@/app/(app)/onboarding/actions";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProductMutation } from "@/lib/products/mutateProducts";
import { useProduct } from "@/lib/products/products";
import { TProduct } from "@formbricks/types/v1/product";
import { Button, ColorPicker, ErrorComponent, Input, Label } from "@formbricks/ui";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
@@ -11,12 +11,11 @@ type Product = {
done: () => void;
environmentId: string;
isLoading: boolean;
product: TProduct;
};
const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
const Product: React.FC<Product> = ({ done, isLoading, environmentId, product }) => {
const [loading, setLoading] = useState(true);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { triggerProductMutate } = useProductMutation(environmentId);
const [name, setName] = useState("");
const [color, setColor] = useState("##4748b");
@@ -30,7 +29,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
};
useEffect(() => {
if (isLoadingProduct) {
if (!product) {
return;
} else if (product && product.name !== "My Product") {
done(); // when product already exists, skip product step entirely
@@ -40,7 +39,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
}
setLoading(false);
}
}, [product, done, isLoadingProduct]);
}, [product, done]);
const dummyChoices = ["❤️ Love it!"];
@@ -50,7 +49,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
}
try {
await triggerProductMutate({ name, brandColor: color });
await updateProductAction(product.id, { name, brandColor: color });
} catch (e) {
toast.error("An error occured saving your settings");
console.error(e);
@@ -63,11 +62,11 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
done();
};
if (isLoadingProduct || loading) {
if (loading) {
return <LoadingSpinner />;
}
if (isErrorProduct) {
if (!product) {
return <ErrorComponent />;
}

View File

@@ -1,11 +1,11 @@
"use client";
import { cn } from "@/../../packages/lib/cn";
import { cn } from "@formbricks/lib/cn";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@/env.mjs";
import { createResponse, formbricksEnabled } from "@/lib/formbricks";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { ResponseId, SurveyId } from "@formbricks/js";
import { TProfile } from "@formbricks/types/v1/profile";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -14,6 +14,7 @@ type RoleProps = {
next: () => void;
skip: () => void;
setFormbricksResponseId: (id: ResponseId) => void;
profile: TProfile;
};
type RoleChoice = {
@@ -21,11 +22,9 @@ type RoleChoice = {
id: "project_manager" | "engineer" | "founder" | "marketing_specialist" | "other";
};
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profile }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const { profile } = useProfile();
const { triggerProfileMutate, isMutatingProfile } = useProfileMutation();
const [isUpdating, setIsUpdating] = useState(false);
const roles: Array<RoleChoice> = [
{ label: "Project Manager", id: "project_manager" },
@@ -40,9 +39,12 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
const selectedRole = roles.find((role) => role.label === selectedChoice);
if (selectedRole) {
try {
setIsUpdating(true);
const updatedProfile = { ...profile, role: selectedRole.id };
await triggerProfileMutate(updatedProfile);
await updateProfileAction(profile.id, updatedProfile);
setIsUpdating(false);
} catch (e) {
setIsUpdating(false);
toast.error("An error occured saving your settings");
console.error(e);
}
@@ -68,7 +70,6 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
What is your role?
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
Make your Formbricks experience more personalised.
</label>
@@ -114,7 +115,7 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
<Button
size="lg"
variant="darkCTA"
loading={isMutatingProfile}
loading={isUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="role-next">

View File

@@ -0,0 +1,13 @@
export default function Loading() {
return (
<div className="flex h-[100vh] w-[80vw] animate-pulse flex-col items-center justify-between p-12 text-white">
<div className="flex w-full justify-between">
<div className="h-12 w-1/6 rounded-lg bg-gray-200"></div>
<div className="h-12 w-1/3 rounded-lg bg-gray-200"></div>
<div className="h-0 w-1/6"></div>
</div>
<div className="h-1/3 w-1/2 rounded-lg bg-gray-200"></div>
<div className="h-10 w-1/2 rounded-lg bg-gray-200"></div>
</div>
);
}

View File

@@ -1,8 +1,23 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import Onboarding from "./Onboarding";
import Onboarding from "./components/Onboarding";
import { getEnvironmentByUser } from "@formbricks/lib/services/environment";
import { getProfile } from "@formbricks/lib/services/profile";
import { ErrorComponent } from "@formbricks/ui";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
export default async function OnboardingPage() {
const session = await getServerSession(authOptions);
return <Onboarding session={session} />;
const environment = await getEnvironmentByUser(session?.user);
const profile = await getProfile(session?.user.id!);
const product = await getProductByEnvironmentId(environment?.id!);
if (!environment || !profile || !product) {
return <ErrorComponent />;
}
return <Onboarding session={session} environmentId={environment?.id} profile={profile} product={product} />;
}

View File

@@ -57,7 +57,7 @@ export const ExpiredContent = () => {
return (
<ContentLayout
headline="Invite expired 😥"
description="Invies are valid for 7 days. Please request a new invite.">
description="Invites are valid for 7 days. Please request a new invite.">
<div></div>
</ContentLayout>
);

View File

@@ -10,8 +10,8 @@ const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHO
if (typeof window !== "undefined") {
if (posthogEnabled) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_API_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
posthog.init(env.NEXT_PUBLIC_POSTHOG_API_KEY!, {
api_host: env.NEXT_PUBLIC_POSTHOG_API_HOST,
});
}
}

View File

@@ -83,6 +83,9 @@ export const authOptions: NextAuthOptions = {
async authorize(credentials, _req) {
let user;
try {
if (!credentials?.token) {
throw new Error("Token not found");
}
const { id } = await verifyToken(credentials?.token);
user = await prisma.user.findUnique({
where: {

View File

@@ -1,12 +1,13 @@
import { env } from "@/env.mjs";
import { responses } from "@/lib/api/response";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { headers } from "next/headers";
export async function POST() {
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey || apiKey !== process.env.CRON_SECRET) {
if (!apiKey || apiKey !== env.CRON_SECRET) {
return responses.notAuthenticatedResponse();
}

View File

@@ -0,0 +1,80 @@
import { env } from "@/env.mjs";
import { prisma } from "@formbricks/database";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
if (!environmentId) {
return NextResponse.json({ error: "Invalid environmentId" });
}
if (code && typeof code !== "string") {
return NextResponse.json({ message: "`code` must be a string" }, { status: 400 });
}
const client_id = env.GOOGLE_SHEETS_CLIENT_ID;
const client_secret = env.GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = env.GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return NextResponse.json({ Error: "Google client id is missing" }, { status: 400 });
if (!client_secret) return NextResponse.json({ Error: "Google client secret is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Google redirect url is missing" }, { status: 400 });
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
let key;
let userEmail;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
}
const googleSheetIntegration = {
type: "googleSheets" as "googleSheets",
environment: environmentId,
config: {
key,
data: [],
email: userEmail,
},
};
const result = await prisma.integration.upsert({
where: {
type_environmentId: {
environmentId,
type: "googleSheets",
},
},
update: {
...googleSheetIntegration,
environment: { connect: { id: environmentId } },
},
create: {
...googleSheetIntegration,
environment: { connect: { id: environmentId } },
},
});
if (result) {
return NextResponse.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/google-sheets`);
}
}

Some files were not shown because too many files have changed in this diff Show More