Merge branch 'main' of github.com:formbricks/formbricks into feat/close-date-edge-case

This commit is contained in:
Matthias Nannt
2023-07-25 16:00:05 +02:00
39 changed files with 615 additions and 407 deletions

View File

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

View File

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

View 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.

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

View File

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

View File

@@ -24,12 +24,7 @@ export default function AttributeDetailModal({
},
{
title: "Settings",
children: (
<AttributeSettingsTab
attributeClass={attributeClass}
setOpen={setOpen}
/>
),
children: <AttributeSettingsTab attributeClass={attributeClass} setOpen={setOpen} />,
},
];

View File

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

View File

@@ -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&apos;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&apos;re all set! Time to create your first survey.
</h1>
</div>
<TemplateList
environmentId={environmentId}
onTemplateClick={(template) => {
newSurveyFromTemplate(template);
}}
environment={environment}
product={product}
/>
</>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}\"",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,4 +41,3 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
throw new ValidationError("Data validation of product failed");
}
});

View File

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

View File

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

View File

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

View File

@@ -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": {