mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 21:32:02 -06:00
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:
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function LinkTab({ surveyUrl, survey, brandColor }: EmailTabProps
|
||||
autoFocus={false}
|
||||
isRedirectDisabled={false}
|
||||
key={survey.id}
|
||||
onFileUpload={async () => ""}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -202,6 +202,7 @@ export default function QuestionsView({
|
||||
<QuestionCard
|
||||
key={internalQuestionIdMap[question.id]}
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
questionIdx={questionIdx}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
86
packages/api/src/api/client/storage.ts
Normal file
86
packages/api/src/api/client/storage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
22
packages/lib/team/hooks/actions.ts
Normal file
22
packages/lib/team/hooks/actions.ts
Normal 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);
|
||||
};
|
||||
34
packages/lib/team/hooks/useGetBillingInfo.ts
Normal file
34
packages/lib/team/hooks/useGetBillingInfo.ts
Normal 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 };
|
||||
};
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
|
||||
@@ -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",
|
||||
|
||||
295
packages/surveys/src/components/general/FileInput.tsx
Normal file
295
packages/surveys/src/components/general/FileInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
77
packages/surveys/src/lib/uploadFile.ts
Normal file
77
packages/surveys/src/lib/uploadFile.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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" : ""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const uploadFile = async (
|
||||
file: File | Blob,
|
||||
allowedFileExtensions: string[],
|
||||
allowedFileExtensions: string[] | undefined,
|
||||
environmentId: string | undefined
|
||||
) => {
|
||||
try {
|
||||
|
||||
80
packages/ui/FileUploadResponse/index.tsx
Normal file
80
packages/ui/FileUploadResponse/index.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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])}
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -687,6 +687,9 @@ importers:
|
||||
|
||||
packages/surveys:
|
||||
devDependencies:
|
||||
'@formbricks/lib':
|
||||
specifier: workspace:*
|
||||
version: link:../lib
|
||||
'@formbricks/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../tsconfig
|
||||
|
||||
Reference in New Issue
Block a user