Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions frontend/cmmty/components/FileUpload/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import { ChangeEvent, DragEvent, useRef, useState } from "react";
import { UploadCloud } from "lucide-react";
import { FileUploadProps } from "./types";
import { validateFile } from "./validation";

function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export default function FileUpload({
allowedMimeTypes = [],
maxFileSizeBytes,
onFileSelect,
label = "Drag and drop a file here, or click to browse",
}: FileUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);

const processFile = (file: File | null) => {
if (!file) return;

const result = validateFile(file, allowedMimeTypes, maxFileSizeBytes);
if (!result.isValid) {
setSelectedFile(null);
setErrorMessage(result.errorMessage ?? "Invalid file");
return;
}

setErrorMessage(null);
setSelectedFile(file);
onFileSelect?.(file);
};

const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragging(false);
processFile(event.dataTransfer.files.item(0));
};

const onDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragging(true);
};

const onDragLeave = () => setIsDragging(false);

const onChange = (event: ChangeEvent<HTMLInputElement>) => {
processFile(event.target.files?.item(0) ?? null);
};

return (
<div className="w-full">
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
inputRef.current?.click();
}
}}
className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 text-center transition ${
isDragging
? "border-blue-500 bg-blue-50"
: "border-gray-300 bg-gray-50 hover:border-blue-400"
}`}
>
<UploadCloud className="mb-2 h-7 w-7 text-gray-600" />
<p className="text-sm font-medium text-gray-700">{label}</p>
{allowedMimeTypes.length > 0 ? (
<p className="mt-1 text-xs text-gray-500">
Allowed types: {allowedMimeTypes.join(", ")}
</p>
) : null}
</div>

<input
ref={inputRef}
type="file"
className="hidden"
onChange={onChange}
accept={allowedMimeTypes.length > 0 ? allowedMimeTypes.join(",") : undefined}
/>

{selectedFile ? (
<div className="mt-3 rounded-md border border-gray-200 bg-white p-3 text-sm text-gray-700">
<p><span className="font-medium">Name:</span> {selectedFile.name}</p>
<p><span className="font-medium">Size:</span> {formatFileSize(selectedFile.size)}</p>
<p><span className="font-medium">Type:</span> {selectedFile.type || "Unknown"}</p>
</div>
) : null}

{errorMessage ? <p className="mt-2 text-xs text-red-600">{errorMessage}</p> : null}
</div>
);
}
3 changes: 3 additions & 0 deletions frontend/cmmty/components/FileUpload/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as FileUpload } from "./FileUpload";
export { validateFile } from "./validation";
export type { FileUploadProps, FileValidationResult } from "./types";
11 changes: 11 additions & 0 deletions frontend/cmmty/components/FileUpload/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface FileUploadProps {
allowedMimeTypes?: string[];
maxFileSizeBytes?: number;
onFileSelect?: (file: File) => void;
label?: string;
}

export interface FileValidationResult {
isValid: boolean;
errorMessage?: string;
}
26 changes: 26 additions & 0 deletions frontend/cmmty/components/FileUpload/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { validateFile } from "./validation";

describe("validateFile", () => {
it("rejects unsupported MIME types", () => {
const file = new File(["content"], "notes.txt", { type: "text/plain" });
const result = validateFile(file, ["application/pdf"], 1024);

expect(result.isValid).toBe(false);
expect(result.errorMessage).toMatch(/unsupported file type/i);
});

it("rejects files larger than max", () => {
const file = new File([new Uint8Array(2048)], "large.pdf", { type: "application/pdf" });
const result = validateFile(file, ["application/pdf"], 1024);

expect(result.isValid).toBe(false);
expect(result.errorMessage).toMatch(/exceeds max size/i);
});

it("accepts valid files", () => {
const file = new File(["content"], "doc.pdf", { type: "application/pdf" });
const result = validateFile(file, ["application/pdf"], 1024 * 1024);

expect(result.isValid).toBe(true);
});
});
23 changes: 23 additions & 0 deletions frontend/cmmty/components/FileUpload/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { FileValidationResult } from "./types";

export function validateFile(
file: File,
allowedMimeTypes: string[] = [],
maxFileSizeBytes?: number,
): FileValidationResult {
if (allowedMimeTypes.length > 0 && !allowedMimeTypes.includes(file.type)) {
return {
isValid: false,
errorMessage: "Unsupported file type.",
};
}

if (maxFileSizeBytes && file.size > maxFileSizeBytes) {
return {
isValid: false,
errorMessage: `File exceeds max size of ${Math.round(maxFileSizeBytes / (1024 * 1024))}MB.`,
};
}

return { isValid: true };
}
29 changes: 29 additions & 0 deletions frontend/cmmty/components/Input/Input.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from "@testing-library/react";
import Input from "./Input";

describe("Input", () => {
it("shows error state and message", () => {
render(
<Input
id="email"
label="Email"
error
errorMessage="Email is required"
placeholder="Enter email"
/>,
);

expect(screen.getByText("Email is required")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true");
});

it("toggles password visibility", () => {
render(<Input id="password" label="Password" type="password" />);

const input = screen.getByLabelText("Password");
expect(input).toHaveAttribute("type", "password");

fireEvent.click(screen.getByRole("button", { name: /show password/i }));
expect(input).toHaveAttribute("type", "text");
});
});
82 changes: 82 additions & 0 deletions frontend/cmmty/components/Input/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import { useId, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { InputProps } from "./types";

export default function Input({
id,
type = "text",
label,
helperText,
error,
errorMessage,
leftIcon,
rightIcon,
className,
disabled,
...props
}: InputProps) {
const generatedId = useId();
const inputId = id || generatedId;
const helperId = `${inputId}-helper`;
const errorId = `${inputId}-error`;
const [showPassword, setShowPassword] = useState(false);

const resolvedType =
type === "password" && showPassword ? "text" : type;

return (
<div className="w-full">
{label ? (
<label htmlFor={inputId} className="mb-1 block text-sm font-medium text-gray-800">
{label}
</label>
) : null}
<div className="relative">
{leftIcon ? (
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-500">
{leftIcon}
</span>
) : null}
<input
id={inputId}
type={resolvedType}
disabled={disabled}
aria-invalid={Boolean(error)}
aria-describedby={error ? errorId : helperText ? helperId : undefined}
className={`w-full rounded-md border bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:ring-2 disabled:cursor-not-allowed disabled:bg-gray-100 ${leftIcon ? "pl-10" : ""} ${(rightIcon || type === "password") ? "pr-10" : ""} ${
error
? "border-red-500 focus:border-red-500 focus:ring-red-200"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-200"
} ${className ?? ""}`}
{...props}
/>

{type === "password" ? (
<button
type="button"
onClick={() => setShowPassword((value) => !value)}
className="absolute inset-y-0 right-2 flex items-center px-1 text-gray-600 hover:text-gray-800"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
) : rightIcon ? (
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-gray-500">
{rightIcon}
</span>
) : null}
</div>
{error && errorMessage ? (
<p id={errorId} className="mt-1 text-xs text-red-600">
{errorMessage}
</p>
) : helperText ? (
<p id={helperId} className="mt-1 text-xs text-gray-500">
{helperText}
</p>
) : null}
</div>
);
}
2 changes: 2 additions & 0 deletions frontend/cmmty/components/Input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as Input } from "./Input";
export type { InputProps, InputType } from "./types";
15 changes: 15 additions & 0 deletions frontend/cmmty/components/Input/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { InputHTMLAttributes, ReactNode } from "react";

export type InputType = "text" | "email" | "password";

export interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
id?: string;
type?: InputType;
label?: string;
helperText?: string;
error?: boolean;
errorMessage?: string;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
28 changes: 28 additions & 0 deletions frontend/cmmty/components/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { fireEvent, render, screen } from "@testing-library/react";
import Modal from "./Modal";

describe("Modal", () => {
it("renders provided content", () => {
render(
<Modal open title="Test Modal">
<p>Body content</p>
</Modal>,
);

expect(screen.getByText("Test Modal")).toBeInTheDocument();
expect(screen.getByText("Body content")).toBeInTheDocument();
});

it("calls onClose on ESC press", () => {
const onClose = jest.fn();

render(
<Modal open title="Esc test" onClose={onClose}>
<p>Body content</p>
</Modal>,
);

fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});
Loading
Loading