fix: surveys pkg android file uploads (#4869)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Anshuman Pandey
2025-03-07 10:08:49 +05:30
committed by GitHub
parent 140aee749b
commit b5a51f1304
12 changed files with 155 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export const FILE_PICK_EVENT = "formbricks:onFilePick";

View File

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

View File

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

View File

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