From 33919578dd47d77cad02f175eb50192f48019fb1 Mon Sep 17 00:00:00 2001 From: Naitik Kapadia <88614335+KapadiaNaitik@users.noreply.github.com> Date: Thu, 23 Nov 2023 20:17:48 +0530 Subject: [PATCH] feat: Introduce FileUpload Question (#1277) Co-authored-by: pandeymangg Co-authored-by: Johannes Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> --- .../components/dummyUI/templates.ts | 8 + .../[environmentId]/settings/profile/lib.ts | 12 +- .../summary/components/FileUploadSummary.tsx | 112 +++++++ .../summary/components/SummaryList.tsx | 18 +- .../components/shareEmbedTabs/EmailTab.tsx | 2 +- .../components/shareEmbedTabs/LinkTab.tsx | 1 + .../components/FileUploadQuestionForm.tsx | 234 ++++++++++++++ .../edit/components/LogicEditor.tsx | 13 +- .../edit/components/QuestionCard.tsx | 19 +- .../edit/components/QuestionsView.tsx | 1 + .../edit/components/StylingCard.tsx | 4 +- .../surveys/components/PreviewSurvey.tsx | 4 + .../surveys/templates/templates.ts | 10 +- .../[environmentId]/storage/local/route.ts | 26 +- .../client/[environmentId]/storage/route.ts | 4 + .../api/v1/management/storage/local/route.ts | 12 +- apps/web/app/lib/questions.ts | 21 +- .../s/[surveyId]/components/LinkSurvey.tsx | 15 + packages/api/src/api/client/index.ts | 3 + packages/api/src/api/client/storage.ts | 86 +++++ packages/js/src/lib/widget.ts | 8 + packages/lib/constants.ts | 4 - packages/lib/product/service.ts | 8 +- packages/lib/storage/service.ts | 7 +- packages/lib/team/hooks/actions.ts | 22 ++ packages/lib/team/hooks/useGetBillingInfo.ts | 34 ++ packages/lib/team/service.ts | 20 +- packages/surveys/package.json | 1 + .../src/components/general/FileInput.tsx | 295 ++++++++++++++++++ .../general/QuestionConditional.tsx | 22 +- .../surveys/src/components/general/Survey.tsx | 3 + .../src/components/general/SurveyInline.tsx | 2 + .../src/components/general/SurveyModal.tsx | 2 + .../questions/FileUploadQuestion.tsx | 87 ++++++ packages/surveys/src/lib/logicEvaluator.ts | 13 + packages/surveys/src/lib/uploadFile.ts | 77 +++++ packages/surveys/src/types/props.ts | 2 + packages/types/common.ts | 4 +- packages/types/storage.ts | 7 + packages/types/surveys.ts | 24 +- packages/ui/AdvancedOptionToggle/index.tsx | 11 +- packages/ui/FileInput/index.tsx | 13 +- packages/ui/FileInput/lib/fileUpload.ts | 2 +- packages/ui/FileUploadResponse/index.tsx | 80 +++++ packages/ui/SingleResponseCard/index.tsx | 3 + packages/ui/Survey/index.tsx | 8 + pnpm-lock.yaml | 3 + 47 files changed, 1307 insertions(+), 60 deletions(-) create mode 100644 apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx create mode 100644 packages/api/src/api/client/storage.ts create mode 100644 packages/lib/team/hooks/actions.ts create mode 100644 packages/lib/team/hooks/useGetBillingInfo.ts create mode 100644 packages/surveys/src/components/general/FileInput.tsx create mode 100644 packages/surveys/src/components/questions/FileUploadQuestion.tsx create mode 100644 packages/surveys/src/lib/uploadFile.ts create mode 100644 packages/ui/FileUploadResponse/index.tsx diff --git a/apps/formbricks-com/components/dummyUI/templates.ts b/apps/formbricks-com/components/dummyUI/templates.ts index cc7abdd3bf..8c9521db4c 100644 --- a/apps/formbricks-com/components/dummyUI/templates.ts +++ b/apps/formbricks-com/components/dummyUI/templates.ts @@ -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: { diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/profile/lib.ts b/apps/web/app/(app)/environments/[environmentId]/settings/profile/lib.ts index d36d261ab1..1d53e6643e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/profile/lib.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/profile/lib.ts @@ -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, }; } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx new file mode 100644 index 0000000000..a64bd661ff --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx @@ -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; + environmentId: string; +} + +export default function FileUploadSummary({ questionSummary, environmentId }: FileUploadSummaryProps) { + const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type); + + return ( +
+
+ + +
+
+ {questionTypeInfo && } + {questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question +
+
+ + {questionSummary.responses.length} Responses +
+
+
+
+
+
User
+
Response
+
Time
+
+ {questionSummary.responses.map((response) => { + const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null; + + return ( +
+
+ {response.person ? ( + +
+ +
+

+ {displayIdentifier} +

+ + ) : ( +
+
+ +
+

Anonymous

+
+ )} +
+ +
+ {response.value === "skipped" && ( +
+

skipped

+
+ )} + + {Array.isArray(response.value) && + (response.value.length > 0 ? ( + response.value.map((fileUrl, index) => ( +
+ +
+
+ +
+
+
+ +
+ +

+ {fileUrl.split("/").pop()} +

+
+
+ )) + ) : ( +
+

skipped

+
+ ))} +
+ +
{timeSince(response.updatedAt.toISOString())}
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx index 800c3fe653..d8ff6ee3d0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx @@ -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 ( + } + environmentId={environment.id} + /> + ); + } if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) { return ( ""} /> + )} + +
+ 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"> + + updateQuestion(questionIdx, { maxSizeInMB: checked ? 10 : undefined })} + htmlId="maxFileSize" + title="Max file size" + description="Limit the maximum file size." + childBorder + customContainerClass="p-0"> + + + + + updateQuestion(questionIdx, { allowedFileExtensions: checked ? [] : undefined }) + } + htmlId="limitFileType" + title="Limit file types" + description="Control which file types can be uploaded." + childBorder + customContainerClass="p-0"> +
+
+ {question.allowedFileExtensions && + question.allowedFileExtensions.map((item, index) => ( +
+

{item}

+ +
+ ))} +
+
+ + +
+
+
+
+ + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx index 067e47d488..fd01ac1c3e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx @@ -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({ {conditions[question.type].map( (condition) => - !(question.required && condition === "skipped") && ( + !(question.required && (condition === "skipped" || condition === "notUploaded")) && ( 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({
- {question.type === TSurveyQuestionType.OpenText ? ( + {question.type === TSurveyQuestionType.FileUpload ? ( + + ) : question.type === TSurveyQuestionType.OpenText ? ( ) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? ( @@ -236,6 +243,16 @@ export default function QuestionCard({ lastQuestion={lastQuestion} isInValid={isInValid} /> + ) : question.type === TSurveyQuestionType.FileUpload ? ( + ) : null}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx index 921578de4b..098614c40e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx @@ -202,6 +202,7 @@ export default function QuestionsView({ diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx index 62b54ca890..d968eebe6a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx @@ -207,6 +207,7 @@ export default function PreviewSurvey({ isBrandingEnabled={product.linkSurveyBranding} onActiveQuestionChange={setActiveQuestionId} isRedirectDisabled={true} + onFileUpload={async () => ""} /> ) : ( @@ -221,6 +222,7 @@ export default function PreviewSurvey({ activeQuestionId={activeQuestionId || undefined} isBrandingEnabled={product.linkSurveyBranding} onActiveQuestionChange={setActiveQuestionId} + onFileUpload={async () => ""} />
@@ -277,6 +279,7 @@ export default function PreviewSurvey({ isBrandingEnabled={product.linkSurveyBranding} onActiveQuestionChange={setActiveQuestionId} isRedirectDisabled={true} + onFileUpload={async () => ""} /> ) : ( @@ -290,6 +293,7 @@ export default function PreviewSurvey({ isBrandingEnabled={product.linkSurveyBranding} onActiveQuestionChange={setActiveQuestionId} isRedirectDisabled={true} + onFileUpload={async () => ""} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts index 58afb2a20d..589e65b9d9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts @@ -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, diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts index 1dbb88e718..0bc10e01fe 100644 --- a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts @@ -17,19 +17,33 @@ interface Context { }; } +export async function OPTIONS(): Promise { + 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 { 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"); diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/route.ts index 3f71ecfc82..0fe14c2f8f 100644 --- a/apps/web/app/api/v1/client/[environmentId]/storage/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/route.ts @@ -10,6 +10,10 @@ interface Context { }; } +export async function OPTIONS(): Promise { + 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 diff --git a/apps/web/app/api/v1/management/storage/local/route.ts b/apps/web/app/api/v1/management/storage/local/route.ts index 55d8bafdc4..85d10b80b7 100644 --- a/apps/web/app/api/v1/management/storage/local/route.ts +++ b/apps/web/app/api/v1/management/storage/local/route.ts @@ -16,13 +16,13 @@ export async function POST(req: NextRequest): Promise { 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"); diff --git a/apps/web/app/lib/questions.ts b/apps/web/app/lib/questions.ts index 0db4aede43..6a0af17c36 100644 --- a/apps/web/app/lib/questions.ts +++ b/apps/web/app/lib/questions.ts @@ -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 = { diff --git a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx index 5684fe2ea6..a06731e7b4 100644 --- a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx @@ -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} diff --git a/packages/api/src/api/client/index.ts b/packages/api/src/api/client/index.ts index 79fa336355..21ba336fc1 100644 --- a/packages/api/src/api/client/index.ts +++ b/packages/api/src/api/client/index.ts @@ -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); } } diff --git a/packages/api/src/api/client/storage.ts b/packages/api/src/api/client/storage.ts new file mode 100644 index 0000000000..9987acf2a1 --- /dev/null +++ b/packages/api/src/api/client/storage.ts @@ -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 { + 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 = {}; + + 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; + } +} diff --git a/packages/js/src/lib/widget.ts b/packages/js/src/lib/widget.ts index d051416111..6dfd989a9f 100644 --- a/packages/js/src/lib/widget.ts +++ b/packages/js/src/lib/widget.ts @@ -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); }; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index e8e1bc284d..1313d897c9 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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; diff --git a/packages/lib/product/service.ts b/packages/lib/product/service.ts index 753b1b7b7a..c47a040daf 100644 --- a/packages/lib/product/service.ts +++ b/packages/lib/product/service.ts @@ -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; }; diff --git a/packages/lib/storage/service.ts b/packages/lib/storage/service.ts index 8f9a6597b2..7a7f5653f3 100644 --- a/packages/lib/storage/service.ts +++ b/packages/lib/storage/service.ts @@ -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, diff --git a/packages/lib/team/hooks/actions.ts b/packages/lib/team/hooks/actions.ts new file mode 100644 index 0000000000..851b032e90 --- /dev/null +++ b/packages/lib/team/hooks/actions.ts @@ -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); +}; diff --git a/packages/lib/team/hooks/useGetBillingInfo.ts b/packages/lib/team/hooks/useGetBillingInfo.ts new file mode 100644 index 0000000000..80ed6082fa --- /dev/null +++ b/packages/lib/team/hooks/useGetBillingInfo.ts @@ -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(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + 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 }; +}; diff --git a/packages/lib/team/service.ts b/packages/lib/team/service.ts index 5591181950..8e17de2b5a 100644 --- a/packages/lib/team/service.ts +++ b/packages/lib/team/service.ts @@ -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 => + 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)], + } + )(); diff --git a/packages/surveys/package.json b/packages/surveys/package.json index c329962428..a7ca7fdf3e 100644 --- a/packages/surveys/package.json +++ b/packages/surveys/package.json @@ -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", diff --git a/packages/surveys/src/components/general/FileInput.tsx b/packages/surveys/src/components/general/FileInput.tsx new file mode 100644 index 0000000000..cb23af287b --- /dev/null +++ b/packages/surveys/src/components/general/FileInput.tsx @@ -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; + fileUrls: string[] | undefined; + maxSizeInMB?: number; + allowMultipleFiles?: boolean; +} + +export default function FileInput({ + allowedFileExtensions, + surveyId, + onUploadCallback, + onFileUpload, + fileUrls, + maxSizeInMB, + allowMultipleFiles, +}: MultipleFileInputProps) { + const [selectedFiles, setSelectedFiles] = useState([]); + 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) => { + e.preventDefault(); + e.stopPropagation(); + + // @ts-expect-error + e.dataTransfer.dropEffect = "copy"; + }; + + const handleDrop = async (e: JSXInternal.TargetedDragEvent) => { + 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) => { + 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 ( +
+
+ {fileUrls && + fileUrls?.map((file, index) => ( +
+
+
+ handleDeleteFile(index, e)}> + + +
+
+ +
+ + + + +

+ {decodeURIComponent(file).split("/").pop()} +

+
+
+ ))} +
+ +
+ {isUploading && ( +
+ +
+ )} + + +
+
+ ); +} diff --git a/packages/surveys/src/components/general/QuestionConditional.tsx b/packages/surveys/src/components/general/QuestionConditional.tsx index 965b944bc6..46392c84b7 100644 --- a/packages/surveys/src/components/general/QuestionConditional.tsx +++ b/packages/surveys/src/components/general/QuestionConditional.tsx @@ -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; 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 ? ( + ) : question.type === TSurveyQuestionType.FileUpload ? ( + ) : null; } diff --git a/packages/surveys/src/components/general/Survey.tsx b/packages/surveys/src/components/general/Survey.tsx index 0692db4a02..8cbef6b5c5 100644 --- a/packages/surveys/src/components/general/Survey.tsx +++ b/packages/surveys/src/components/general/Survey.tsx @@ -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 && ( {}, prefillResponseData, isRedirectDisabled = false, + onFileUpload, }: SurveyBaseProps) { return (
@@ -24,6 +25,7 @@ export function SurveyInline({ onClose={onClose} prefillResponseData={prefillResponseData} isRedirectDisabled={isRedirectDisabled} + onFileUpload={onFileUpload} />
); diff --git a/packages/surveys/src/components/general/SurveyModal.tsx b/packages/surveys/src/components/general/SurveyModal.tsx index 40d2162bca..93eddefa34 100644 --- a/packages/surveys/src/components/general/SurveyModal.tsx +++ b/packages/surveys/src/components/general/SurveyModal.tsx @@ -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} /> diff --git a/packages/surveys/src/components/questions/FileUploadQuestion.tsx b/packages/surveys/src/components/questions/FileUploadQuestion.tsx new file mode 100644 index 0000000000..ba558a7b5a --- /dev/null +++ b/packages/surveys/src/components/questions/FileUploadQuestion.tsx @@ -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; + isFirstQuestion: boolean; + isLastQuestion: boolean; + surveyId: string; +} + +export default function FileUploadQuestion({ + question, + value, + onChange, + onSubmit, + onBack, + isFirstQuestion, + isLastQuestion, + surveyId, + onFileUpload, +}: FileUploadQuestionProps) { + return ( +
{ + 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"> + + + + { + 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 } : {})} + /> + +
+ {!isFirstQuestion && ( + { + onBack(); + }} + /> + )} +
+ {}} /> +
+ + ); +} diff --git a/packages/surveys/src/lib/logicEvaluator.ts b/packages/surveys/src/lib/logicEvaluator.ts index 85c4c90af8..3e5bc6bede 100644 --- a/packages/surveys/src/lib/logicEvaluator.ts +++ b/packages/surveys/src/lib/logicEvaluator.ts @@ -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; } diff --git a/packages/surveys/src/lib/uploadFile.ts b/packages/surveys/src/lib/uploadFile.ts new file mode 100644 index 0000000000..10a1091ee7 --- /dev/null +++ b/packages/surveys/src/lib/uploadFile.ts @@ -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 = {}; + + 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, + }; +}; diff --git a/packages/surveys/src/types/props.ts b/packages/surveys/src/types/props.ts index f905c63c0b..e58c9439bf 100644 --- a/packages/surveys/src/types/props.ts +++ b/packages/surveys/src/types/props.ts @@ -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; } export interface SurveyInlineProps extends SurveyBaseProps { diff --git a/packages/types/common.ts b/packages/types/common.ts index b88ad68777..827a85b191 100644 --- a/packages/types/common.ts +++ b/packages/types/common.ts @@ -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; -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; +export type TAllowedFileExtension = z.infer; diff --git a/packages/types/storage.ts b/packages/types/storage.ts index 466bc529e1..99279690b6 100644 --- a/packages/types/storage.ts +++ b/packages/types/storage.ts @@ -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; diff --git a/packages/types/surveys.ts b/packages/types/surveys.ts index 144625deb5..796c196438 100644 --- a/packages/types/surveys.ts +++ b/packages/types/surveys.ts @@ -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; @@ -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; @@ -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; + export const ZSurveyOpenTextQuestionInputType = z.enum(["text", "email", "url", "number", "phone"]); export type TSurveyOpenTextQuestionInputType = z.infer; @@ -300,7 +319,6 @@ export const ZSurveyPictureSelectionQuestion = ZSurveyQuestionBase.extend({ export type TSurveyPictureSelectionQuestion = z.infer; 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; @@ -395,6 +414,7 @@ export type TSurveyDates = { export type TSurveyInput = z.infer; export const ZSurveyTSurveyQuestionType = z.union([ + z.literal("fileUpload"), z.literal("openText"), z.literal("multipleChoiceSingle"), z.literal("multipleChoiceMulti"), diff --git a/packages/ui/AdvancedOptionToggle/index.tsx b/packages/ui/AdvancedOptionToggle/index.tsx index 2e2fcf09b3..7d614a2668 100644 --- a/packages/ui/AdvancedOptionToggle/index.tsx +++ b/packages/ui/AdvancedOptionToggle/index.tsx @@ -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 ( -
+
-
- {isChecked && ( + {children && isChecked && (
{ - 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 = ({ (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 = ({ (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) => void; uploaderClassName: string; handleDrop: (e: React.DragEvent) => void; - allowedFileExtensions: TAllowedFileExtensions[]; + allowedFileExtensions: TAllowedFileExtension[]; multiple: boolean; handleUpload: (files: File[]) => void; uploadMore?: boolean; diff --git a/packages/ui/FileInput/lib/fileUpload.ts b/packages/ui/FileInput/lib/fileUpload.ts index 4d4fcc755c..f88965526a 100644 --- a/packages/ui/FileInput/lib/fileUpload.ts +++ b/packages/ui/FileInput/lib/fileUpload.ts @@ -2,7 +2,7 @@ const uploadFile = async ( file: File | Blob, - allowedFileExtensions: string[], + allowedFileExtensions: string[] | undefined, environmentId: string | undefined ) => { try { diff --git a/packages/ui/FileUploadResponse/index.tsx b/packages/ui/FileUploadResponse/index.tsx new file mode 100644 index 0000000000..1f0537de06 --- /dev/null +++ b/packages/ui/FileUploadResponse/index.tsx @@ -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" ? ( +
skipped
+ ) : ( +
+ {Array.isArray(selected) ? ( + selected.map((fileUrl, index) => ( +
+ +
+
+ + + +
+
+
+ +
+ +

+ {decodeURIComponent(fileUrl).split("/").pop()} +

+
+
+ )) + ) : ( +
+ +
+
+ + + +
+
+
+ +
+ +

+ {selected && typeof selected === "string" && decodeURIComponent(selected).split("/").pop()} +

+
+
+ )} +
+ )} + + ); +}; diff --git a/packages/ui/SingleResponseCard/index.tsx b/packages/ui/SingleResponseCard/index.tsx index 60c9780d20..9561f35ebd 100644 --- a/packages/ui/SingleResponseCard/index.tsx +++ b/packages/ui/SingleResponseCard/index.tsx @@ -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 ? ( + ) : (

{handleArray(response.data[question.id])} diff --git a/packages/ui/Survey/index.tsx b/packages/ui/Survey/index.tsx index 0b80c6eb0a..69c29fadeb 100644 --- a/packages/ui/Survey/index.tsx +++ b/packages/ui/Survey/index.tsx @@ -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; 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

; }; @@ -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
; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a2018c85b..002463bd94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -687,6 +687,9 @@ importers: packages/surveys: devDependencies: + '@formbricks/lib': + specifier: workspace:* + version: link:../lib '@formbricks/tsconfig': specifier: workspace:* version: link:../tsconfig