mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-15 02:57:41 -05:00
Merge branch 'main' of github.com:formbricks/formbricks into feat/close-date-edge-case
This commit is contained in:
51
.github/ISSUE_TEMPLATE/bug_report.md
vendored
51
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,51 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
### Issue Summary
|
||||
|
||||
<!--
|
||||
A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
|
||||
### Expected behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
### Other information
|
||||
|
||||
#### Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
#### Environment
|
||||
|
||||
- [ ] Formbricks Cloud (app.formbricks.com)
|
||||
- [ ] self-hosted Formbricks, version/commit: [please provide]
|
||||
|
||||
#### Desktop (please complete the following information):
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
#### Node.JS version
|
||||
|
||||
[e.g. v18.15.0]
|
||||
|
||||
#### Anything else?
|
||||
|
||||
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
|
||||
- Anything else that you think could be an issue?
|
||||
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Bug report
|
||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: issue-summary
|
||||
attributes:
|
||||
label: Issue Summary
|
||||
description: A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
value: |
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other-information
|
||||
attributes:
|
||||
label: Other information
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
options:
|
||||
- label: Formbricks Cloud (app.formbricks.com)
|
||||
- label: Self-hosted Formbricks
|
||||
- type: textarea
|
||||
id: desktop-version
|
||||
attributes:
|
||||
label: Desktop (please complete the following information)
|
||||
description: |
|
||||
examples:
|
||||
- **OS**: [e.g. iOS]
|
||||
- **Browser**: [e.g. chrome, safari]
|
||||
- **Version**: [e.g. 22]
|
||||
value: |
|
||||
- OS:
|
||||
- Node:
|
||||
- npm:
|
||||
render: markdown
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
id: nodejs-version
|
||||
attributes:
|
||||
value: |
|
||||
#### Node.JS version
|
||||
|
||||
[e.g. v18.15.0]
|
||||
- type: markdown
|
||||
id: anything-else
|
||||
attributes:
|
||||
value: |
|
||||
#### Anything else?
|
||||
|
||||
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
|
||||
- Anything else that you think could be an issue?
|
||||
26
.github/ISSUE_TEMPLATE/feature_request.md
vendored
26
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: "Suggest an idea for this project \U0001F680"
|
||||
title: "[FEATURE]"
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
### How we code at Formbricks 🤓
|
||||
|
||||
- Everything is type-safe
|
||||
- All UI components are in the package `formbricks/ui`
|
||||
- Run `pnpm dev` to find a demo app to test in-app surveys at `localhost:3002`
|
||||
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right context before you write your prompt.
|
||||
45
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
45
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Feature request
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
title: "[FEATURE]"
|
||||
labels: enhancement
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternate-solution-description
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
id: formbricks-info
|
||||
attributes:
|
||||
value: |
|
||||
### How we code at Formbricks 🤓
|
||||
|
||||
- Everything is type-safe
|
||||
- All UI components are in the package `formbricks/ui`
|
||||
- Run `pnpm dev` to find a demo app to test in-app surveys at `localhost:3002`
|
||||
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right
|
||||
context before you write your prompt.
|
||||
14
apps/formbricks-com/pages/api/oss-friends/index.ts
Normal file
14
apps/formbricks-com/pages/api/oss-friends/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { OSSFriends } from "@/pages/oss-friends";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// GET
|
||||
if (req.method === "GET") {
|
||||
return res.status(200).json({ data: OSSFriends });
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Layout from "@/components/shared/Layout";
|
||||
import HeroTitle from "@/components/shared/HeroTitle";
|
||||
import { Button } from "@formbricks/ui";
|
||||
|
||||
const OSSFriends = [
|
||||
export const OSSFriends = [
|
||||
{
|
||||
name: "BoxyHQ",
|
||||
description:
|
||||
|
||||
@@ -24,12 +24,7 @@ export default function AttributeDetailModal({
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
children: (
|
||||
<AttributeSettingsTab
|
||||
attributeClass={attributeClass}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
),
|
||||
children: <AttributeSettingsTab attributeClass={attributeClass} setOpen={setOpen} />,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,198 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
copyToOtherEnvironmentAction,
|
||||
deleteSurveyAction,
|
||||
duplicateSurveyAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import {
|
||||
ArrowUpOnSquareStackIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
environmentId: string;
|
||||
survey: TSurveyWithAnalytics;
|
||||
environment: TEnvironment;
|
||||
otherEnvironment: TEnvironment;
|
||||
}
|
||||
|
||||
export default function SurveyDropDownMenu({
|
||||
environmentId,
|
||||
survey,
|
||||
environment,
|
||||
otherEnvironment,
|
||||
}: SurveyDropDownMenuProps) {
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleDeleteSurvey = async (survey) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction(survey.id);
|
||||
router.refresh();
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success("Survey deleted successfully.");
|
||||
} catch (error) {
|
||||
toast.error("An error occured while deleting survey");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRefresh = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await duplicateSurveyAction(environmentId, surveyId);
|
||||
router.refresh();
|
||||
toast.success("Survey duplicated successfully.");
|
||||
} catch (error) {
|
||||
toast.error("Failed to duplicate the survey.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copyToOtherEnvironment = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
|
||||
if (otherEnvironment.type === "production") {
|
||||
toast.success("Survey copied to production env.");
|
||||
} else if (otherEnvironment.type === "development") {
|
||||
toast.success("Survey copied to development env.");
|
||||
}
|
||||
router.replace(`/environments/${otherEnvironment.id}`);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to copy to ${otherEnvironment.type}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="opacity-0.2 absolute left-0 top-0 h-full w-full bg-gray-100">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div>
|
||||
<span className="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
|
||||
<PencilSquareIcon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={async () => {
|
||||
duplicateSurveyAndRefresh(survey.id);
|
||||
}}>
|
||||
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{environment.type === "development" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Prod
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : environment.type === "production" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Dev
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/s/${survey.id}?preview=true`}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
|
||||
);
|
||||
toast.success("Copied link to clipboard");
|
||||
}}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat="Survey"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={() => handleDeleteSurvey(survey)}
|
||||
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
copyToOtherEnvironmentAction,
|
||||
deleteSurveyAction,
|
||||
duplicateSurveyAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import {
|
||||
ArrowUpOnSquareStackIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
environmentId: string;
|
||||
survey: TSurveyWithAnalytics;
|
||||
environment: TEnvironment;
|
||||
otherEnvironment: TEnvironment;
|
||||
}
|
||||
|
||||
export default function SurveyDropDownMenu({
|
||||
environmentId,
|
||||
survey,
|
||||
environment,
|
||||
otherEnvironment,
|
||||
}: SurveyDropDownMenuProps) {
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleDeleteSurvey = async (survey) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction(survey.id);
|
||||
router.refresh();
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success("Survey deleted successfully.");
|
||||
} catch (error) {
|
||||
toast.error("An error occured while deleting survey");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRefresh = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await duplicateSurveyAction(environmentId, surveyId);
|
||||
router.refresh();
|
||||
toast.success("Survey duplicated successfully.");
|
||||
} catch (error) {
|
||||
toast.error("Failed to duplicate the survey.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copyToOtherEnvironment = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
|
||||
if (otherEnvironment.type === "production") {
|
||||
toast.success("Survey copied to production env.");
|
||||
} else if (otherEnvironment.type === "development") {
|
||||
toast.success("Survey copied to development env.");
|
||||
}
|
||||
router.replace(`/environments/${otherEnvironment.id}`);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to copy to ${otherEnvironment.type}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="opacity-0.2 absolute left-0 top-0 h-full w-full bg-gray-100">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div>
|
||||
<span className="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
|
||||
<PencilSquareIcon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={async () => {
|
||||
duplicateSurveyAndRefresh(survey.id);
|
||||
}}>
|
||||
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{environment.type === "development" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Prod
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : environment.type === "production" ? (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
Copy to Dev
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/s/${survey.id}?preview=true`}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
|
||||
);
|
||||
toast.success("Copied link to clipboard");
|
||||
}}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat="Survey"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={() => handleDeleteSurvey(survey)}
|
||||
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
"use client";
|
||||
import { Template } from "@/../../packages/types/templates";
|
||||
import { createSurveyAction } from "./actions";
|
||||
import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templates/TemplateList";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
export default function SurveyStarter({
|
||||
environmentId,
|
||||
environment,
|
||||
product,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
product: TProduct;
|
||||
}) {
|
||||
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const newSurveyFromTemplate = async (template: Template) => {
|
||||
setIsCreateSurveyLoading(true);
|
||||
const augmentedTemplate = {
|
||||
...template.preset,
|
||||
type: environment?.widgetSetupCompleted ? "web" : "link",
|
||||
};
|
||||
try {
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
} catch (e) {
|
||||
toast.error("An error occured creating a new survey");
|
||||
setIsCreateSurveyLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
|
||||
{isCreateSurveyLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-7 pb-4">
|
||||
<h1 className="text-3xl font-extrabold text-slate-700">
|
||||
You're all set! Time to create your first survey.
|
||||
</h1>
|
||||
</div>
|
||||
<TemplateList
|
||||
environmentId={environmentId}
|
||||
onTemplateClick={(template) => {
|
||||
newSurveyFromTemplate(template);
|
||||
}}
|
||||
environment={environment}
|
||||
product={product}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
import { Template } from "@/../../packages/types/templates";
|
||||
import { createSurveyAction } from "./actions";
|
||||
import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templates/TemplateList";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
export default function SurveyStarter({
|
||||
environmentId,
|
||||
environment,
|
||||
product,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
product: TProduct;
|
||||
}) {
|
||||
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const newSurveyFromTemplate = async (template: Template) => {
|
||||
setIsCreateSurveyLoading(true);
|
||||
const augmentedTemplate = {
|
||||
...template.preset,
|
||||
type: environment?.widgetSetupCompleted ? "web" : "link",
|
||||
};
|
||||
try {
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
} catch (e) {
|
||||
toast.error("An error occured creating a new survey");
|
||||
setIsCreateSurveyLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
|
||||
{isCreateSurveyLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-7 pb-4">
|
||||
<h1 className="text-3xl font-extrabold text-slate-700">
|
||||
You're all set! Time to create your first survey.
|
||||
</h1>
|
||||
</div>
|
||||
<TemplateList
|
||||
environmentId={environmentId}
|
||||
onTemplateClick={(template) => {
|
||||
newSurveyFromTemplate(template);
|
||||
}}
|
||||
environment={environment}
|
||||
product={product}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ const TagsCombobox: React.FC<ITagsComboboxProps> = ({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && searchValue !== "") {
|
||||
if (
|
||||
!tagsToSearch?.find(
|
||||
(tag) => tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
|
||||
!tagsToSearch?.find((tag) =>
|
||||
tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
|
||||
)
|
||||
) {
|
||||
createTag?.(searchValue);
|
||||
|
||||
@@ -126,7 +126,7 @@ const ResponseFilter = () => {
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-slate-100 p-3 text-sm text-slate-600 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-slate-100 p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
Filter {selectedFilter.filter.length > 0 && `(${selectedFilter.filter.length})`}
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ interface CTAQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function CTAQuestionForm({
|
||||
@@ -19,6 +20,7 @@ export default function CTAQuestionForm({
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
}: CTAQuestionFormProps): JSX.Element {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
@@ -33,6 +35,7 @@ export default function CTAQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,12 +11,14 @@ interface ConsentQuestionFormProps {
|
||||
question: ConsentQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function ConsentQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
}: ConsentQuestionFormProps): JSX.Element {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
return (
|
||||
@@ -29,6 +31,7 @@ export default function ConsentQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,6 +65,7 @@ export default function ConsentQuestionForm({
|
||||
value={question.label}
|
||||
placeholder="I agree to the terms and conditions"
|
||||
onChange={(e) => updateQuestion(questionIdx, { label: e.target.value })}
|
||||
isInvalid={isInValid && question.label.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="mt-3">
|
||||
|
||||
@@ -21,12 +21,14 @@ interface OpenQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function MultipleChoiceMultiForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
}: OpenQuestionFormProps): JSX.Element {
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
@@ -137,6 +139,7 @@ export default function MultipleChoiceMultiForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,6 +187,7 @@ export default function MultipleChoiceMultiForm({
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
|
||||
isInvalid={isInValid && choice.label.trim() === ""}
|
||||
/>
|
||||
{question.choices && question.choices.length > 2 && (
|
||||
<TrashIcon
|
||||
|
||||
@@ -21,12 +21,14 @@ interface OpenQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function MultipleChoiceSingleForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
}: OpenQuestionFormProps): JSX.Element {
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
@@ -137,6 +139,7 @@ export default function MultipleChoiceSingleForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,6 +187,7 @@ export default function MultipleChoiceSingleForm({
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
|
||||
isInvalid={isInValid && choice.label.trim() === ""}
|
||||
/>
|
||||
{question.choices && question.choices.length > 2 && (
|
||||
<TrashIcon
|
||||
|
||||
@@ -10,6 +10,7 @@ interface NPSQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function NPSQuestionForm({
|
||||
@@ -17,6 +18,7 @@ export default function NPSQuestionForm({
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
}: NPSQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
|
||||
@@ -31,6 +33,7 @@ export default function NPSQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,14 @@ interface OpenQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function OpenQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
}: OpenQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
|
||||
@@ -30,6 +32,7 @@ export default function OpenQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ interface QuestionCardProps {
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function QuestionCard({
|
||||
@@ -51,6 +52,7 @@ export default function QuestionCard({
|
||||
activeQuestionId,
|
||||
setActiveQuestionId,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
}: QuestionCardProps) {
|
||||
const question = localSurvey.questions[questionIdx];
|
||||
const open = activeQuestionId === question.id;
|
||||
@@ -69,7 +71,8 @@ export default function QuestionCard({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:bg-slate-600"
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:bg-slate-600",
|
||||
isInValid && "bg-red-400 hover:bg-red-600"
|
||||
)}>
|
||||
{questionIdx + 1}
|
||||
</div>
|
||||
@@ -136,6 +139,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceSingleForm
|
||||
@@ -144,6 +148,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceMultiForm
|
||||
@@ -152,6 +157,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.NPS ? (
|
||||
<NPSQuestionForm
|
||||
@@ -160,6 +166,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.CTA ? (
|
||||
<CTAQuestionForm
|
||||
@@ -168,6 +175,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.Rating ? (
|
||||
<RatingQuestionForm
|
||||
@@ -176,6 +184,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === "consent" ? (
|
||||
<ConsentQuestionForm
|
||||
@@ -183,6 +192,7 @@ export default function QuestionCard({
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -9,6 +9,8 @@ import AddQuestionButton from "./AddQuestionButton";
|
||||
import EditThankYouCard from "./EditThankYouCard";
|
||||
import QuestionCard from "./QuestionCard";
|
||||
import { StrictModeDroppable } from "./StrictModeDroppable";
|
||||
import { Question } from "@formbricks/types/questions";
|
||||
import { validateQuestion } from "./Validation";
|
||||
|
||||
interface QuestionsViewProps {
|
||||
localSurvey: Survey;
|
||||
@@ -16,6 +18,8 @@ interface QuestionsViewProps {
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
environmentId: string;
|
||||
invalidQuestions: String[] | null;
|
||||
setInvalidQuestions: (invalidQuestions: String[] | null) => void;
|
||||
}
|
||||
|
||||
export default function QuestionsView({
|
||||
@@ -24,6 +28,8 @@ export default function QuestionsView({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
environmentId,
|
||||
invalidQuestions,
|
||||
setInvalidQuestions,
|
||||
}: QuestionsViewProps) {
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
return localSurvey.questions.reduce((acc, question) => {
|
||||
@@ -44,12 +50,33 @@ export default function QuestionsView({
|
||||
return survey;
|
||||
};
|
||||
|
||||
// function to validate individual questions
|
||||
const validateSurvey = (question: Question) => {
|
||||
// prevent this function to execute further if user hasnt still tried to save the survey
|
||||
if (invalidQuestions === null) {
|
||||
return;
|
||||
}
|
||||
let temp = JSON.parse(JSON.stringify(invalidQuestions));
|
||||
if (validateQuestion(question)) {
|
||||
temp = invalidQuestions.filter((id) => id !== question.id);
|
||||
setInvalidQuestions(temp);
|
||||
} else if (!invalidQuestions.includes(question.id)) {
|
||||
temp.push(question.id);
|
||||
setInvalidQuestions(temp);
|
||||
}
|
||||
};
|
||||
|
||||
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
|
||||
let updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
|
||||
if ("id" in updatedAttributes) {
|
||||
// if the survey whose id is to be changed is linked to logic of any other survey then changing it
|
||||
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id);
|
||||
if (invalidQuestions?.includes(initialQuestionId)) {
|
||||
setInvalidQuestions(
|
||||
invalidQuestions.map((id) => (id === initialQuestionId ? updatedAttributes.id : id))
|
||||
);
|
||||
}
|
||||
|
||||
// relink the question to internal Id
|
||||
internalQuestionIdMap[updatedAttributes.id] =
|
||||
@@ -63,6 +90,7 @@ export default function QuestionsView({
|
||||
...updatedAttributes,
|
||||
};
|
||||
setLocalSurvey(updatedSurvey);
|
||||
validateSurvey(updatedSurvey.questions[questionIdx]);
|
||||
};
|
||||
|
||||
const deleteQuestion = (questionIdx: number) => {
|
||||
@@ -120,7 +148,6 @@ export default function QuestionsView({
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const [reorderedQuestion] = newQuestions.splice(result.source.index, 1);
|
||||
newQuestions.splice(result.destination.index, 0, reorderedQuestion);
|
||||
@@ -134,7 +161,6 @@ export default function QuestionsView({
|
||||
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
|
||||
const destinationIndex = up ? questionIndex - 1 : questionIndex + 1;
|
||||
newQuestions.splice(destinationIndex, 0, reorderedQuestion);
|
||||
|
||||
const updatedSurvey = { ...localSurvey, questions: newQuestions };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
@@ -159,6 +185,7 @@ export default function QuestionsView({
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
lastQuestion={questionIdx === localSurvey.questions.length - 1}
|
||||
isInValid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface RatingQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function RatingQuestionForm({
|
||||
@@ -19,6 +20,7 @@ export default function RatingQuestionForm({
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
}: RatingQuestionFormProps) {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
|
||||
@@ -33,6 +35,7 @@ export default function RatingQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
const [localSurvey, setLocalSurvey] = useState<Survey | null>();
|
||||
|
||||
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
|
||||
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
@@ -56,6 +56,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
environmentId={environmentId}
|
||||
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">
|
||||
@@ -67,6 +68,8 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
environmentId={environmentId}
|
||||
invalidQuestions={invalidQuestions}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
/>
|
||||
) : (
|
||||
<SettingsView
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { isEqual } from "lodash";
|
||||
import { validateQuestion } from "./Validation";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
localSurvey: Survey;
|
||||
@@ -21,6 +22,7 @@ interface SurveyMenuBarProps {
|
||||
environmentId: string;
|
||||
activeId: "questions" | "settings";
|
||||
setActiveId: (id: "questions" | "settings") => void;
|
||||
setInvalidQuestions: (invalidQuestions: String[]) => void;
|
||||
}
|
||||
|
||||
export default function SurveyMenuBar({
|
||||
@@ -30,6 +32,7 @@ export default function SurveyMenuBar({
|
||||
setLocalSurvey,
|
||||
activeId,
|
||||
setActiveId,
|
||||
setInvalidQuestions,
|
||||
}: SurveyMenuBarProps) {
|
||||
const router = useRouter();
|
||||
const { triggerSurveyMutate, isMutatingSurvey } = useSurveyMutation(environmentId, localSurvey.id);
|
||||
@@ -37,6 +40,7 @@ export default function SurveyMenuBar({
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const { product } = useProduct(environmentId);
|
||||
let faultyQuestions: String[] = [];
|
||||
|
||||
useEffect(() => {
|
||||
if (audiencePrompt && activeId === "settings") {
|
||||
@@ -85,6 +89,26 @@ export default function SurveyMenuBar({
|
||||
}
|
||||
};
|
||||
|
||||
const validateSurvey = (survey) => {
|
||||
faultyQuestions = [];
|
||||
for (let index = 0; index < survey.questions.length; index++) {
|
||||
const question = survey.questions[index];
|
||||
const isValid = validateQuestion(question);
|
||||
|
||||
if (!isValid) {
|
||||
faultyQuestions.push(question.id);
|
||||
}
|
||||
}
|
||||
// if there are any faulty questions, the user won't be allowed to save the survey
|
||||
if (faultyQuestions.length > 0) {
|
||||
setInvalidQuestions(faultyQuestions);
|
||||
toast.error("Please fill required fields");
|
||||
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 = {
|
||||
@@ -94,6 +118,11 @@ export default function SurveyMenuBar({
|
||||
return rest;
|
||||
}),
|
||||
};
|
||||
|
||||
if (!validateSurvey(localSurvey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerSurveyMutate({ ...strippedSurvey })
|
||||
.then(async (response) => {
|
||||
if (!response?.ok) {
|
||||
@@ -180,6 +209,9 @@ export default function SurveyMenuBar({
|
||||
variant="darkCTA"
|
||||
loading={isMutatingSurvey}
|
||||
onClick={async () => {
|
||||
if (!validateSurvey(localSurvey)) {
|
||||
return;
|
||||
}
|
||||
await triggerSurveyMutate({ ...localSurvey, status: "inProgress" });
|
||||
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
|
||||
}}>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// extend this object in order to add more validation rules
|
||||
|
||||
import {
|
||||
MultipleChoiceMultiQuestion,
|
||||
MultipleChoiceSingleQuestion,
|
||||
Question,
|
||||
} from "@formbricks/types/questions";
|
||||
|
||||
const validationRules = {
|
||||
multipleChoiceMulti: (question: MultipleChoiceMultiQuestion) => {
|
||||
return !question.choices.some((element) => element.label.trim() === "");
|
||||
},
|
||||
multipleChoiceSingle: (question: MultipleChoiceSingleQuestion) => {
|
||||
return !question.choices.some((element) => element.label.trim() === "");
|
||||
},
|
||||
defaultValidation: (question: Question) => {
|
||||
return question.headline.trim() !== "";
|
||||
},
|
||||
};
|
||||
|
||||
const validateQuestion = (question) => {
|
||||
const specificValidation = validationRules[question.type];
|
||||
const defaultValidation = validationRules.defaultValidation;
|
||||
|
||||
const specificValidationResult = specificValidation ? specificValidation(question) : true;
|
||||
const defaultValidationResult = defaultValidation(question);
|
||||
|
||||
// Return true only if both specific and default validation pass
|
||||
return specificValidationResult && defaultValidationResult;
|
||||
};
|
||||
|
||||
export { validateQuestion };
|
||||
@@ -1,10 +1,15 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import ContentWrapper from "@/components/shared/ContentWrapper";
|
||||
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
|
||||
import SurveysList from "./SurveyList";
|
||||
import { Metadata } from "next";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your Surveys",
|
||||
};
|
||||
|
||||
export default async function SurveysPage({ params }) {
|
||||
return (
|
||||
<ContentWrapper className="flex h-full flex-col justify-between">
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta = {
|
||||
url: responseInput?.meta?.url ?? "",
|
||||
userAgent: {
|
||||
browser: agent?.browser.name,
|
||||
device: agent?.device.type,
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
|
||||
const handleSelect = (number: number) => {
|
||||
setSelectedChoice(number);
|
||||
if (question.required) {
|
||||
setSelectedChoice(null);
|
||||
onSubmit({
|
||||
[question.id]: number,
|
||||
});
|
||||
@@ -33,6 +34,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
|
||||
[question.id]: selectedChoice,
|
||||
};
|
||||
|
||||
setSelectedChoice(null);
|
||||
onSubmit(data);
|
||||
// reset form
|
||||
}}>
|
||||
@@ -54,7 +56,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
|
||||
name="nps"
|
||||
value={number}
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
onChange={() => handleSelect(number)}
|
||||
onClick={() => handleSelect(number)}
|
||||
required={question.required}
|
||||
/>
|
||||
{number}
|
||||
|
||||
@@ -108,6 +108,9 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
personId: personId,
|
||||
finished,
|
||||
data,
|
||||
meta: {
|
||||
url: window.location.href,
|
||||
},
|
||||
};
|
||||
if (!responseId && !isPreview) {
|
||||
const response = await createResponse(
|
||||
|
||||
@@ -6,33 +6,33 @@ Follow the instructions below to quickly get Formbricks running on your system w
|
||||
|
||||
Open a terminal and create a new directory for Formbricks, then navigate into this new directory:
|
||||
|
||||
\```bash
|
||||
```bash
|
||||
mkdir formbricks-quickstart && cd formbricks-quickstart
|
||||
\```
|
||||
```
|
||||
|
||||
2. **Download the Docker-Compose File**
|
||||
|
||||
Download the docker-compose file directly from the Formbricks repository:
|
||||
|
||||
\```bash
|
||||
```bash
|
||||
curl -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/docker/main/docker-compose.yml
|
||||
\```
|
||||
```
|
||||
|
||||
3. **Generate NextAuth Secret**
|
||||
|
||||
Next, you need to generate a NextAuth secret. This will be used for session signing and encryption. The `sed` command below generates a random string using `openssl`, then replaces the `NEXTAUTH_SECRET:` placeholder in the `docker-compose.yml` file with this generated secret:
|
||||
|
||||
\```bash
|
||||
```bash
|
||||
sed -i "/NEXTAUTH_SECRET:$/s/NEXTAUTH_SECRET:.\*/NEXTAUTH_SECRET: $(openssl rand -base64 32)/" docker-compose.yml
|
||||
\```
|
||||
```
|
||||
|
||||
4. **Start the Docker Setup**
|
||||
|
||||
You're now ready to start the Formbricks Docker setup. The following command will start Formbricks together with a postgreSQL database using Docker Compose:
|
||||
|
||||
\```bash
|
||||
```bash
|
||||
docker compose up -d
|
||||
\```
|
||||
```
|
||||
|
||||
The `-d` flag will run the containers in detached mode, meaning they'll run in the background.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"db:migrate:deploy": "turbo run db:migrate:deploy",
|
||||
"db:migrate:vercel": "turbo run db:migrate:vercel",
|
||||
"db:push": "turbo run db:push",
|
||||
"go": "turbo run go",
|
||||
"go": "turbo run go --concurrency 16",
|
||||
"dev": "turbo run dev --parallel",
|
||||
"start": "turbo run start --parallel",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"db:up": "docker-compose up -d",
|
||||
"db:setup": "pnpm db:up && pnpm db:migrate:dev",
|
||||
"db:start": "pnpm db:setup",
|
||||
"go": "pnpm db:setup",
|
||||
"format": "prisma format",
|
||||
"generate": "prisma generate",
|
||||
"lint": "eslint ./src --fix",
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
|
||||
const handleSelect = (number: number) => {
|
||||
setSelectedChoice(number);
|
||||
if (question.required) {
|
||||
setSelectedChoice(null);
|
||||
onSubmit({
|
||||
[question.id]: number,
|
||||
});
|
||||
@@ -36,6 +37,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
|
||||
data[question.id] = selectedChoice;
|
||||
}
|
||||
|
||||
setSelectedChoice(null);
|
||||
onSubmit(data);
|
||||
// reset form
|
||||
}}>
|
||||
@@ -57,7 +59,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
|
||||
name="nps"
|
||||
value={number}
|
||||
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
|
||||
onChange={() => handleSelect(number)}
|
||||
onClick={() => handleSelect(number)}
|
||||
required={question.required}
|
||||
/>
|
||||
{number}
|
||||
|
||||
@@ -187,6 +187,9 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
|
||||
personId: config.state.person.id,
|
||||
finished,
|
||||
data,
|
||||
meta: {
|
||||
url: window.location.href,
|
||||
},
|
||||
};
|
||||
if (!responseId) {
|
||||
const [response, _] = await Promise.all([
|
||||
|
||||
@@ -38,42 +38,40 @@ export const getEnvironment = cache(async (environmentId: string): Promise<TEnvi
|
||||
}
|
||||
});
|
||||
|
||||
export const getEnvironments = cache(
|
||||
async (productId: string): Promise<TEnvironment[]> => {
|
||||
let productPrisma;
|
||||
try {
|
||||
productPrisma = await prisma.product.findFirst({
|
||||
where: {
|
||||
id: productId,
|
||||
},
|
||||
include:{
|
||||
environments:true
|
||||
}
|
||||
});
|
||||
export const getEnvironments = cache(async (productId: string): Promise<TEnvironment[]> => {
|
||||
let productPrisma;
|
||||
try {
|
||||
productPrisma = await prisma.product.findFirst({
|
||||
where: {
|
||||
id: productId,
|
||||
},
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!productPrisma) {
|
||||
throw new ResourceNotFoundError("Product", productId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw error;
|
||||
if (!productPrisma) {
|
||||
throw new ResourceNotFoundError("Product", productId);
|
||||
}
|
||||
|
||||
const environments:TEnvironment[]=[];
|
||||
for(let environment of productPrisma.environments){
|
||||
let targetEnvironment:TEnvironment=ZEnvironment.parse(environment);
|
||||
environments.push(targetEnvironment);
|
||||
}
|
||||
|
||||
try {
|
||||
return environments;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
throw new ValidationError("Data validation of environments array failed");
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
|
||||
const environments: TEnvironment[] = [];
|
||||
for (let environment of productPrisma.environments) {
|
||||
let targetEnvironment: TEnvironment = ZEnvironment.parse(environment);
|
||||
environments.push(targetEnvironment);
|
||||
}
|
||||
|
||||
try {
|
||||
return environments;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
throw new ValidationError("Data validation of environments array failed");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,13 +33,10 @@ type TransformPersonInput = {
|
||||
};
|
||||
|
||||
export const transformPrismaPerson = (person: TransformPersonInput): TPerson => {
|
||||
const attributes = person.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
const attributes = person.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string | number>);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
|
||||
@@ -41,4 +41,3 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
|
||||
throw new ValidationError("Data validation of product failed");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const ZResponseNote = z.object({
|
||||
export type TResponseNote = z.infer<typeof ZResponseNote>;
|
||||
|
||||
export const ZResponseMeta = z.object({
|
||||
url: z.string(),
|
||||
userAgent: z.object({
|
||||
browser: z.string().optional(),
|
||||
os: z.string().optional(),
|
||||
@@ -67,6 +68,7 @@ export const ZResponseInput = z.object({
|
||||
data: ZResponseData,
|
||||
meta: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
userAgent: z
|
||||
.object({
|
||||
browser: z.string().optional(),
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ColorPicker = ({ color, onChange }: { color: string; onChange: (v:
|
||||
<div>
|
||||
#
|
||||
<HexColorInput
|
||||
className="ml-2 mr-2 h-10 w-16 bg-transparent text-slate-500 outline-none focus:border-none"
|
||||
className="ml-2 mr-2 h-10 w-32 border-0 bg-transparent text-slate-500 outline-none focus:border-none"
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface InputProps
|
||||
dangerouslySetInnerHTML?: {
|
||||
__html: string;
|
||||
};
|
||||
isInvalid?: boolean;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
|
||||
@@ -14,7 +15,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...pr
|
||||
<input
|
||||
className={cn(
|
||||
"focus:border-brand 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 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
|
||||
className
|
||||
className,
|
||||
props.isInvalid && "border border-red-600 focus:border-red-600"
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
11
turbo.json
11
turbo.json
@@ -4,7 +4,11 @@
|
||||
"@formbricks/web#go": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["@formbricks/database#go", "@formbricks/js#build"]
|
||||
"dependsOn": ["@formbricks/database#db:setup", "@formbricks/js#build"]
|
||||
},
|
||||
"@formbricks/api#build": {
|
||||
"outputs": ["dist/**"],
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"@formbricks/js#build": {
|
||||
"outputs": ["dist/**"],
|
||||
@@ -72,7 +76,12 @@
|
||||
"VERCEL_URL"
|
||||
]
|
||||
},
|
||||
"db:setup": {
|
||||
"cache": false,
|
||||
"outputs": []
|
||||
},
|
||||
"go": {
|
||||
"persistent": true,
|
||||
"cache": false
|
||||
},
|
||||
"prebuild": {
|
||||
|
||||
Reference in New Issue
Block a user