mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-13 20:11:43 -05:00
sync with main
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
/*
|
||||
########################################################################
|
||||
# ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------#
|
||||
########################################################################
|
||||
@@ -105,3 +106,4 @@ NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
|
||||
|
||||
# Cron Secret
|
||||
CRON_SECRET=
|
||||
*/
|
||||
@@ -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`
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
3
apps/web/.gitignore
vendored
@@ -37,3 +37,6 @@ next-env.d.ts
|
||||
|
||||
# Sentry Auth Token
|
||||
.sentryclirc
|
||||
|
||||
# Google Sheets Token File
|
||||
token.json
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function WebhookTable({
|
||||
id: "",
|
||||
name: "",
|
||||
url: "",
|
||||
source: "user",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// export { EditMembershipsClient } from "./EditMembershipscClient";
|
||||
export { EditMemberships } from "./EditMemberships";
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
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;
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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'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)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ const CustomFilter = ({ environmentId, responses, survey, totalResponses }: Cust
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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() !== "";
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,6 @@ export default function TemplateContainerWithPreview({
|
||||
product={product}
|
||||
environment={environment}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
|
||||
@@ -2059,5 +2059,7 @@ export const minimalSurvey: TSurvey = {
|
||||
delay: 0, // No delay
|
||||
autoComplete: null,
|
||||
closeOnDate: null,
|
||||
surveyClosedMessage: {},
|
||||
surveyClosedMessage: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
14
apps/web/app/(app)/onboarding/actions.ts
Normal file
14
apps/web/app/(app)/onboarding/actions.ts
Normal 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);
|
||||
}
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
13
apps/web/app/(app)/onboarding/loading.tsx
Normal file
13
apps/web/app/(app)/onboarding/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
80
apps/web/app/api/google-sheet/callback/route.ts
Normal file
80
apps/web/app/api/google-sheet/callback/route.ts
Normal 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
Reference in New Issue
Block a user