feat: Introduce FileUpload Question (#1277)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
This commit is contained in:
Naitik Kapadia
2023-11-23 20:17:48 +05:30
committed by GitHub
parent 7e68a5e590
commit 33919578dd
47 changed files with 1307 additions and 60 deletions

View File

@@ -1271,6 +1271,14 @@ export const templates: TTemplate[] = [
buttonUrl: "https://app.formbricks.com/auth/signup",
buttonExternal: true,
},
{
id: createId(),
type: TSurveyQuestionType.FileUpload,
headline: "Upload file",
required: false,
allowMultipleFiles: false,
maxSizeInMB: 10,
},
],
thankYouCard: thankYouCardDefault,
welcomeCard: {

View File

@@ -51,12 +51,12 @@ export const handleFileUpload = async (
const { signature, timestamp, uuid } = signingData;
requestHeaders = {
fileType: file.type,
fileName: file.name,
environmentId: environmentId ?? "",
signature,
timestamp,
uuid,
"X-File-Type": file.type,
"X-File-Name": file.name,
"X-Environment-ID": environmentId ?? "",
"X-Signature": signature,
"X-Timestamp": timestamp,
"X-UUID": uuid,
};
}

View File

@@ -0,0 +1,112 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { DownloadIcon, FileIcon } from "lucide-react";
import Link from "next/link";
interface FileUploadSummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyFileUploadQuestion>;
environmentId: string;
}
export default function FileUploadSummary({ questionSummary, environmentId }: FileUploadSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} required={questionSummary.question.required} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
</div>
</div>
</div>
<div className="rounded-b-lg bg-white ">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.responses.map((response) => {
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="col-span-2 grid">
{response.value === "skipped" && (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">skipped</p>
</div>
)}
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl, index) => (
<div className="relative m-2 rounded-lg bg-slate-200">
<a href={fileUrl as string} key={index} download target="_blank">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{fileUrl.split("/").pop()}
</p>
</div>
</div>
))
) : (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">skipped</p>
</div>
))}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -3,7 +3,11 @@ import ConsentSummary from "@/app/(app)/environments/[environmentId]/surveys/[su
import HiddenFieldsSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
import type {
TSurveyFileUploadQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestionSummary,
} from "@formbricks/types/surveys";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import {
@@ -22,7 +26,8 @@ import MultipleChoiceSummary from "./MultipleChoiceSummary";
import NPSSummary from "./NPSSummary";
import OpenTextSummary from "./OpenTextSummary";
import RatingSummary from "./RatingSummary";
import PictureChoiceSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary";
import FileUploadSummary from "./FileUploadSummary";
import PictureChoiceSummary from "./PictureChoiceSummary";
interface SummaryListProps {
environment: TEnvironment;
@@ -122,6 +127,15 @@ export default function SummaryList({ environment, survey, responses, responsesP
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.FileUpload) {
return (
<FileUploadSummary
key={questionSummary.question.id}
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyFileUploadQuestion>}
environmentId={environment.id}
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) {
return (
<PictureChoiceSummary

View File

@@ -3,11 +3,11 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { Button } from "@formbricks/ui/Button";
import CodeBlock from "@formbricks/ui/CodeBlock";
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
import { CodeBracketIcon, DocumentDuplicateIcon, EnvelopeIcon } from "@heroicons/react/24/solid";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { getEmailHtmlAction, sendEmailAction } from "../../actions";
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
interface EmailTabProps {
surveyId: string;

View File

@@ -60,6 +60,7 @@ export default function LinkTab({ surveyUrl, survey, brandColor }: EmailTabProps
autoFocus={false}
isRedirectDisabled={false}
key={survey.id}
onFileUpload={async () => ""}
/>
<Button

View File

@@ -0,0 +1,234 @@
"use client";
import { useGetBillingInfo } from "@formbricks/lib/team/hooks/useGetBillingInfo";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { PlusIcon, TrashIcon, XCircleIcon } from "@heroicons/react/24/solid";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
interface FileUploadFormProps {
localSurvey: TSurvey;
product?: TProduct;
question: TSurveyFileUploadQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
isInValid: boolean;
}
export default function FileUploadQuestionForm({
question,
questionIdx,
updateQuestion,
isInValid,
product,
}: FileUploadFormProps): JSX.Element {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const [extension, setExtension] = useState("");
const {
billingInfo,
error: billingInfoError,
isLoading: billingInfoLoading,
} = useGetBillingInfo(product?.teamId ?? "");
const handleInputChange = (event) => {
setExtension(event.target.value);
};
const addExtension = (event) => {
event.preventDefault();
event.stopPropagation();
let modifiedExtension = extension.trim();
// Remove the dot at the start if it exists
if (modifiedExtension.startsWith(".")) {
modifiedExtension = modifiedExtension.substring(1);
}
if (!modifiedExtension) {
toast.error("Please enter a file extension.");
return;
}
const parsedExtensionResult = ZAllowedFileExtension.safeParse(modifiedExtension);
if (!parsedExtensionResult.success) {
toast.error("This file type is not supported.");
return;
}
if (question.allowedFileExtensions) {
if (!question.allowedFileExtensions.includes(modifiedExtension as TAllowedFileExtension)) {
updateQuestion(questionIdx, {
allowedFileExtensions: [...question.allowedFileExtensions, modifiedExtension],
});
setExtension("");
} else {
toast.error("This extension is already added.");
}
} else {
updateQuestion(questionIdx, { allowedFileExtensions: [modifiedExtension] });
setExtension("");
}
};
const removeExtension = (event, index: number) => {
event.preventDefault();
if (question.allowedFileExtensions) {
const updatedExtensions = [...question?.allowedFileExtensions];
updatedExtensions.splice(index, 1);
updateQuestion(questionIdx, { allowedFileExtensions: updatedExtensions });
}
};
const maxSizeInMBLimit = useMemo(() => {
if (billingInfoError || billingInfoLoading || !billingInfo) {
return 10;
}
if (billingInfo.features.linkSurvey.status === "active") {
// 1GB in MB
return 1024;
}
return 10;
}, [billingInfo, billingInfoError, billingInfoLoading]);
return (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
</div>
</div>
<div className="mt-3">
{showSubheader && (
<>
<Label htmlFor="subheader">Description</Label>
<div className="mt-2 inline-flex w-full items-center">
<Input
id="subheader"
name="subheader"
value={question.subheader}
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
/>
<TrashIcon
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
/>
</div>
</>
)}
{!showSubheader && (
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
)}
</div>
<div className="mb-8 mt-6 space-y-6">
<AdvancedOptionToggle
isChecked={question.allowMultipleFiles}
onToggle={() => updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })}
htmlId="allowMultipleFile"
title="Allow Multiple Files"
description="Let people upload up to 10 files at the same time."
childBorder
customContainerClass="p-0"></AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={!!question.maxSizeInMB}
onToggle={(checked) => updateQuestion(questionIdx, { maxSizeInMB: checked ? 10 : undefined })}
htmlId="maxFileSize"
title="Max file size"
description="Limit the maximum file size."
childBorder
customContainerClass="p-0">
<label htmlFor="autoCompleteResponses" className="cursor-pointer bg-slate-50 p-4">
<p className="text-sm font-semibold text-slate-700">
Limit upload file size to
<Input
autoFocus
type="number"
id="fileSizeLimit"
value={question.maxSizeInMB}
onChange={(e) => {
const parsedValue = parseInt(e.target.value, 10);
if (parsedValue > maxSizeInMBLimit) {
toast.error(`Max file size limit is ${maxSizeInMBLimit} MB`);
updateQuestion(questionIdx, { maxSizeInMB: maxSizeInMBLimit });
return;
}
updateQuestion(questionIdx, { maxSizeInMB: parseInt(e.target.value, 10) });
}}
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
/>
MB
</p>
</label>
</AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={!!question.allowedFileExtensions}
onToggle={(checked) =>
updateQuestion(questionIdx, { allowedFileExtensions: checked ? [] : undefined })
}
htmlId="limitFileType"
title="Limit file types"
description="Control which file types can be uploaded."
childBorder
customContainerClass="p-0">
<div className="p-4">
<div className="flex flex-row flex-wrap gap-2">
{question.allowedFileExtensions &&
question.allowedFileExtensions.map((item, index) => (
<div className="mb-2 flex h-8 items-center space-x-2 rounded-full bg-slate-200 px-2">
<p className="text-sm text-slate-800">{item}</p>
<Button
className="inline-flex px-0"
variant="minimal"
onClick={(e) => removeExtension(e, index)}>
<XCircleIcon className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="flex items-center">
<Input
autoFocus
className="mr-2 w-20 rounded-md bg-white placeholder:text-sm"
placeholder=".pdf"
value={extension}
onChange={handleInputChange}
type="text"
/>
<Button size="sm" variant="secondary" onClick={(e) => addExtension(e)}>
Allow file type
</Button>
</div>
</div>
</AdvancedOptionToggle>
</div>
</form>
);
}

View File

@@ -81,6 +81,7 @@ export default function LogicEditor({
cta: ["clicked", "skipped"],
consent: ["skipped", "accepted"],
pictureSelection: ["submitted", "skipped"],
fileUpload: ["uploaded", "notUploaded"],
};
const logicConditions: LogicConditions = {
@@ -99,6 +100,16 @@ export default function LogicEditor({
values: null,
unique: true,
},
uploaded: {
label: "has uploaded file",
values: null,
unique: true,
},
notUploaded: {
label: "has not uploaded file",
values: null,
unique: true,
},
clicked: {
label: "is clicked",
values: null,
@@ -242,7 +253,7 @@ export default function LogicEditor({
<SelectContent>
{conditions[question.type].map(
(condition) =>
!(question.required && condition === "skipped") && (
!(question.required && (condition === "skipped" || condition === "notUploaded")) && (
<SelectItem
key={condition}
value={condition}

View File

@@ -18,11 +18,13 @@ import {
PresentationChartBarIcon,
QueueListIcon,
StarIcon,
ArrowUpTrayIcon,
PhotoIcon,
} from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { Draggable } from "react-beautiful-dnd";
import FileUploadQuestionForm from "./FileUploadQuestionForm";
import CTAQuestionForm from "./CTAQuestionForm";
import ConsentQuestionForm from "./ConsentQuestionForm";
import MultipleChoiceMultiForm from "./MultipleChoiceMultiForm";
@@ -32,9 +34,11 @@ import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionMenu";
import RatingQuestionForm from "./RatingQuestionForm";
import PictureSelectionForm from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm";
import { TProduct } from "@formbricks/types/product";
interface QuestionCardProps {
localSurvey: TSurvey;
product?: TProduct;
questionIdx: number;
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
@@ -74,6 +78,7 @@ export function BackButtonInput({
export default function QuestionCard({
localSurvey,
product,
questionIdx,
moveQuestion,
updateQuestion,
@@ -123,7 +128,9 @@ export default function QuestionCard({
<div>
<div className="inline-flex">
<div className="-ml-0.5 mr-3 h-6 w-6 text-slate-400">
{question.type === TSurveyQuestionType.OpenText ? (
{question.type === TSurveyQuestionType.FileUpload ? (
<ArrowUpTrayIcon />
) : question.type === TSurveyQuestionType.OpenText ? (
<ChatBubbleBottomCenterTextIcon />
) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? (
<QueueListIcon />
@@ -236,6 +243,16 @@ export default function QuestionCard({
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : question.type === TSurveyQuestionType.FileUpload ? (
<FileUploadQuestionForm
localSurvey={localSurvey}
product={product}
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : null}
<div className="mt-4">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">

View File

@@ -202,6 +202,7 @@ export default function QuestionsView({
<QuestionCard
key={internalQuestionIdMap[question.id]}
localSurvey={localSurvey}
product={product}
questionIdx={questionIdx}
moveQuestion={moveQuestion}
updateQuestion={updateQuestion}

View File

@@ -184,9 +184,9 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
/>
<Label htmlFor="autoComplete" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Overwrite border highlight</h3>
<h3 className="text-sm font-semibold text-slate-700">Overwrite Highlight Border</h3>
<p className="text-xs font-normal text-slate-500">
Change the border highlight for this survey.
Change the highlight border for this survey.
</p>
</div>
</Label>

View File

@@ -207,6 +207,7 @@ export default function PreviewSurvey({
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={async () => ""}
/>
</Modal>
) : (
@@ -221,6 +222,7 @@ export default function PreviewSurvey({
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
onFileUpload={async () => ""}
/>
</div>
</div>
@@ -277,6 +279,7 @@ export default function PreviewSurvey({
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={async () => ""}
/>
</Modal>
) : (
@@ -290,6 +293,7 @@ export default function PreviewSurvey({
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={async () => ""}
/>
</div>
</div>

View File

@@ -1,5 +1,9 @@
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurvey, TSurveyHiddenFields, TSurveyWelcomeCard } from "@formbricks/types/surveys";
import {
TSurvey,
TSurveyHiddenFields,
TSurveyQuestionType,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys";
import { TTemplate } from "@formbricks/types/templates";
import { createId } from "@paralleldrive/cuid2";
@@ -2478,7 +2482,7 @@ export const customSurvey: TTemplate = {
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "Custom Survey",
headline: "What would you like to know?",
subheader: "This is an example survey.",
placeholder: "Type your answer here...",
required: true,

View File

@@ -17,19 +17,33 @@ interface Context {
};
}
export async function OPTIONS(): Promise<NextResponse> {
return NextResponse.json(
{},
{
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, X-File-Name, X-File-Type, X-Survey-ID, X-Signature, X-Timestamp, X-UUID",
},
}
);
}
export async function POST(req: NextRequest, context: Context): Promise<NextResponse> {
const environmentId = context.params.environmentId;
const accessType = "private"; // private files are accessible only by authorized users
const headersList = headers();
const fileType = headersList.get("fileType");
const fileName = headersList.get("fileName");
const surveyId = headersList.get("surveyId");
const fileType = headersList.get("X-File-Type");
const fileName = headersList.get("X-File-Name");
const surveyId = headersList.get("X-Survey-ID");
const signedSignature = headersList.get("signature");
const signedUuid = headersList.get("uuid");
const signedTimestamp = headersList.get("timestamp");
const signedSignature = headersList.get("X-Signature");
const signedUuid = headersList.get("X-UUID");
const signedTimestamp = headersList.get("X-Timestamp");
if (!fileType) {
return responses.badRequestResponse("contentType is required");

View File

@@ -10,6 +10,10 @@ interface Context {
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
// api endpoint for uploading private files
// uploaded files will be private, only the user who has access to the environment can access the file
// uploading private files requires no authentication

View File

@@ -16,13 +16,13 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const accessType = "public"; // public files are accessible by anyone
const headersList = headers();
const fileType = headersList.get("fileType");
const fileName = headersList.get("fileName");
const environmentId = headersList.get("environmentId");
const fileType = headersList.get("X-File-Type");
const fileName = headersList.get("X-File-Name");
const environmentId = headersList.get("X-Environment-ID");
const signedSignature = headersList.get("signature");
const signedUuid = headersList.get("uuid");
const signedTimestamp = headersList.get("timestamp");
const signedSignature = headersList.get("X-Signature");
const signedUuid = headersList.get("X-UUID");
const signedTimestamp = headersList.get("X-Timestamp");
if (!fileType) {
return responses.badRequestResponse("fileType is required");

View File

@@ -1,5 +1,6 @@
import { TSurveyQuestionType as QuestionId } from "@formbricks/types/surveys";
import {
ArrowUpTrayIcon,
ChatBubbleBottomCenterTextIcon,
CheckIcon,
CursorArrowRippleIcon,
@@ -24,7 +25,7 @@ export const questionTypes: TSurveyQuestionType[] = [
{
id: QuestionId.OpenText,
label: "Free text",
description: "A single line of text",
description: "Ask for a text-based answer",
icon: ChatBubbleBottomCenterTextIcon,
preset: {
headline: "Who let the dogs out?",
@@ -66,7 +67,7 @@ export const questionTypes: TSurveyQuestionType[] = [
{
id: QuestionId.PictureSelection,
label: "Picture Selection",
description: "Select one or more pictures",
description: "Ask respondents to select one or more pictures",
icon: PhotoIcon,
preset: {
headline: "Which is the cutest puppy?",
@@ -87,7 +88,7 @@ export const questionTypes: TSurveyQuestionType[] = [
{
id: QuestionId.Rating,
label: "Rating",
description: "Ask your users to rate something",
description: "Ask respondents for a rating",
icon: StarIcon,
preset: {
headline: "How would you rate {{productName}}",
@@ -112,7 +113,7 @@ export const questionTypes: TSurveyQuestionType[] = [
{
id: QuestionId.CTA,
label: "Call-to-Action",
description: "Ask your users to perform an action",
description: "Prompt respondents to perform an action",
icon: CursorArrowRippleIcon,
preset: {
headline: "You are one of our power users!",
@@ -124,7 +125,7 @@ export const questionTypes: TSurveyQuestionType[] = [
{
id: QuestionId.Consent,
label: "Consent",
description: "Ask your users to accept something",
description: "Ask respondents for consent",
icon: CheckIcon,
preset: {
headline: "Terms and Conditions",
@@ -132,6 +133,16 @@ export const questionTypes: TSurveyQuestionType[] = [
dismissButtonLabel: "Skip",
},
},
{
id: QuestionId.FileUpload,
label: "File Upload",
description: "Allow respondents to upload a file",
icon: ArrowUpTrayIcon,
preset: {
headline: "File Upload",
allowMultipleFiles: false,
},
},
];
export const universalQuestionPresets = {

View File

@@ -14,6 +14,7 @@ import { ArrowPathIcon } from "@heroicons/react/24/solid";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { FormbricksAPI } from "@formbricks/api";
import { TUploadFileConfig } from "@formbricks/types/storage";
interface LinkSurveyProps {
survey: TSurvey;
@@ -167,6 +168,20 @@ export default function LinkSurvey({
},
});
}}
onFileUpload={async (file: File, params: TUploadFileConfig) => {
const api = new FormbricksAPI({
apiHost: webAppUrl,
environmentId: survey.environmentId,
});
try {
const uploadedUrl = await api.client.storage.uploadFile(file, params);
return uploadedUrl;
} catch (err) {
console.error(err);
return "";
}
}}
onActiveQuestionChange={(questionId) => setActiveQuestionId(questionId)}
activeQuestionId={activeQuestionId}
autoFocus={autoFocus}

View File

@@ -3,12 +3,14 @@ import { DisplayAPI } from "./display";
import { ApiConfig } from "../../types";
import { ActionAPI } from "./action";
import { PeopleAPI } from "./people";
import { StorageAPI } from "./storage";
export class Client {
response: ResponseAPI;
display: DisplayAPI;
action: ActionAPI;
people: PeopleAPI;
storage: StorageAPI;
constructor(options: ApiConfig) {
const { apiHost, environmentId } = options;
@@ -17,5 +19,6 @@ export class Client {
this.display = new DisplayAPI(apiHost, environmentId);
this.action = new ActionAPI(apiHost, environmentId);
this.people = new PeopleAPI(apiHost, environmentId);
this.storage = new StorageAPI(apiHost, environmentId);
}
}

View File

@@ -0,0 +1,86 @@
interface UploadFileConfig {
allowedFileExtensions?: string[];
surveyId?: string;
}
export class StorageAPI {
private apiHost: string;
private environmentId: string;
constructor(apiHost: string, environmentId: string) {
this.apiHost = apiHost;
this.environmentId = environmentId;
}
async uploadFile(
file: File,
{ allowedFileExtensions, surveyId }: UploadFileConfig | undefined = {}
): Promise<string> {
if (!(file instanceof Blob) || !(file instanceof File)) {
throw new Error(`Invalid file type. Expected Blob or File, but received ${typeof file}`);
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions,
surveyId,
};
const response = await fetch(`${this.apiHost}/api/v1/client/${this.environmentId}/storage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Upload failed with status: ${response.status}`);
}
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields } = data;
let requestHeaders: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
requestHeaders = {
"X-File-Type": file.type,
"X-File-Name": file.name,
"X-Survey-ID": surveyId ?? "",
"X-Signature": signature,
"X-Timestamp": timestamp,
"X-UUID": uuid,
};
}
const formData = new FormData();
if (presignedFields) {
Object.keys(presignedFields).forEach((key) => {
formData.append(key, presignedFields[key]);
});
}
// Add the actual file to be uploaded
formData.append("file", file);
const uploadResponse = await fetch(signedUrl, {
method: "POST",
...(signingData ? { headers: requestHeaders } : {}),
body: formData,
});
if (!uploadResponse.ok) {
const uploadJson = await uploadResponse.json();
throw new Error(`${uploadJson.message}`);
}
return fileUrl;
}
}

View File

@@ -130,6 +130,14 @@ export const renderWidget = (survey: TSurvey) => {
});
},
onClose: closeSurvey,
onFileUpload: async (file: File, params) => {
const api = new FormbricksAPI({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
return await api.client.storage.uploadFile(file, params);
},
});
}, survey.delay * 1000);
};

View File

@@ -66,10 +66,6 @@ export const MAX_SIZES = {
} as const;
export const IS_S3_CONFIGURED: boolean =
env.S3_ACCESS_KEY && env.S3_SECRET_KEY && env.S3_REGION && env.S3_BUCKET_NAME ? true : false;
export const LOCAL_UPLOAD_URL = {
public: new URL(`${WEBAPP_URL}/api/v1/management/storage/local`).href,
private: new URL(`${WEBAPP_URL}/api/v1/client/storage/local`).href,
} as const;
// Pricing
export const PRICING_USERTARGETING_FREE_MTU = 2500;

View File

@@ -83,6 +83,10 @@ export const getProductByEnvironmentId = async (environmentId: string): Promise<
select: selectProduct,
});
if (!productPrisma) {
return null;
}
return productPrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -264,9 +268,7 @@ export const createProduct = async (
type: "production",
});
product = await updateProduct(product.id, {
return await updateProduct(product.id, {
environments: [devEnvironment, prodEnvironment],
});
return product;
};

View File

@@ -11,7 +11,7 @@ import { access, mkdir, writeFile, readFile, unlink, rmdir } from "fs/promises";
import { join } from "path";
import mime from "mime";
import { env } from "../env.mjs";
import { IS_S3_CONFIGURED, LOCAL_UPLOAD_URL, MAX_SIZES, UPLOADS_DIR, WEBAPP_URL } from "../constants";
import { IS_S3_CONFIGURED, MAX_SIZES, UPLOADS_DIR, WEBAPP_URL } from "../constants";
import { unstable_cache } from "next/cache";
import { storageCache } from "./cache";
import { TAccessType } from "@formbricks/types/storage";
@@ -175,7 +175,10 @@ export const getUploadSignedUrl = async (
const { signature, timestamp, uuid } = generateLocalSignedUrl(fileName, environmentId, fileType);
return {
signedUrl: LOCAL_UPLOAD_URL[accessType],
signedUrl:
accessType === "private"
? new URL(`${WEBAPP_URL}/api/v1/client/${environmentId}/storage/local`).href
: new URL(`${WEBAPP_URL}/api/v1/management/storage/local`).href,
signingData: {
signature,
timestamp,

View File

@@ -0,0 +1,22 @@
"use server";
import "server-only";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { authOptions } from "../../authOptions";
import { getServerSession } from "next-auth";
import { getTeam, getTeamBillingInfo } from "../service";
export const getTeamBillingInfoAction = async (teamId: string) => {
const session = await getServerSession(authOptions);
const team = await getTeam(teamId);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
if (!team) {
throw new ResourceNotFoundError("Team", teamId);
}
return await getTeamBillingInfo(teamId);
};

View File

@@ -0,0 +1,34 @@
import { TTeamBilling } from "@formbricks/types/teams";
import { useState, useEffect } from "react";
import { getTeamBillingInfoAction } from "./actions";
export const useGetBillingInfo = (teamId: string) => {
const [billingInfo, setBillingInfo] = useState<TTeamBilling>();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
useEffect(() => {
const getBillingInfo = async () => {
try {
setIsLoading(true);
const billingInfo = await getTeamBillingInfoAction(teamId);
if (!billingInfo) {
setError("No billing info found");
setIsLoading(false);
return;
}
setIsLoading(false);
setBillingInfo(billingInfo);
} catch (err: any) {
setIsLoading(false);
setError(err.message);
}
};
getBillingInfo();
}, [teamId]);
return { billingInfo, isLoading, error };
};

View File

@@ -4,7 +4,7 @@ import { prisma } from "@formbricks/database";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TTeam, TTeamUpdateInput, ZTeamUpdateInput } from "@formbricks/types/teams";
import { TTeam, TTeamBilling, TTeamUpdateInput, ZTeamUpdateInput } from "@formbricks/types/teams";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
@@ -361,3 +361,21 @@ export const getMonthlyTeamResponseCount = async (teamId: string): Promise<numbe
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getTeamBillingInfo = async (teamId: string): Promise<TTeamBilling | null> =>
await unstable_cache(
async () => {
const billingInfo = await prisma.team.findUnique({
where: {
id: teamId,
},
});
return billingInfo?.billing ?? null;
},
[`getTeamBillingInfo-${teamId}`],
{
revalidate: SERVICES_REVALIDATION_INTERVAL,
tags: [teamCache.tag.byId(teamId)],
}
)();

View File

@@ -23,6 +23,7 @@
"clean": "rimraf .turbo node_modules dist"
},
"devDependencies": {
"@formbricks/lib": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@preact/preset-vite": "^2.7.0",

View File

@@ -0,0 +1,295 @@
import { TAllowedFileExtension } from "@formbricks/types/common";
import { useMemo } from "preact/hooks";
import { JSXInternal } from "preact/src/jsx";
import { useState } from "react";
import { TUploadFileConfig } from "@formbricks/types/storage";
interface MultipleFileInputProps {
allowedFileExtensions?: TAllowedFileExtension[];
surveyId: string | undefined;
onUploadCallback: (uploadedUrls: string[]) => void;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
fileUrls: string[] | undefined;
maxSizeInMB?: number;
allowMultipleFiles?: boolean;
}
export default function FileInput({
allowedFileExtensions,
surveyId,
onUploadCallback,
onFileUpload,
fileUrls,
maxSizeInMB,
allowMultipleFiles,
}: MultipleFileInputProps) {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const handleFileUpload = async (file: File) => {
if (file) {
if (maxSizeInMB) {
const fileBuffer = await file.arrayBuffer();
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
if (bufferKB > maxSizeInMB * 1024) {
alert(`File should be less than ${maxSizeInMB} MB`);
} else {
setIsUploading(true);
try {
const response = await onFileUpload(file, { allowedFileExtensions, surveyId });
setSelectedFiles([...selectedFiles, file]);
setIsUploading(false);
if (fileUrls) {
onUploadCallback([...fileUrls, response]);
} else {
onUploadCallback([response]);
}
} catch (err: any) {
setIsUploading(false);
if (err.message === "File size exceeds the 10 MB limit") {
alert(err.message);
} else {
alert("Upload failed! Please try again.");
}
}
}
} else {
setIsUploading(true);
try {
const response = await onFileUpload(file, { allowedFileExtensions, surveyId });
setSelectedFiles([...selectedFiles, file]);
setIsUploading(false);
if (fileUrls) {
onUploadCallback([...fileUrls, response]);
} else {
onUploadCallback([response]);
}
} catch (err: any) {
setIsUploading(false);
if (err.message === "File size exceeds the 10 MB limit") {
alert(err.message);
} else {
alert("Upload failed! Please try again.");
}
}
}
} else {
alert("Please select a file");
}
};
const handleDragOver = (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
// @ts-expect-error
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = async (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
// @ts-expect-error
const files = Array.from(e.dataTransfer.files);
if (!allowMultipleFiles && files.length > 1) {
alert("Only one file can be uploaded at a time.");
return;
}
if (files.length > 0) {
const validFiles = files.filter((file) =>
allowedFileExtensions && allowedFileExtensions.length > 0
? allowedFileExtensions.includes(
file.type.substring(file.type.lastIndexOf("/") + 1) as TAllowedFileExtension
)
: true
);
if (validFiles.length > 0) {
const uploadedUrls: string[] = [];
for (const file of validFiles) {
if (maxSizeInMB) {
const fileBuffer = await file.arrayBuffer();
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
if (bufferKB > maxSizeInMB * 1024) {
alert(`File should be less than ${maxSizeInMB} MB`);
} else {
setIsUploading(true);
try {
const response = await onFileUpload(file, { allowedFileExtensions, surveyId });
setSelectedFiles([...selectedFiles, file]);
uploadedUrls.push(response);
} catch (err: any) {
setIsUploading(false);
if (err.message === "File size exceeds the 10 MB limit") {
alert(err.message);
} else {
alert("Upload failed! Please try again.");
}
}
}
} else {
setIsUploading(true);
try {
const response = await onFileUpload(file, { allowedFileExtensions, surveyId });
setSelectedFiles([...selectedFiles, file]);
uploadedUrls.push(response);
} catch (err: any) {
setIsUploading(false);
if (err.message === "File size exceeds the 10 MB limit") {
alert(err.message);
} else {
alert("Upload failed! Please try again.");
}
}
}
}
setIsUploading(false);
if (fileUrls) {
onUploadCallback([...fileUrls, ...uploadedUrls]);
} else {
onUploadCallback(uploadedUrls);
}
} else {
alert("no selected files are valid");
}
}
};
const handleDeleteFile = (index: number, event: JSXInternal.TargetedMouseEvent<SVGSVGElement>) => {
event.stopPropagation();
if (fileUrls) {
const newFiles = [...selectedFiles];
newFiles.splice(index, 1);
setSelectedFiles(newFiles);
const updatedFileUrls = [...fileUrls];
updatedFileUrls.splice(index, 1);
onUploadCallback(updatedFileUrls);
}
};
const showUploader = useMemo(() => {
if (isUploading) {
return false;
}
if (allowMultipleFiles) {
return true;
}
if (fileUrls && fileUrls.length > 0) {
return false;
}
return true;
}, [allowMultipleFiles, fileUrls, isUploading]);
return (
<div className="items-left relative mt-3 flex w-full cursor-pointer flex-col justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800">
<div>
{fileUrls &&
fileUrls?.map((file, index) => (
<div key={index} className="relative m-2 rounded-md bg-slate-200">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-slate-100 hover:bg-slate-50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 26 26"
strokeWidth={1}
stroke="currentColor"
className="h-5 text-slate-700 hover:text-slate-900"
onClick={(e) => handleDeleteFile(index, e)}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10 10m0-10L9 19" />
</svg>
</div>
</div>
<div className="flex flex-col items-center justify-center p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-file"
className="h-6 text-slate-500">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
{decodeURIComponent(file).split("/").pop()}
</p>
</div>
</div>
))}
</div>
<div>
{isUploading && (
<div className="inset-0 flex animate-pulse items-center justify-center rounded-lg bg-slate-100 py-4">
<label htmlFor="selectedFile" className="text-sm font-medium text-slate-500">
Uploading...
</label>
</div>
)}
<label htmlFor="selectedFile" onDragOver={(e) => handleDragOver(e)} onDrop={(e) => handleDrop(e)}>
{showUploader && (
<div className="flex flex-col items-center justify-center py-6">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 text-slate-500">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
<span className="font-medium">Click or drag to upload files.</span>
</p>
<input
type="file"
id="selectedFile"
name="selectedFile"
accept={allowedFileExtensions?.map((ext) => `.${ext}`).join(",")}
className="hidden"
onChange={(e) => {
const inputElement = e.target as HTMLInputElement; // Cast e.target to HTMLInputElement
if (inputElement.files) {
handleFileUpload(inputElement.files[0]);
}
}}
/>
</div>
)}
</label>
</div>
</div>
);
}

View File

@@ -1,13 +1,15 @@
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
import CTAQuestion from "@/components/questions/CTAQuestion";
import ConsentQuestion from "@/components/questions/ConsentQuestion";
import FileUploadQuestion from "@/components/questions/FileUploadQuestion";
import MultipleChoiceMultiQuestion from "@/components/questions/MultipleChoiceMultiQuestion";
import MultipleChoiceSingleQuestion from "@/components/questions/MultipleChoiceSingleQuestion";
import NPSQuestion from "@/components/questions/NPSQuestion";
import OpenTextQuestion from "@/components/questions/OpenTextQuestion";
import PictureSelectionQuestion from "@/components/questions/PictureSelectionQuestion";
import RatingQuestion from "@/components/questions/RatingQuestion";
import { TResponseData } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
interface QuestionConditionalProps {
question: TSurveyQuestion;
@@ -15,9 +17,11 @@ interface QuestionConditionalProps {
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData) => void;
onBack: () => void;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
isFirstQuestion: boolean;
isLastQuestion: boolean;
autoFocus?: boolean;
surveyId: string;
}
export default function QuestionConditional({
@@ -29,6 +33,8 @@ export default function QuestionConditional({
isFirstQuestion,
isLastQuestion,
autoFocus = true,
surveyId,
onFileUpload,
}: QuestionConditionalProps) {
return question.type === TSurveyQuestionType.OpenText ? (
<OpenTextQuestion
@@ -111,5 +117,17 @@ export default function QuestionConditional({
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
/>
) : question.type === TSurveyQuestionType.FileUpload ? (
<FileUploadQuestion
surveyId={surveyId}
question={question}
value={value}
onChange={onChange}
onSubmit={onSubmit}
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
onFileUpload={onFileUpload}
/>
) : null;
}

View File

@@ -21,6 +21,7 @@ export function Survey({
onFinished = () => {},
isRedirectDisabled = false,
prefillResponseData,
onFileUpload,
}: SurveyBaseProps) {
const [questionId, setQuestionId] = useState(
activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id)
@@ -146,11 +147,13 @@ export function Survey({
return (
currQues && (
<QuestionConditional
surveyId={survey.id}
question={currQues}
value={responseData[currQues.id]}
onChange={onChange}
onSubmit={onSubmit}
onBack={onBack}
onFileUpload={onFileUpload}
isFirstQuestion={
history && prefillResponseData
? history[history.length - 1] === survey.questions[0].id

View File

@@ -11,6 +11,7 @@ export function SurveyInline({
onClose = () => {},
prefillResponseData,
isRedirectDisabled = false,
onFileUpload,
}: SurveyBaseProps) {
return (
<div id="fbjs" className="formbricks-form h-full w-full">
@@ -24,6 +25,7 @@ export function SurveyInline({
onClose={onClose}
prefillResponseData={prefillResponseData}
isRedirectDisabled={isRedirectDisabled}
onFileUpload={onFileUpload}
/>
</div>
);

View File

@@ -16,6 +16,7 @@ export function SurveyModal({
onResponse = () => {},
onClose = () => {},
onFinished = () => {},
onFileUpload,
isRedirectDisabled = false,
}: SurveyModalProps) {
const [isOpen, setIsOpen] = useState(true);
@@ -52,6 +53,7 @@ export function SurveyModal({
}
}, 4000); // close modal automatically after 4 seconds
}}
onFileUpload={onFileUpload}
isRedirectDisabled={isRedirectDisabled}
/>
</Modal>

View File

@@ -0,0 +1,87 @@
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
import { BackButton } from "../buttons/BackButton";
import SubmitButton from "../buttons/SubmitButton";
import FileInput from "../general/FileInput";
import Headline from "../general/Headline";
import Subheader from "../general/Subheader";
import { TUploadFileConfig } from "@formbricks/types/storage";
interface FileUploadQuestionProps {
question: TSurveyFileUploadQuestion;
value: string | number | string[];
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData) => void;
onBack: () => void;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
isFirstQuestion: boolean;
isLastQuestion: boolean;
surveyId: string;
}
export default function FileUploadQuestion({
question,
value,
onChange,
onSubmit,
onBack,
isFirstQuestion,
isLastQuestion,
surveyId,
onFileUpload,
}: FileUploadQuestionProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (question.required) {
if (value && (typeof value === "string" || Array.isArray(value)) && value.length > 0) {
onSubmit({ [question.id]: typeof value === "string" ? [value] : value });
} else {
alert("Please upload a file");
}
} else {
if (value) {
onSubmit({ [question.id]: typeof value === "string" ? [value] : value });
} else {
onSubmit({ [question.id]: "skipped" });
}
}
}}
className="w-full">
<Headline headline={question.headline} questionId={question.id} required={question.required} />
<Subheader subheader={question.subheader} questionId={question.id} />
<FileInput
surveyId={surveyId}
onFileUpload={onFileUpload}
onUploadCallback={(urls: string[]) => {
if (urls) {
onChange({ [question.id]: urls });
} else {
onChange({ [question.id]: "skipped" });
}
}}
fileUrls={value as string[]}
allowMultipleFiles={question.allowMultipleFiles}
{...(!!question.allowedFileExtensions
? { allowedFileExtensions: question.allowedFileExtensions }
: {})}
{...(!!question.maxSizeInMB ? { maxSizeInMB: question.maxSizeInMB } : {})}
/>
<div className="mt-4 flex w-full justify-between">
{!isFirstQuestion && (
<BackButton
backButtonLabel={question.backButtonLabel}
onClick={() => {
onBack();
}}
/>
)}
<div></div>
<SubmitButton buttonLabel={question.buttonLabel} isLastQuestion={isLastQuestion} onClick={() => {}} />
</div>
</form>
);
}

View File

@@ -50,6 +50,19 @@ export function evaluateCondition(logic: TSurveyLogic, responseValue: any): bool
responseValue === undefined ||
responseValue === "dismissed"
);
case "uploaded":
if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else {
return responseValue !== "skipped" && responseValue !== "" && responseValue !== null;
}
case "notUploaded":
return (
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === "skipped"
);
default:
return false;
}

View File

@@ -0,0 +1,77 @@
export const uploadFile = async (
file: File | Blob,
allowedFileExtensions: string[] | undefined,
surveyId: string | undefined,
environmentId: string | undefined
) => {
if (!(file instanceof Blob) || !(file instanceof File)) {
throw new Error(`Invalid file type. Expected Blob or File, but received ${typeof file}`);
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions: allowedFileExtensions,
surveyId: surveyId,
};
const response = await fetch(`/api/v1/client/${environmentId}/storage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Upload failed with status: ${response.status}`);
}
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields } = data;
let requestHeaders: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
requestHeaders = {
fileType: file.type,
fileName: file.name,
surveyId: surveyId ?? "",
signature,
timestamp,
uuid,
};
}
const formData = new FormData();
if (presignedFields) {
Object.keys(presignedFields).forEach((key) => {
formData.append(key, presignedFields[key]);
});
}
// Add the actual file to be uploaded
formData.append("file", file);
const uploadResponse = await fetch(signedUrl, {
method: "POST",
...(signingData ? { headers: requestHeaders } : {}),
body: formData,
});
if (!uploadResponse.ok) {
const uploadJson = await uploadResponse.json();
console.log(uploadJson);
throw new Error(`${uploadJson.message}`);
}
return {
uploaded: true,
url: fileUrl,
};
};

View File

@@ -1,5 +1,6 @@
import { TResponseData, TResponseUpdate } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TUploadFileConfig } from "@formbricks/types/storage";
export interface SurveyBaseProps {
survey: TSurvey;
@@ -13,6 +14,7 @@ export interface SurveyBaseProps {
autoFocus?: boolean;
isRedirectDisabled?: boolean;
prefillResponseData?: TResponseData;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
}
export interface SurveyInlineProps extends SurveyBaseProps {

View File

@@ -11,7 +11,7 @@ export const ZColor = z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/);
export const ZPlacement = z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]);
export type TPlacement = z.infer<typeof ZPlacement>;
export const ZAllowedFileExtensions = z.enum([
export const ZAllowedFileExtension = z.enum([
"png",
"jpeg",
"jpg",
@@ -35,4 +35,4 @@ export const ZAllowedFileExtensions = z.enum([
"tar",
]);
export type TAllowedFileExtensions = z.infer<typeof ZAllowedFileExtensions>;
export type TAllowedFileExtension = z.infer<typeof ZAllowedFileExtension>;

View File

@@ -8,3 +8,10 @@ export const ZStorageRetrievalParams = z.object({
environmentId: z.string().cuid(),
accessType: ZAccessType,
});
export const ZUploadFileConfig = z.object({
allowedFileExtensions: z.array(z.string()).optional(),
surveyId: z.string().optional(),
});
export type TUploadFileConfig = z.infer<typeof ZUploadFileConfig>;

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { ZColor, ZPlacement } from "./common";
import { ZAllowedFileExtension, ZColor, ZPlacement } from "./common";
import { TPerson } from "./people";
export const ZSurveyThankYouCard = z.object({
@@ -9,6 +9,7 @@ export const ZSurveyThankYouCard = z.object({
});
export enum TSurveyQuestionType {
FileUpload = "fileUpload",
OpenText = "openText",
MultipleChoiceSingle = "multipleChoiceSingle",
MultipleChoiceMulti = "multipleChoiceMulti",
@@ -105,6 +106,8 @@ export const ZSurveyLogicCondition = z.enum([
"greaterEqual",
"includesAll",
"includesOne",
"uploaded",
"notUploaded",
]);
export type TSurveyLogicCondition = z.infer<typeof ZSurveyLogicCondition>;
@@ -115,6 +118,11 @@ export const ZSurveyLogicBase = z.object({
destination: z.union([z.string(), z.literal("end")]).optional(),
});
export const ZSurveyFileUploadLogic = ZSurveyLogicBase.extend({
condition: z.enum(["uploaded", "notUploaded"]).optional(),
value: z.undefined(),
});
export const ZSurveyOpenTextLogic = ZSurveyLogicBase.extend({
condition: z.enum(["submitted", "skipped"]).optional(),
value: z.undefined(),
@@ -187,6 +195,7 @@ export const ZSurveyLogic = z.union([
ZSurveyCTALogic,
ZSurveyRatingLogic,
ZSurveyPictureSelectionLogic,
ZSurveyFileUploadLogic,
]);
export type TSurveyLogic = z.infer<typeof ZSurveyLogic>;
@@ -206,6 +215,16 @@ const ZSurveyQuestionBase = z.object({
isDraft: z.boolean().optional(),
});
export const ZSurveyFileUploadQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionType.FileUpload),
allowMultipleFiles: z.boolean(),
maxSizeInMB: z.number().optional(),
allowedFileExtensions: z.array(ZAllowedFileExtension).optional(),
logic: z.array(ZSurveyFileUploadLogic).optional(),
});
export type TSurveyFileUploadQuestion = z.infer<typeof ZSurveyFileUploadQuestion>;
export const ZSurveyOpenTextQuestionInputType = z.enum(["text", "email", "url", "number", "phone"]);
export type TSurveyOpenTextQuestionInputType = z.infer<typeof ZSurveyOpenTextQuestionInputType>;
@@ -300,7 +319,6 @@ export const ZSurveyPictureSelectionQuestion = ZSurveyQuestionBase.extend({
export type TSurveyPictureSelectionQuestion = z.infer<typeof ZSurveyPictureSelectionQuestion>;
export const ZSurveyQuestion = z.union([
// ZSurveyWelcomeQuestion,
ZSurveyOpenTextQuestion,
ZSurveyConsentQuestion,
ZSurveyMultipleChoiceSingleQuestion,
@@ -309,6 +327,7 @@ export const ZSurveyQuestion = z.union([
ZSurveyCTAQuestion,
ZSurveyRatingQuestion,
ZSurveyPictureSelectionQuestion,
ZSurveyFileUploadQuestion,
]);
export type TSurveyQuestion = z.infer<typeof ZSurveyQuestion>;
@@ -395,6 +414,7 @@ export type TSurveyDates = {
export type TSurveyInput = z.infer<typeof ZSurveyInput>;
export const ZSurveyTSurveyQuestionType = z.union([
z.literal("fileUpload"),
z.literal("openText"),
z.literal("multipleChoiceSingle"),
z.literal("multipleChoiceMulti"),

View File

@@ -1,3 +1,4 @@
import { cn } from "@formbricks/lib/cn";
import { Label } from "../Label";
import { Switch } from "../Switch";
@@ -7,8 +8,9 @@ interface AdvancedOptionToggleProps {
htmlId: string;
title: string;
description: any;
children: React.ReactNode;
children?: React.ReactNode;
childBorder?: boolean;
customContainerClass?: string;
}
export function AdvancedOptionToggle({
@@ -19,19 +21,20 @@ export function AdvancedOptionToggle({
description,
children,
childBorder,
customContainerClass,
}: AdvancedOptionToggleProps) {
return (
<div className="px-4 py-2">
<div className={cn("px-4 py-2", customContainerClass)}>
<div className="flex items-center space-x-1">
<Switch id={htmlId} checked={isChecked} onCheckedChange={onToggle} />
<Label htmlFor={htmlId} className="cursor-pointer">
<Label htmlFor={htmlId} className="cursor-pointer rounded-l-lg">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">{title}</h3>
<p className="text-xs font-normal text-slate-500">{description}</p>
</div>
</Label>
</div>
{isChecked && (
{children && isChecked && (
<div
className={`mt-4 flex w-full items-center space-x-1 rounded-lg ${
childBorder ? "border" : ""

View File

@@ -1,6 +1,7 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { TAllowedFileExtensions } from "@formbricks/types/common";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { ArrowUpTrayIcon } from "@heroicons/react/24/solid";
import { FileIcon } from "lucide-react";
@@ -11,11 +12,11 @@ import { uploadFile } from "./lib/fileUpload";
const allowedFileTypesForPreview = ["png", "jpeg", "jpg", "webp"];
const isImage = (name: string) => {
return allowedFileTypesForPreview.includes(name.split(".").pop() as TAllowedFileExtensions);
return allowedFileTypesForPreview.includes(name.split(".").pop() as TAllowedFileExtension);
};
interface FileInputProps {
id: string;
allowedFileExtensions: TAllowedFileExtensions[];
allowedFileExtensions: TAllowedFileExtension[];
environmentId: string | undefined;
onFileUpload: (uploadedUrl: string[] | undefined) => void;
fileUrl?: string | string[];
@@ -48,7 +49,7 @@ const FileInput: React.FC<FileInputProps> = ({
(file) =>
file &&
file.type &&
allowedFileExtensions.includes(file.name.split(".").pop() as TAllowedFileExtensions)
allowedFileExtensions.includes(file.name.split(".").pop() as TAllowedFileExtension)
);
if (allowedFiles.length < files.length) {
@@ -127,7 +128,7 @@ const FileInput: React.FC<FileInputProps> = ({
(file) =>
file &&
file.type &&
allowedFileExtensions.includes(file.name.split(".").pop() as TAllowedFileExtensions)
allowedFileExtensions.includes(file.name.split(".").pop() as TAllowedFileExtension)
);
if (allowedFiles.length < filesToUpload.length) {
@@ -316,7 +317,7 @@ const Uploader = ({
handleDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
uploaderClassName: string;
handleDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
allowedFileExtensions: TAllowedFileExtensions[];
allowedFileExtensions: TAllowedFileExtension[];
multiple: boolean;
handleUpload: (files: File[]) => void;
uploadMore?: boolean;

View File

@@ -2,7 +2,7 @@
const uploadFile = async (
file: File | Blob,
allowedFileExtensions: string[],
allowedFileExtensions: string[] | undefined,
environmentId: string | undefined
) => {
try {

View File

@@ -0,0 +1,80 @@
"use client";
import { FileIcon } from "lucide-react";
interface FileUploadResponseProps {
selected: string | number | string[];
}
export const FileUploadResponse = ({ selected }: FileUploadResponseProps) => {
return (
<>
{selected === "selected" ? (
<div className="ph-no-capture col-span-2 whitespace-pre-wrap font-semibold">skipped</div>
) : (
<div className="col-span-2 grid md:grid-cols-2 lg:grid-cols-4">
{Array.isArray(selected) ? (
selected.map((fileUrl, index) => (
<div className="relative m-2 ml-0 rounded-lg bg-slate-200">
<a href={fileUrl as string} key={index} download>
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
className="h-6 w-6">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{decodeURIComponent(fileUrl).split("/").pop()}
</p>
</div>
</div>
))
) : (
<div className="relative m-2 rounded-lg bg-slate-300">
<a href={selected as string} download>
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-100 bg-opacity-50 hover:bg-slate-200/50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
className="h-6 w-6">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{selected && typeof selected === "string" && decodeURIComponent(selected).split("/").pop()}
</p>
</div>
</div>
)}
</div>
)}
</>
);
};

View File

@@ -25,6 +25,7 @@ import ResponseNotes from "./components/ResponseNote";
import ResponseTagsWrapper from "./components/ResponseTagsWrapper";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { PictureSelectionResponse } from "../PictureSelectionResponse";
import { FileUploadResponse } from "../FileUploadResponse";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { LoadingWrapper } from "../LoadingWrapper";
@@ -320,6 +321,8 @@ export default function SingleResponseCard({
choices={question.choices}
selected={response.data[question.id]}
/>
) : question.type === TSurveyQuestionType.FileUpload ? (
<FileUploadResponse selected={response.data[question.id]} />
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{handleArray(response.data[question.id])}

View File

@@ -1,5 +1,6 @@
import { renderSurveyInline, renderSurveyModal } from "@formbricks/surveys";
import { TResponseData, TResponseUpdate } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurvey } from "@formbricks/types/surveys";
import { useEffect, useMemo } from "react";
@@ -15,6 +16,7 @@ interface SurveyProps {
onFinished?: () => void;
onActiveQuestionChange?: (questionId: string) => void;
onClose?: () => void;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
autoFocus?: boolean;
prefillResponseData?: TResponseData;
isRedirectDisabled?: boolean;
@@ -39,6 +41,7 @@ export const SurveyInline = ({
autoFocus,
prefillResponseData,
isRedirectDisabled,
onFileUpload,
}: SurveyProps) => {
const containerId = useMemo(() => createContainerId(), []);
useEffect(() => {
@@ -55,6 +58,7 @@ export const SurveyInline = ({
autoFocus,
prefillResponseData,
isRedirectDisabled,
onFileUpload,
});
}, [
activeQuestionId,
@@ -69,6 +73,7 @@ export const SurveyInline = ({
autoFocus,
prefillResponseData,
isRedirectDisabled,
onFileUpload,
]);
return <div id={containerId} className="h-full w-full" />;
};
@@ -88,6 +93,7 @@ export const SurveyModal = ({
onClose = () => {},
autoFocus,
isRedirectDisabled,
onFileUpload,
}: SurveyModalProps) => {
useEffect(() => {
renderSurveyModal({
@@ -105,6 +111,7 @@ export const SurveyModal = ({
onActiveQuestionChange,
autoFocus,
isRedirectDisabled,
onFileUpload,
});
}, [
activeQuestionId,
@@ -121,6 +128,7 @@ export const SurveyModal = ({
survey,
autoFocus,
isRedirectDisabled,
onFileUpload,
]);
return <div id="formbricks-survey"></div>;
};

3
pnpm-lock.yaml generated
View File

@@ -687,6 +687,9 @@ importers:
packages/surveys:
devDependencies:
'@formbricks/lib':
specifier: workspace:*
version: link:../lib
'@formbricks/tsconfig':
specifier: workspace:*
version: link:../tsconfig