mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-12 09:39:39 -06:00
fix: moves storage api management endpoint to use payload instead of … (#5348)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
@@ -33,7 +33,7 @@ const Loading = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,7 +264,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -9,8 +9,8 @@ import { validateFile } from "@/lib/fileValidation";
|
||||
import { putFileToLocalStorage } from "@/lib/storage/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
@@ -18,28 +18,27 @@ export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
}
|
||||
|
||||
const accessType = "public"; // public files are accessible by anyone
|
||||
const headersList = await headers();
|
||||
|
||||
const fileType = headersList.get("X-File-Type");
|
||||
const encodedFileName = headersList.get("X-File-Name");
|
||||
const environmentId = headersList.get("X-Environment-ID");
|
||||
const jsonInput = await req.json();
|
||||
const fileType = jsonInput.fileType as string;
|
||||
const encodedFileName = jsonInput.fileName as string;
|
||||
const signedSignature = jsonInput.signature as string;
|
||||
const signedUuid = jsonInput.uuid as string;
|
||||
const signedTimestamp = jsonInput.timestamp as string;
|
||||
const environmentId = jsonInput.environmentId as string;
|
||||
|
||||
const signedSignature = headersList.get("X-Signature");
|
||||
const signedUuid = headersList.get("X-UUID");
|
||||
const signedTimestamp = headersList.get("X-Timestamp");
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
|
||||
if (!fileType) {
|
||||
return responses.badRequestResponse("fileType is required");
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
if (!encodedFileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
|
||||
if (!signedSignature) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
@@ -88,8 +87,9 @@ export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as unknown as File;
|
||||
const base64String = jsonInput.fileBase64String as string;
|
||||
const buffer = Buffer.from(base64String.split(",")[1], "base64");
|
||||
const file = new Blob([buffer], { type: fileType });
|
||||
|
||||
if (!file) {
|
||||
return responses.badRequestResponse("fileBuffer is required");
|
||||
@@ -105,6 +105,7 @@ export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
message: "File uploaded successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, "Error uploading file");
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return responses.badRequestResponse(err.message);
|
||||
}
|
||||
|
||||
266
apps/web/app/lib/fileUpload.test.ts
Normal file
266
apps/web/app/lib/fileUpload.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import * as fileUploadModule from "./fileUpload";
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const mockAtoB = vi.fn();
|
||||
global.atob = mockAtoB;
|
||||
|
||||
// Mock FileReader
|
||||
const mockFileReader = {
|
||||
readAsDataURL: vi.fn(),
|
||||
result: "data:image/jpeg;base64,test",
|
||||
onload: null as any,
|
||||
onerror: null as any,
|
||||
};
|
||||
|
||||
// Mock File object
|
||||
const createMockFile = (name: string, type: string, size: number) => {
|
||||
const file = new File([], name, { type });
|
||||
Object.defineProperty(file, "size", {
|
||||
value: size,
|
||||
writable: false,
|
||||
});
|
||||
return file;
|
||||
};
|
||||
|
||||
describe("fileUpload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock FileReader
|
||||
global.FileReader = vi.fn(() => mockFileReader) as any;
|
||||
global.atob = (base64) => Buffer.from(base64, "base64").toString("binary");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return error when no file is provided", async () => {
|
||||
const result = await fileUploadModule.handleFileUpload(null as any, "test-env");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.NO_FILE);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return error when file is not an image", async () => {
|
||||
const file = createMockFile("test.pdf", "application/pdf", 1000);
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Please upload an image file.");
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return FILE_SIZE_EXCEEDED if arrayBuffer is > 10MB even if file.size is OK", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000); // file.size = 1KB
|
||||
|
||||
// Mock arrayBuffer to return >10MB buffer
|
||||
file.arrayBuffer = vi.fn().mockResolvedValueOnce(new ArrayBuffer(11 * 1024 * 1024)); // 11MB
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "env-oversize-buffer");
|
||||
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.FILE_SIZE_EXCEEDED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should handle API error when getting signed URL", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
// Mock failed API response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should handle successful file upload with presigned fields", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
// Mock successful API response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
fileUrl: "https://s3.example.com/file.jpg",
|
||||
presignedFields: {
|
||||
key: "value",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock successful upload response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
// Simulate FileReader onload
|
||||
setTimeout(() => {
|
||||
mockFileReader.onload();
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.url).toBe("https://s3.example.com/file.jpg");
|
||||
});
|
||||
|
||||
test("should handle successful file upload without presigned fields", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
// Mock successful API response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
fileUrl: "https://s3.example.com/file.jpg",
|
||||
signingData: {
|
||||
signature: "test-signature",
|
||||
timestamp: 1234567890,
|
||||
uuid: "test-uuid",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock successful upload response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
// Simulate FileReader onload
|
||||
setTimeout(() => {
|
||||
mockFileReader.onload();
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.url).toBe("https://s3.example.com/file.jpg");
|
||||
});
|
||||
|
||||
test("should handle upload error with presigned fields", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
// Mock successful API response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
fileUrl: "https://s3.example.com/file.jpg",
|
||||
presignedFields: {
|
||||
key: "value",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
global.atob = vi.fn(() => {
|
||||
throw new Error("Failed to decode base64 string");
|
||||
});
|
||||
|
||||
// Simulate FileReader onload
|
||||
setTimeout(() => {
|
||||
mockFileReader.onload();
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should handle upload error", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
// Mock successful API response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
fileUrl: "https://s3.example.com/file.jpg",
|
||||
presignedFields: {
|
||||
key: "value",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock failed upload response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
});
|
||||
|
||||
// Simulate FileReader onload
|
||||
setTimeout(() => {
|
||||
mockFileReader.onload();
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should catch unexpected errors and return UPLOAD_FAILED", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
// Force arrayBuffer() to throw
|
||||
file.arrayBuffer = vi.fn().mockImplementation(() => {
|
||||
throw new Error("Unexpected crash in arrayBuffer");
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "env-crash");
|
||||
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileUploadModule.toBase64", () => {
|
||||
test("resolves with base64 string when FileReader succeeds", async () => {
|
||||
const dummyFile = new File(["hello"], "hello.txt", { type: "text/plain" });
|
||||
|
||||
// Mock FileReader
|
||||
const mockReadAsDataURL = vi.fn();
|
||||
const mockFileReaderInstance = {
|
||||
readAsDataURL: mockReadAsDataURL,
|
||||
onload: null as ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null,
|
||||
onerror: null,
|
||||
result: "data:text/plain;base64,aGVsbG8=",
|
||||
};
|
||||
|
||||
globalThis.FileReader = vi.fn(() => mockFileReaderInstance as unknown as FileReader) as any;
|
||||
|
||||
const promise = fileUploadModule.toBase64(dummyFile);
|
||||
|
||||
// Trigger the onload manually
|
||||
mockFileReaderInstance.onload?.call(mockFileReaderInstance as unknown as FileReader, new Error("load"));
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe("data:text/plain;base64,aGVsbG8=");
|
||||
});
|
||||
|
||||
test("rejects when FileReader errors", async () => {
|
||||
const dummyFile = new File(["oops"], "oops.txt", { type: "text/plain" });
|
||||
|
||||
const mockReadAsDataURL = vi.fn();
|
||||
const mockFileReaderInstance = {
|
||||
readAsDataURL: mockReadAsDataURL,
|
||||
onload: null,
|
||||
onerror: null as ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null,
|
||||
result: null,
|
||||
};
|
||||
|
||||
globalThis.FileReader = vi.fn(() => mockFileReaderInstance as unknown as FileReader) as any;
|
||||
|
||||
const promise = fileUploadModule.toBase64(dummyFile);
|
||||
|
||||
// Simulate error
|
||||
mockFileReaderInstance.onerror?.call(mockFileReaderInstance as unknown as FileReader, new Error("error"));
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,90 +1,146 @@
|
||||
export enum FileUploadError {
|
||||
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
|
||||
INVALID_FILE_TYPE = "Please upload an image file.",
|
||||
FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.",
|
||||
UPLOAD_FAILED = "Upload failed. Please try again.",
|
||||
}
|
||||
|
||||
export const toBase64 = (file: File) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
|
||||
export const handleFileUpload = async (
|
||||
file: File,
|
||||
environmentId: string
|
||||
environmentId: string,
|
||||
allowedFileExtensions?: string[]
|
||||
): Promise<{
|
||||
error?: string;
|
||||
error?: FileUploadError;
|
||||
url: string;
|
||||
}> => {
|
||||
if (!file) return { error: "No file provided", url: "" };
|
||||
try {
|
||||
if (!(file instanceof File)) {
|
||||
return {
|
||||
error: FileUploadError.NO_FILE,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return { error: "Please upload an image file.", url: "" };
|
||||
}
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return { error: FileUploadError.INVALID_FILE_TYPE, url: "" };
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return {
|
||||
error: "File size must be less than 10 MB.",
|
||||
url: "",
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
const bufferBytes = fileBuffer.byteLength;
|
||||
const bufferKB = bufferBytes / 1024;
|
||||
|
||||
if (bufferKB > 10240) {
|
||||
return {
|
||||
error: FileUploadError.FILE_SIZE_EXCEEDED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
allowedFileExtensions,
|
||||
environmentId,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
environmentId,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/v1/management/storage", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// throw new Error(`Upload failed with status: ${response.status}`);
|
||||
return {
|
||||
error: "Upload failed. Please try again.",
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const { data } = json;
|
||||
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
|
||||
|
||||
let requestHeaders: Record<string, string> = {};
|
||||
|
||||
if (signingData) {
|
||||
const { signature, timestamp, uuid } = signingData;
|
||||
|
||||
requestHeaders = {
|
||||
"X-File-Type": file.type,
|
||||
"X-File-Name": encodeURIComponent(updatedFileName),
|
||||
"X-Environment-ID": environmentId ?? "",
|
||||
"X-Signature": signature,
|
||||
"X-Timestamp": String(timestamp),
|
||||
"X-UUID": uuid,
|
||||
};
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
if (presignedFields) {
|
||||
Object.keys(presignedFields).forEach((key) => {
|
||||
formData.append(key, presignedFields[key]);
|
||||
const response = await fetch("/api/v1/management/storage", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
// Add the actual file to be uploaded
|
||||
formData.append("file", file);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
const uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
...(signingData ? { headers: requestHeaders } : {}),
|
||||
body: formData,
|
||||
});
|
||||
const json = await response.json();
|
||||
const { data } = json;
|
||||
|
||||
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
|
||||
|
||||
let localUploadDetails: Record<string, string> = {};
|
||||
|
||||
if (signingData) {
|
||||
const { signature, timestamp, uuid } = signingData;
|
||||
|
||||
localUploadDetails = {
|
||||
fileType: file.type,
|
||||
fileName: encodeURIComponent(updatedFileName),
|
||||
environmentId,
|
||||
signature,
|
||||
timestamp: String(timestamp),
|
||||
uuid,
|
||||
};
|
||||
}
|
||||
|
||||
const fileBase64 = (await toBase64(file)) as string;
|
||||
|
||||
const formData: Record<string, string> = {};
|
||||
const formDataForS3 = new FormData();
|
||||
|
||||
if (presignedFields) {
|
||||
Object.entries(presignedFields as Record<string, string>).forEach(([key, value]) => {
|
||||
formDataForS3.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const binaryString = atob(fileBase64.split(",")[1]);
|
||||
const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0)));
|
||||
const blob = new Blob([uint8Array], { type: file.type });
|
||||
|
||||
formDataForS3.append("file", blob);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return {
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
formData.fileBase64String = fileBase64;
|
||||
|
||||
const uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
body: presignedFields
|
||||
? formDataForS3
|
||||
: JSON.stringify({
|
||||
...formData,
|
||||
...localUploadDetails,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
return {
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
return {
|
||||
error: "Upload failed. Please try again.",
|
||||
url: fileUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error in uploading file: ", error);
|
||||
return {
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: fileUrl,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -146,7 +146,7 @@ export const PricingTable = ({
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex w-full">
|
||||
<h2 className="mr-2 mb-3 inline-flex w-full text-2xl font-bold text-slate-700">
|
||||
<h2 className="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
|
||||
{t("environments.settings.billing.current_plan")}:{" "}
|
||||
{capitalizeFirstLetter(organization.billing.plan)}
|
||||
{cancellingOn && (
|
||||
@@ -201,7 +201,7 @@ export const PricingTable = ({
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-8 mb-8 flex flex-col gap-4",
|
||||
peopleUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
|
||||
peopleUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
|
||||
)}>
|
||||
<p className="text-md font-semibold text-slate-700">
|
||||
{t("environments.settings.billing.monthly_identified_users")}
|
||||
@@ -224,7 +224,7 @@ export const PricingTable = ({
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-8 flex flex-col gap-4 pb-12",
|
||||
projectsUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
|
||||
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
|
||||
)}>
|
||||
<p className="text-md font-semibold text-slate-700">{t("common.projects")}</p>
|
||||
{organization.billing.limits.projects && (
|
||||
@@ -260,7 +260,7 @@ export const PricingTable = ({
|
||||
{t("environments.settings.billing.monthly")}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 items-center rounded-md py-0.5 pr-2 pl-4 text-center whitespace-nowrap ${
|
||||
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
|
||||
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("yearly")}>
|
||||
@@ -272,7 +272,7 @@ export const PricingTable = ({
|
||||
</div>
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
|
||||
<div
|
||||
className="hidden lg:absolute lg:inset-x-px lg:top-4 lg:bottom-0 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
|
||||
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{getCloudPricingData(t).plans.map((plan) => (
|
||||
|
||||
@@ -297,7 +297,7 @@ export const UploadContactsCSVButton = ({
|
||||
<div className="sticky top-0 flex h-full flex-col rounded-lg">
|
||||
<button
|
||||
className={cn(
|
||||
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
|
||||
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
|
||||
)}
|
||||
onClick={() => {
|
||||
resetState(true);
|
||||
@@ -343,7 +343,7 @@ export const UploadContactsCSVButton = ({
|
||||
)}
|
||||
onDragOver={(e) => handleDragOver(e)}
|
||||
onDrop={(e) => handleDrop(e)}>
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
|
||||
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
|
||||
<span className="font-semibold">{t("common.upload_input_description")}</span>
|
||||
|
||||
@@ -207,7 +207,7 @@ export const TeamSettingsModal = ({
|
||||
<div className="sticky top-0 flex h-full flex-col rounded-lg">
|
||||
<button
|
||||
className={cn(
|
||||
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
|
||||
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
|
||||
)}
|
||||
onClick={closeSettingsModal}>
|
||||
<XIcon className="h-6 w-6 rounded-md bg-white" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||
import {
|
||||
removeOrganizationEmailLogoUrlAction,
|
||||
sendTestEmailAction,
|
||||
updateOrganizationEmailLogoUrlAction,
|
||||
} from "@/modules/ee/whitelabel/email-customization/actions";
|
||||
import { uploadFile } from "@/modules/ui/components/file-input/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -18,8 +18,8 @@ vi.mock("@/modules/ee/whitelabel/email-customization/actions", () => ({
|
||||
updateOrganizationEmailLogoUrlAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/file-input/lib/utils", () => ({
|
||||
uploadFile: vi.fn(),
|
||||
vi.mock("@/app/lib/fileUpload", () => ({
|
||||
handleFileUpload: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
@@ -82,8 +82,7 @@ describe("EmailCustomizationSettings", () => {
|
||||
});
|
||||
|
||||
test("calls updateOrganizationEmailLogoUrlAction after uploading and clicking save", async () => {
|
||||
vi.mocked(uploadFile).mockResolvedValueOnce({
|
||||
uploaded: true,
|
||||
vi.mocked(handleFileUpload).mockResolvedValueOnce({
|
||||
url: "https://example.com/new-uploaded-logo.png",
|
||||
});
|
||||
vi.mocked(updateOrganizationEmailLogoUrlAction).mockResolvedValue({
|
||||
@@ -104,7 +103,7 @@ describe("EmailCustomizationSettings", () => {
|
||||
await user.click(saveButton[0]);
|
||||
|
||||
// The component calls `uploadFile` then `updateOrganizationEmailLogoUrlAction`
|
||||
expect(uploadFile).toHaveBeenCalledWith(testFile, ["jpeg", "png", "jpg", "webp"], "env-123");
|
||||
expect(handleFileUpload).toHaveBeenCalledWith(testFile, "env-123", ["jpeg", "png", "jpg", "webp"]);
|
||||
expect(updateOrganizationEmailLogoUrlAction).toHaveBeenCalledWith({
|
||||
organizationId: "org-123",
|
||||
logoUrl: "https://example.com/new-uploaded-logo.png",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
|
||||
import { uploadFile } from "@/modules/ui/components/file-input/lib/utils";
|
||||
import { Muted, P, Small } from "@/modules/ui/components/typography";
|
||||
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -120,7 +120,13 @@ export const EmailCustomizationSettings = ({
|
||||
const handleSave = async () => {
|
||||
if (!logoFile) return;
|
||||
setIsSaving(true);
|
||||
const { url } = await uploadFile(logoFile, allowedFileExtensions, environmentId);
|
||||
const { url, error } = await handleFileUpload(logoFile, environmentId, allowedFileExtensions);
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateLogoResponse = await updateOrganizationEmailLogoUrlAction({
|
||||
organizationId: organization.id,
|
||||
@@ -205,7 +211,7 @@ export const EmailCustomizationSettings = ({
|
||||
data-testid="replace-logo-button"
|
||||
variant="secondary"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isReadOnly}>
|
||||
disabled={isReadOnly || isSaving}>
|
||||
<RepeatIcon className="h-4 w-4" />
|
||||
{t("environments.settings.general.replace_logo")}
|
||||
</Button>
|
||||
@@ -213,7 +219,7 @@ export const EmailCustomizationSettings = ({
|
||||
data-testid="remove-logo-button"
|
||||
onClick={removeLogo}
|
||||
variant="outline"
|
||||
disabled={isReadOnly}>
|
||||
disabled={isReadOnly || isSaving}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
{t("environments.settings.general.remove_logo")}
|
||||
</Button>
|
||||
@@ -241,7 +247,7 @@ export const EmailCustomizationSettings = ({
|
||||
<Button
|
||||
data-testid="send-test-email-button"
|
||||
variant="secondary"
|
||||
disabled={isReadOnly}
|
||||
disabled={isReadOnly || isSaving}
|
||||
onClick={sendTestEmail}>
|
||||
{t("common.send_test_email")}
|
||||
</Button>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
|
||||
<source src={`${key}`} type="video/mp4" />
|
||||
</video>
|
||||
<input
|
||||
className="absolute top-2 right-2 h-4 w-4 rounded-sm bg-white"
|
||||
className="absolute right-2 top-2 h-4 w-4 rounded-sm bg-white"
|
||||
type="checkbox"
|
||||
checked={animation === value}
|
||||
onChange={() => handleBg(value)}
|
||||
|
||||
@@ -67,7 +67,7 @@ export const EditWelcomeCard = ({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<Hand className="h-4 w-4" />
|
||||
|
||||
@@ -20,7 +20,6 @@ interface PictureSelectionFormProps {
|
||||
question: TSurveyPictureSelectionQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyPictureSelectionQuestion>) => void;
|
||||
lastQuestion: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
isInvalid: boolean;
|
||||
|
||||
@@ -196,7 +196,7 @@ export const QuestionCard = ({
|
||||
)}>
|
||||
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
|
||||
|
||||
<button className="opacity-0 group-hover:opacity-100 hover:cursor-move">
|
||||
<button className="opacity-0 hover:cursor-move group-hover:opacity-100">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -377,7 +377,6 @@ export const QuestionCard = ({
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
|
||||
@@ -91,7 +91,7 @@ export const SurveyPlacementCard = ({
|
||||
asChild
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
|
||||
@@ -41,7 +41,7 @@ export const SurveyVariablesCard = ({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<div className="flex w-full justify-center">
|
||||
<FileDigitIcon className="h-4 w-4" />
|
||||
@@ -75,7 +75,7 @@ export const SurveyVariablesCard = ({
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-500 italic">
|
||||
<p className="mt-2 text-sm italic text-slate-500">
|
||||
{t("environments.surveys.edit.no_variables_yet_add_first_one_below")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -75,7 +75,7 @@ const FollowUpActionMultiEmailInput = ({
|
||||
<span className="text-slate-900">{email}</span>
|
||||
<button
|
||||
onClick={() => removeEmail(index)}
|
||||
className="px-1 text-lg leading-none font-medium text-slate-500">
|
||||
className="px-1 text-lg font-medium leading-none text-slate-500">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -98,11 +98,11 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
field.onChange([...field.value, environment.id]);
|
||||
}
|
||||
}}
|
||||
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
id={environment.id}
|
||||
/>
|
||||
<Label htmlFor={environment.id}>
|
||||
<p className="text-sm font-medium text-slate-900 capitalize">
|
||||
<p className="text-sm font-medium capitalize text-slate-900">
|
||||
{environment.type}
|
||||
</p>
|
||||
</Label>
|
||||
@@ -121,8 +121,8 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="fixed right-0 bottom-0 left-0 z-10 flex w-full justify-end space-x-2 bg-white">
|
||||
<div className="flex w-full justify-end pr-4 pb-4">
|
||||
<div className="fixed bottom-0 left-0 right-0 z-10 flex w-full justify-end space-x-2 bg-white">
|
||||
<div className="flex w-full justify-end pb-4 pr-4">
|
||||
<Button type="button" onClick={onCancel} variant="ghost">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("@/modules/ui/components/checkbox", () => ({
|
||||
id={id}
|
||||
data-testid={id}
|
||||
name={props.name}
|
||||
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
onChange={() => {
|
||||
// Call onCheckedChange with true to simulate checkbox selection
|
||||
onCheckedChange(true);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
@@ -11,7 +12,7 @@ import toast from "react-hot-toast";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/common";
|
||||
import { Uploader } from "./components/uploader";
|
||||
import { VideoSettings } from "./components/video-settings";
|
||||
import { getAllowedFiles, uploadFile } from "./lib/utils";
|
||||
import { getAllowedFiles } from "./lib/utils";
|
||||
|
||||
const allowedFileTypesForPreview = ["png", "jpeg", "jpg", "webp"];
|
||||
const isImage = (name: string) => {
|
||||
@@ -21,7 +22,7 @@ const isImage = (name: string) => {
|
||||
interface FileInputProps {
|
||||
id: string;
|
||||
allowedFileExtensions: TAllowedFileExtension[];
|
||||
environmentId: string | undefined;
|
||||
environmentId: string;
|
||||
onFileUpload: (uploadedUrl: string[] | undefined, fileType: "image" | "video") => void;
|
||||
fileUrl?: string | string[];
|
||||
videoUrl?: string;
|
||||
@@ -78,14 +79,11 @@ export const FileInput = ({
|
||||
allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false }))
|
||||
);
|
||||
|
||||
const uploadedFiles = await Promise.allSettled(
|
||||
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
|
||||
const uploadedFiles = await Promise.all(
|
||||
allowedFiles.map((file) => handleFileUpload(file, environmentId, allowedFileExtensions))
|
||||
);
|
||||
|
||||
if (
|
||||
uploadedFiles.length < allowedFiles.length ||
|
||||
uploadedFiles.some((file) => file.status === "rejected")
|
||||
) {
|
||||
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
|
||||
if (uploadedFiles.length === 0) {
|
||||
toast.error(t("common.no_files_uploaded"));
|
||||
} else {
|
||||
@@ -95,8 +93,8 @@ export const FileInput = ({
|
||||
|
||||
const uploadedUrls: string[] = [];
|
||||
uploadedFiles.forEach((file) => {
|
||||
if (file.status === "fulfilled") {
|
||||
uploadedUrls.push(encodeURI(file.value.url));
|
||||
if (file.url) {
|
||||
uploadedUrls.push(encodeURI(file.url));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -147,14 +145,11 @@ export const FileInput = ({
|
||||
...allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false })),
|
||||
]);
|
||||
|
||||
const uploadedFiles = await Promise.allSettled(
|
||||
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
|
||||
const uploadedFiles = await Promise.all(
|
||||
allowedFiles.map((file) => handleFileUpload(file, environmentId, allowedFileExtensions))
|
||||
);
|
||||
|
||||
if (
|
||||
uploadedFiles.length < allowedFiles.length ||
|
||||
uploadedFiles.some((file) => file.status === "rejected")
|
||||
) {
|
||||
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
|
||||
if (uploadedFiles.length === 0) {
|
||||
toast.error(t("common.no_files_uploaded"));
|
||||
} else {
|
||||
@@ -164,8 +159,8 @@ export const FileInput = ({
|
||||
|
||||
const uploadedUrls: string[] = [];
|
||||
uploadedFiles.forEach((file) => {
|
||||
if (file.status === "fulfilled") {
|
||||
uploadedUrls.push(encodeURI(file.value.url));
|
||||
if (file.url) {
|
||||
uploadedUrls.push(encodeURI(file.url));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
101
apps/web/modules/ui/components/file-input/lib/utils.test.ts
Normal file
101
apps/web/modules/ui/components/file-input/lib/utils.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { toast } from "react-hot-toast";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { convertHeicToJpegAction } from "./actions";
|
||||
import { checkForYoutubePrivacyMode, getAllowedFiles } from "./utils";
|
||||
|
||||
// Mock FileReader
|
||||
class MockFileReader {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: ((error: any) => void) | null = null;
|
||||
result: string | null = null;
|
||||
|
||||
readAsDataURL() {
|
||||
// Simulate asynchronous read
|
||||
setTimeout(() => {
|
||||
this.result = "data:text/plain;base64,dGVzdA=="; // base64 for "test"
|
||||
if (this.onload) {
|
||||
this.onload();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock global FileReader
|
||||
global.FileReader = MockFileReader as any;
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./actions", () => ({
|
||||
convertHeicToJpegAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("File Input Utils", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getAllowedFiles", () => {
|
||||
test("should filter out files with unsupported extensions", async () => {
|
||||
const files = [
|
||||
new File(["test"], "test.txt", { type: "text/plain" }),
|
||||
new File(["test"], "test.doc", { type: "application/msword" }),
|
||||
];
|
||||
|
||||
const result = await getAllowedFiles(files, ["txt"], 5);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("test.txt");
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Unsupported file types: test.doc"));
|
||||
});
|
||||
|
||||
test("should filter out files exceeding size limit", async () => {
|
||||
const files = [
|
||||
new File(["x".repeat(6 * 1024 * 1024)], "large.txt", { type: "text/plain" }),
|
||||
new File(["test"], "small.txt", { type: "text/plain" }),
|
||||
];
|
||||
|
||||
const result = await getAllowedFiles(files, ["txt"], 5);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("small.txt");
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Files exceeding size limit (5 MB)"));
|
||||
});
|
||||
|
||||
test("should convert HEIC files to JPEG", async () => {
|
||||
const heicFile = new File(["test"], "test.heic", { type: "image/heic" });
|
||||
const mockConvertedFile = new File(["converted"], "test.jpg", { type: "image/jpeg" });
|
||||
|
||||
vi.mocked(convertHeicToJpegAction).mockResolvedValue({
|
||||
data: mockConvertedFile,
|
||||
});
|
||||
|
||||
const result = await getAllowedFiles([heicFile], ["heic", "jpg"], 5);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("test.jpg");
|
||||
expect(result[0].type).toBe("image/jpeg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkForYoutubePrivacyMode", () => {
|
||||
test("should return true for youtube-nocookie.com URLs", () => {
|
||||
const url = "https://www.youtube-nocookie.com/watch?v=test";
|
||||
expect(checkForYoutubePrivacyMode(url)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for regular youtube.com URLs", () => {
|
||||
const url = "https://www.youtube.com/watch?v=test";
|
||||
expect(checkForYoutubePrivacyMode(url)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for invalid URLs", () => {
|
||||
const url = "not-a-url";
|
||||
expect(checkForYoutubePrivacyMode(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,96 +4,6 @@ import { toast } from "react-hot-toast";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/common";
|
||||
import { convertHeicToJpegAction } from "./actions";
|
||||
|
||||
export const uploadFile = async (
|
||||
file: File | Blob,
|
||||
allowedFileExtensions: string[] | undefined,
|
||||
environmentId: string | undefined
|
||||
) => {
|
||||
try {
|
||||
if (!(file instanceof Blob) || !(file instanceof File)) {
|
||||
throw new Error(`Invalid file type. Expected Blob or File, but received ${typeof file}`);
|
||||
}
|
||||
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
const bufferBytes = fileBuffer.byteLength;
|
||||
const bufferKB = bufferBytes / 1024;
|
||||
|
||||
if (bufferKB > 10240) {
|
||||
const err = new Error("File size is greater than 10MB");
|
||||
err.name = "FileTooLargeError";
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
allowedFileExtensions: allowedFileExtensions,
|
||||
environmentId: environmentId,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/v1/management/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, updatedFileName } = data;
|
||||
|
||||
let requestHeaders: Record<string, string> = {};
|
||||
|
||||
if (signingData) {
|
||||
const { signature, timestamp, uuid } = signingData;
|
||||
|
||||
requestHeaders = {
|
||||
"X-File-Type": file.type,
|
||||
"X-File-Name": encodeURIComponent(updatedFileName),
|
||||
"X-Environment-ID": environmentId ?? "",
|
||||
"X-Signature": signature,
|
||||
"X-Timestamp": String(timestamp),
|
||||
"X-UUID": uuid,
|
||||
};
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
if (presignedFields) {
|
||||
Object.keys(presignedFields).forEach((key) => {
|
||||
formData.append(key, presignedFields[key]);
|
||||
});
|
||||
}
|
||||
|
||||
formData.append("file", file);
|
||||
|
||||
const uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
...(signingData ? { headers: requestHeaders } : {}),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status: ${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
uploaded: true,
|
||||
url: fileUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const isFileSizeExceed = (fileSizeInMB: number, maxSizeInMB?: number) => {
|
||||
if (maxSizeInMB && fileSizeInMB > maxSizeInMB) {
|
||||
return true;
|
||||
@@ -169,6 +79,7 @@ export const checkForYoutubePrivacyMode = (url: string): boolean => {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.host === "www.youtube-nocookie.com";
|
||||
} catch (e) {
|
||||
console.error("Invalid URL", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export default defineConfig({
|
||||
"packages/surveys/src/components/general/smileys.tsx",
|
||||
"apps/web/modules/auth/lib/mock-data.ts", // Exclude mock data files
|
||||
"apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx",
|
||||
"**/*.mjs"
|
||||
"**/*.mjs",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user