mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-18 10:09:49 -06:00
fix: surveys pkg android file uploads (#4869)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
@@ -8,6 +8,7 @@ declare global {
|
||||
renderSurveyInline: (props: SurveyContainerProps) => void;
|
||||
renderSurveyModal: (props: SurveyContainerProps) => void;
|
||||
renderSurvey: (props: SurveyContainerProps) => void;
|
||||
onFilePick: (files: { name: string; type: string; base64: string }[]) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,19 @@ export const ZJsRNWebViewOnMessageData = z.object({
|
||||
onDisplayCreated: z.boolean().nullish(),
|
||||
onResponseCreated: z.boolean().nullish(),
|
||||
onClose: z.boolean().nullish(),
|
||||
onFilePick: z.boolean().nullish(),
|
||||
fileUploadParams: z
|
||||
.object({
|
||||
allowedFileExtensions: z.string().nullish(),
|
||||
allowMultipleFiles: z.boolean().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
onOpenExternalURL: z.boolean().nullish(),
|
||||
onOpenExternalURLParams: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export interface TUpdates {
|
||||
|
||||
@@ -21,6 +21,7 @@ interface EndingCardProps {
|
||||
languageCode: string;
|
||||
responseData: TResponseData;
|
||||
variablesData: TResponseVariables;
|
||||
onOpenExternalURL?: (url: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function EndingCard({
|
||||
@@ -33,11 +34,13 @@ export function EndingCard({
|
||||
languageCode,
|
||||
responseData,
|
||||
variablesData,
|
||||
onOpenExternalURL,
|
||||
}: EndingCardProps) {
|
||||
const media =
|
||||
endingCard.type === "endScreen" && (endingCard.imageUrl ?? endingCard.videoUrl) ? (
|
||||
<QuestionMedia imgUrl={endingCard.imageUrl} videoUrl={endingCard.videoUrl} />
|
||||
) : null;
|
||||
|
||||
const checkmark = (
|
||||
<div className="fb-text-brand fb-flex fb-flex-col fb-items-center fb-justify-center">
|
||||
<svg
|
||||
@@ -61,7 +64,11 @@ export function EndingCard({
|
||||
try {
|
||||
const url = replaceRecallInfo(urlString, responseData, variablesData);
|
||||
if (url && new URL(url)) {
|
||||
window.top?.location.replace(url);
|
||||
if (onOpenExternalURL) {
|
||||
onOpenExternalURL(url);
|
||||
} else {
|
||||
window.top?.location.replace(url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Invalid URL after recall processing:", error);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FILE_PICK_EVENT } from "@/lib/constants";
|
||||
import { getOriginalFileNameFromUrl } from "@/lib/storage";
|
||||
import { getMimeType } from "@/lib/utils";
|
||||
import { isFulfilled, isRejected } from "@/lib/utils";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { type JSXInternal } from "preact/src/jsx";
|
||||
import { type TAllowedFileExtension } from "@formbricks/types/common";
|
||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||
@@ -34,6 +36,70 @@ export function FileInput({
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Listen for the native file-upload event dispatched via window.formbricksSurveys.onFilePick
|
||||
useEffect(() => {
|
||||
const handleNativeFileUpload = async (
|
||||
event: CustomEvent<{ name: string; type: string; base64: string }[]>
|
||||
) => {
|
||||
const filesFromNative = event.detail;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
// Filter out files that exceed the maximum size
|
||||
const filteredFiles: typeof filesFromNative = [];
|
||||
const rejectedFiles: string[] = [];
|
||||
|
||||
if (maxSizeInMB) {
|
||||
for (const file of filesFromNative) {
|
||||
// Calculate file size from base64 string
|
||||
// Base64 size in bytes is roughly 3/4 of the string length
|
||||
const base64SizeInKB = (file.base64.length * 0.75) / 1024;
|
||||
|
||||
if (base64SizeInKB > maxSizeInMB * 1024) {
|
||||
rejectedFiles.push(file.name);
|
||||
} else {
|
||||
filteredFiles.push(file);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no size limit is specified, use all files
|
||||
filteredFiles.push(...filesFromNative);
|
||||
}
|
||||
|
||||
// Display alert for rejected files
|
||||
if (rejectedFiles.length > 0) {
|
||||
const fileNames = rejectedFiles.join(", ");
|
||||
alert(
|
||||
`The following file(s) exceed the maximum size of ${maxSizeInMB} MB and were removed: ${fileNames}`
|
||||
);
|
||||
}
|
||||
|
||||
// If no files remain after filtering, exit early
|
||||
if (filteredFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedUrls = await Promise.all(
|
||||
filteredFiles.map((file) => onFileUpload(file, { allowedFileExtensions, surveyId }))
|
||||
);
|
||||
|
||||
// Update file URLs by appending the new URL
|
||||
onUploadCallback(fileUrls ? [...fileUrls, ...uploadedUrls] : uploadedUrls);
|
||||
} catch (err) {
|
||||
console.error(`Error uploading native file.`);
|
||||
alert(`Upload failed! Please try again.`);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(FILE_PICK_EVENT, handleNativeFileUpload as unknown as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(FILE_PICK_EVENT, handleNativeFileUpload as unknown as EventListener);
|
||||
};
|
||||
}, [allowedFileExtensions, fileUrls, maxSizeInMB, onFileUpload, onUploadCallback, surveyId]);
|
||||
|
||||
const validateFileSize = async (file: File): Promise<boolean> => {
|
||||
if (maxSizeInMB) {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
@@ -68,6 +134,11 @@ export function FileInput({
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!validFiles.length) {
|
||||
alert("No valid file types selected. Please select a valid file type.");
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredFiles: File[] = [];
|
||||
|
||||
for (const validFile of validFiles) {
|
||||
@@ -157,6 +228,10 @@ export function FileInput({
|
||||
|
||||
const uniqueHtmlFor = useMemo(() => `selectedFile-${htmlFor}`, [htmlFor]);
|
||||
|
||||
const mimeTypeForAllowedFileExtensions = useMemo(() => {
|
||||
return allowedFileExtensions?.map((ext) => getMimeType(ext)).join(",");
|
||||
}, [allowedFileExtensions]);
|
||||
|
||||
return (
|
||||
<div className="fb-items-left fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-relative fb-mt-3 fb-flex fb-w-full fb-flex-col fb-justify-center fb-rounded-lg fb-border-2 fb-border-dashed dark:fb-border-slate-600 dark:fb-bg-slate-700 dark:hover:fb-border-slate-500 dark:hover:fb-bg-slate-800">
|
||||
<div ref={parent}>
|
||||
@@ -257,7 +332,7 @@ export function FileInput({
|
||||
type="file"
|
||||
id={uniqueHtmlFor}
|
||||
name={uniqueHtmlFor}
|
||||
accept={allowedFileExtensions?.map((ext) => `.${ext}`).join(",")}
|
||||
accept={mimeTypeForAllowedFileExtensions}
|
||||
className="fb-hidden"
|
||||
onChange={async (e) => {
|
||||
const inputElement = e.target as HTMLInputElement;
|
||||
@@ -268,6 +343,8 @@ export function FileInput({
|
||||
multiple={allowMultipleFiles}
|
||||
aria-label="File upload"
|
||||
aria-describedby={`${uniqueHtmlFor}-label`}
|
||||
data-accept-multiple={allowMultipleFiles}
|
||||
data-accept-extensions={mimeTypeForAllowedFileExtensions}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -42,6 +42,7 @@ interface QuestionConditionalProps {
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: TSurveyQuestionId;
|
||||
isBackButtonHidden: boolean;
|
||||
onOpenExternalURL?: (url: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function QuestionConditional({
|
||||
@@ -62,6 +63,7 @@ export function QuestionConditional({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
onOpenExternalURL,
|
||||
}: QuestionConditionalProps) {
|
||||
const getResponseValueForRankingQuestion = (
|
||||
value: string[],
|
||||
@@ -164,6 +166,7 @@ export function QuestionConditional({
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={currentQuestionId}
|
||||
isBackButtonHidden={isBackButtonHidden}
|
||||
onOpenExternalURL={onOpenExternalURL}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Rating ? (
|
||||
<RatingQuestion
|
||||
|
||||
@@ -67,6 +67,7 @@ export function Survey({
|
||||
singleUseId,
|
||||
singleUseResponseId,
|
||||
mode,
|
||||
onOpenExternalURL,
|
||||
}: SurveyContainerProps) {
|
||||
let apiClient: ApiClient | null = null;
|
||||
|
||||
@@ -546,6 +547,7 @@ export function Survey({
|
||||
isResponseSendingFinished={isResponseSendingFinished}
|
||||
responseData={responseData}
|
||||
variablesData={currentVariables}
|
||||
onOpenExternalURL={onOpenExternalURL}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -572,6 +574,7 @@ export function Survey({
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentQuestionId={questionId}
|
||||
isBackButtonHidden={localSurvey.isBackButtonHidden}
|
||||
onOpenExternalURL={onOpenExternalURL}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ interface CTAQuestionProps {
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: TSurveyQuestionId;
|
||||
isBackButtonHidden: boolean;
|
||||
onOpenExternalURL?: (url: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function CTAQuestion({
|
||||
@@ -39,6 +40,7 @@ export function CTAQuestion({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
onOpenExternalURL,
|
||||
}: CTAQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
@@ -69,7 +71,11 @@ export function CTAQuestion({
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
onClick={() => {
|
||||
if (question.buttonExternal && question.buttonUrl) {
|
||||
window.open(question.buttonUrl, "_blank")?.focus();
|
||||
if (onOpenExternalURL) {
|
||||
onOpenExternalURL(question.buttonUrl);
|
||||
} else {
|
||||
window.open(question.buttonUrl, "_blank")?.focus();
|
||||
}
|
||||
}
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RenderSurvey } from "@/components/general/render-survey";
|
||||
import { FILE_PICK_EVENT } from "@/lib/constants";
|
||||
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
|
||||
import { h, render } from "preact";
|
||||
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
|
||||
@@ -45,10 +46,17 @@ export const renderSurvey = (props: SurveyContainerProps) => {
|
||||
|
||||
export const renderSurveyModal = renderSurvey;
|
||||
|
||||
export const onFilePick = (files: { name: string; type: string; base64: string }[]) => {
|
||||
const fileUploadEvent = new CustomEvent(FILE_PICK_EVENT, { detail: files });
|
||||
window.dispatchEvent(fileUploadEvent);
|
||||
};
|
||||
|
||||
// Initialize the global formbricksSurveys object if it doesn't exist
|
||||
if (typeof window !== "undefined") {
|
||||
window.formbricksSurveys = {
|
||||
renderSurveyInline,
|
||||
renderSurveyModal,
|
||||
renderSurvey,
|
||||
onFilePick,
|
||||
};
|
||||
}
|
||||
|
||||
1
packages/surveys/src/lib/constants.ts
Normal file
1
packages/surveys/src/lib/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const FILE_PICK_EVENT = "formbricks:onFilePick";
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/common";
|
||||
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
|
||||
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
@@ -158,3 +159,32 @@ export const getDefaultLanguageCode = (survey: TJsEnvironmentStateSurvey): strin
|
||||
});
|
||||
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
|
||||
};
|
||||
|
||||
const mimeTypes: { [key in TAllowedFileExtension]: string } = {
|
||||
heic: "image/heic",
|
||||
png: "image/png",
|
||||
jpeg: "image/jpeg",
|
||||
jpg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
pdf: "application/pdf",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
ppt: "application/vnd.ms-powerpoint",
|
||||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
plain: "text/plain",
|
||||
csv: "text/csv",
|
||||
mp4: "video/mp4",
|
||||
mov: "video/quicktime",
|
||||
avi: "video/x-msvideo",
|
||||
mkv: "video/x-matroska",
|
||||
webm: "video/webm",
|
||||
zip: "application/zip",
|
||||
rar: "application/vnd.rar",
|
||||
"7z": "application/x-7z-compressed",
|
||||
tar: "application/x-tar",
|
||||
};
|
||||
|
||||
// Function to convert file extension to its MIME type
|
||||
export const getMimeType = (extension: TAllowedFileExtension): string => mimeTypes[extension];
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface SurveyContainerProps extends Omit<SurveyBaseProps, "onFileUploa
|
||||
onDisplayCreated?: () => void | Promise<void>;
|
||||
onResponseCreated?: () => void | Promise<void>;
|
||||
onFileUpload?: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
|
||||
onOpenExternalURL?: (url: string) => void | Promise<void>;
|
||||
mode?: "modal" | "inline";
|
||||
containerId?: string;
|
||||
clickOutside?: boolean;
|
||||
|
||||
1
packages/types/surveys.d.ts
vendored
1
packages/types/surveys.d.ts
vendored
@@ -6,6 +6,7 @@ declare global {
|
||||
renderSurveyInline: (props: SurveyContainerProps) => void;
|
||||
renderSurveyModal: (props: SurveyContainerProps) => void;
|
||||
renderSurvey: (props: SurveyContainerProps) => void;
|
||||
onFilePick: (files: { name: string; type: string; base64: string }[]) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user