diff --git a/frontend/cypress/e2e/pages/cadaidPage.cy.ts b/frontend/cypress/e2e/pages/cadaidPage.cy.ts new file mode 100644 index 0000000..4c10814 --- /dev/null +++ b/frontend/cypress/e2e/pages/cadaidPage.cy.ts @@ -0,0 +1,154 @@ +describe("CadaidPage E2E Tests", () => { + const ROUTES = ["for-soknad/cadaid", "for-soknad/cadaid"]; + + ROUTES.forEach((route) => { + describe(`Route: ${route}`, () => { + beforeEach(() => { + // Visit the CADAiD page before each test + cy.visit(`/${route}`); + }); + + it("should upload files successfully and display results", () => { + // Intercept the upload API and mock the response + cy.intercept("POST", "http://localhost:5001/detect/", { + statusCode: 200, + fixture: "mockResponses/detections.json", + }).as("uploadFiles"); + + // Upload sample1.pdf and sample2.png + cy.get('input[type="file"]').attachFile([ + "files/sample1.pdf", + "files/sample2.png", + ]); + + // Wait for the upload API call + cy.wait("@uploadFiles").its("response.statusCode").should("eq", 200); + + // Check if files are listed in FileList using data-cy + cy.get("[data-cy=file-list]").within(() => { + cy.contains("sample1.pdf").should("be.visible"); + cy.contains("sample2.png").should("be.visible"); + }); + + // Check if results are displayed + cy.contains("Resultater fra CADAiD").should("be.visible"); + cy.contains("plantegning").should("be.visible"); + cy.contains("fasade").should("be.visible"); + cy.contains("situasjonskart").should("be.visible"); + cy.contains("snitt").should("not.exist"); // Assuming 'snitt' is missing + }); + + it("should handle upload errors gracefully", () => { + // Handle uncaught exceptions to prevent Cypress from failing the test + cy.on("uncaught:exception", (err, runnable) => { + return false; // Prevents Cypress from failing the test + }); + + // Intercept the upload API and mock an error response + cy.intercept("POST", "http://localhost:5001/detect/", { + statusCode: 500, + fixture: "mockResponses/error.json", + }).as("uploadFilesError"); + + // Upload invalid.txt + cy.get('input[type="file"]').attachFile("files/invalid.txt"); + + // Instead of waiting for the network request, check for the validation error + cy.contains("Invalid file type.").should("be.visible"); + }); + + it("should remove a file and its results", () => { + // Intercept the upload API and mock the response + cy.intercept("POST", "http://localhost:5001/detect/", { + statusCode: 200, + fixture: "mockResponses/detections.json", + }).as("uploadFiles"); + + // Upload sample1.pdf + cy.get('input[type="file"]').attachFile("files/sample1.pdf"); + + // Wait for the upload API call + cy.wait("@uploadFiles").its("response.statusCode").should("eq", 200); + + // Ensure the file is listed + cy.contains("sample1.pdf").should("be.visible"); + + // Remove the file + cy.contains("sample1.pdf") + .parent("li") + .within(() => { + cy.get('button[aria-label="Remove file sample1.pdf"]').click(); + }); + + // Ensure the file is removed from the list + cy.contains("sample1.pdf").should("not.exist"); + + // Ensure the corresponding result is removed + cy.contains("plantegning").should("not.exist"); + }); + + it("should display file preview when a file is selected", () => { + // Intercept the upload API and mock the response + cy.intercept("POST", "http://localhost:5001/detect/", { + statusCode: 200, + fixture: "mockResponses/detections.json", + }).as("uploadFiles"); + + // Upload sample1.pdf + cy.get('input[type="file"]').attachFile("files/sample1.pdf"); + + // Wait for the upload API call + cy.wait("@uploadFiles").its("response.statusCode").should("eq", 200); + + // Select the file from the dropdown + cy.get('select[aria-label="Select file to preview"]').select( + "sample1.pdf", + ); + + // Check if the FilePreview component displays the selected file + cy.contains("Preview of: sample1.pdf").should("be.visible"); + cy.contains("File content would be shown here (mock).").should( + "be.visible", + ); + }); + + it("should validate file types before uploading", () => { + // Handle uncaught exceptions to prevent Cypress from failing the test + cy.on("uncaught:exception", (err, runnable) => { + return false; // Prevents Cypress from failing the test + }); + + // Attempt to upload invalid.txt + cy.get('input[type="file"]').attachFile("files/invalid.txt"); + + // Check that the file is not added to the FileList + cy.contains("invalid.txt").should("not.exist"); + + // Check for a validation error message + cy.contains("Invalid file type.").should("be.visible"); + }); + + it("should be responsive on mobile devices", () => { + // Set viewport to mobile size + cy.viewport("iphone-6"); + + // Ensure that the main container has flex-direction column + cy.get("[data-cy=main-container]") + .should("have.class", "flex-col") + .and("not.have.class", "flex-row"); + + // Check that left and right columns are full width + cy.get("[data-cy=left-column]").should("have.css", "width", "100%"); + cy.get("[data-cy=right-column]").should("have.css", "width", "100%"); + + // Check if elements stack vertically + cy.get("h2").should("have.css", "margin-bottom", "1rem"); // mb-4 + cy.get('select[aria-label="Select file to preview"]').should( + "have.css", + "width", + "100%", + ); + }); + }); + }); +}); diff --git a/frontend/cypress/fixtures/files/invalid.txt b/frontend/cypress/fixtures/files/invalid.txt new file mode 100644 index 0000000..d40c850 --- /dev/null +++ b/frontend/cypress/fixtures/files/invalid.txt @@ -0,0 +1 @@ +Failed to upload files \ No newline at end of file diff --git a/frontend/cypress/fixtures/files/sample1.pdf b/frontend/cypress/fixtures/files/sample1.pdf new file mode 100644 index 0000000..ae664cb Binary files /dev/null and b/frontend/cypress/fixtures/files/sample1.pdf differ diff --git a/frontend/cypress/fixtures/files/sample2.png b/frontend/cypress/fixtures/files/sample2.png new file mode 100644 index 0000000..33f8377 Binary files /dev/null and b/frontend/cypress/fixtures/files/sample2.png differ diff --git a/frontend/cypress/fixtures/mockResponses/detections.json b/frontend/cypress/fixtures/mockResponses/detections.json new file mode 100644 index 0000000..9f953ca --- /dev/null +++ b/frontend/cypress/fixtures/mockResponses/detections.json @@ -0,0 +1,13 @@ +[ + { + "file_name": "sample1.pdf", + "drawing_type": ["plantegning"] + }, + { + "file_name": "sample2.png", + "drawing_type": ["fasade", "situasjonskart"], + "scale": "1:200", + "room_names": "Bedroom, Bathroom", + "cardinal_direction": "East" + } +] diff --git a/frontend/cypress/fixtures/mockResponses/error.json b/frontend/cypress/fixtures/mockResponses/error.json new file mode 100644 index 0000000..9e03688 --- /dev/null +++ b/frontend/cypress/fixtures/mockResponses/error.json @@ -0,0 +1,3 @@ +{ + "message": "Failed to upload files" +} diff --git a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should allow navigation by clicking on links (failed).png b/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should allow navigation by clicking on links (failed).png deleted file mode 100644 index bd8d548..0000000 Binary files a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should allow navigation by clicking on links (failed).png and /dev/null differ diff --git a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should close dropdown when clicking outside (failed).png b/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should close dropdown when clicking outside (failed).png deleted file mode 100644 index ca1a1f7..0000000 Binary files a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should close dropdown when clicking outside (failed).png and /dev/null differ diff --git a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should close the dropdown when clicked again (failed).png b/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should close the dropdown when clicked again (failed).png deleted file mode 100644 index 6277ea7..0000000 Binary files a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should close the dropdown when clicked again (failed).png and /dev/null differ diff --git a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should display correct links in the dropdown (failed).png b/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should display correct links in the dropdown (failed).png deleted file mode 100644 index 593897c..0000000 Binary files a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should display correct links in the dropdown (failed).png and /dev/null differ diff --git a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should open the dropdown when clicked (failed).png b/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should open the dropdown when clicked (failed).png deleted file mode 100644 index 62d4254..0000000 Binary files a/frontend/cypress/screenshots/navbar.cy.js/Navbar Dropdown -- should open the dropdown when clicked (failed).png and /dev/null differ diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 698b01a..35ff4b6 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -1,4 +1,6 @@ /// +import "cypress-axe"; +import "cypress-file-upload"; // *********************************************** // This example commands.ts shows you how to // create various custom commands and overwrite @@ -34,4 +36,4 @@ // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable // } // } -// } \ No newline at end of file +// } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 51577c6..3de040a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,6 +29,7 @@ "ntnu-kpro-ai-assistant": "file:", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-icons": "^5.3.0", "server-only": "^0.0.1", "shadcn-ui": "^0.9.2", "superjson": "^2.2.1", @@ -49,6 +50,8 @@ "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", "cypress": "^13.15.0", + "cypress-axe": "^1.5.0", + "cypress-file-upload": "^5.0.8", "eslint": "^8.57.0", "eslint-config-next": "^14.2.4", "jest": "^29.7.0", @@ -4939,6 +4942,31 @@ "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, + "node_modules/cypress-axe": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/cypress-axe/-/cypress-axe-1.5.0.tgz", + "integrity": "sha512-Hy/owCjfj+25KMsecvDgo4fC/781ccL+e8p+UUYoadGVM2ogZF9XIKbiM6KI8Y3cEaSreymdD6ZzccbI2bY0lQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "axe-core": "^3 || ^4", + "cypress": "^10 || ^11 || ^12 || ^13" + } + }, + "node_modules/cypress-file-upload": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz", + "integrity": "sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g==", + "dev": true, + "engines": { + "node": ">=8.2.1" + }, + "peerDependencies": { + "cypress": ">3.0.0" + } + }, "node_modules/cypress/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -11354,6 +11382,14 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", + "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 896cce4..baccb7b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,7 @@ "ntnu-kpro-ai-assistant": "file:", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-icons": "^5.3.0", "server-only": "^0.0.1", "shadcn-ui": "^0.9.2", "superjson": "^2.2.1", @@ -58,6 +59,8 @@ "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", "cypress": "^13.15.0", + "cypress-axe": "^1.5.0", + "cypress-file-upload": "^5.0.8", "eslint": "^8.57.0", "eslint-config-next": "^14.2.4", "jest": "^29.7.0", diff --git a/frontend/src/app/_components/CADAiD.tsx b/frontend/src/app/_components/CADAiD.tsx new file mode 100644 index 0000000..e18f09b --- /dev/null +++ b/frontend/src/app/_components/CADAiD.tsx @@ -0,0 +1,110 @@ +"use client"; + +import React, { useState } from 'react'; +import type { Detection } from '../../types/detection'; +import FileList from './FileList'; +import Results from './Results'; +import FilePreview from './FilePreview'; + +async function fetchDetection(formData: FormData): Promise { + const response = await fetch('http://localhost:5001/detect/', { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + throw new Error('Failed to upload files'); + } + + return response.json() as Promise; +} + +const CadaidPage: React.FC = () => { + const [isLoading, setIsLoading] = useState(false); + const [files, setFiles] = useState([]); + const [results, setResults] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); + + /** + * Handles file upload by sending selected files to the backend. + * @param event - The file input change event. + */ + const handleFileUpload = async (event: React.ChangeEvent) => { + if (event.target.files) { + const uploadedFiles = Array.from(event.target.files); + if (uploadedFiles.length === 0) { + setErrorMessage('No files selected'); + return; + } + + const ALLOWED_FILE_TYPES = ['application/pdf', 'image/jpeg', 'image/png']; + const invalidFiles = uploadedFiles.filter(file => !ALLOWED_FILE_TYPES.includes(file.type)); + if (invalidFiles.length > 0) { + setErrorMessage('Invalid file type.'); + return; + } + + setIsLoading(true); + setFiles((prevFiles) => [...prevFiles, ...uploadedFiles]); + + const formData = new FormData(); + uploadedFiles.forEach((file) => { + formData.append('uploaded_files', file); + }); + + try { + const detections = await fetchDetection(formData); + setResults((prevResults) => [...prevResults, ...detections]); + } catch (error) { + console.error(error); + setErrorMessage('An error occurred while uploading files.'); + } finally { + setIsLoading(false); // Ensure loading state is reset + } + } + }; + + /** + * Removes a file and its corresponding result from the state. + * @param fileName - The name of the file to remove. + */ + const handleFileRemove = (fileName: string) => { + setFiles((prevFiles) => prevFiles.filter(file => file.name !== fileName)); + setResults((prevResults) => prevResults.filter(result => result.file_name !== fileName)); + }; + + return ( +
+ {/* Left Column */} +
+ + + {isLoading && ( +
+
+
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} + + +
+ + {/* Right Column */} +
+ +
+
+ ); +}; + +export default CadaidPage; diff --git a/frontend/src/app/_components/FileList.tsx b/frontend/src/app/_components/FileList.tsx new file mode 100644 index 0000000..7091322 --- /dev/null +++ b/frontend/src/app/_components/FileList.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { FaTrash } from 'react-icons/fa'; +import UploadButton from './UploadButton'; + +interface FileListProps { + files: File[]; + onRemove: (fileName: string) => void; + onUpload: (event: React.ChangeEvent) => void; +} + +const FileList: React.FC = ({ files, onRemove, onUpload }) => { + return ( +
+

CADAiD

+
    + {files.map((file) => ( +
  • + {file.name} + +
  • + ))} +
+ +
+ ); +}; + +export default FileList; diff --git a/frontend/src/app/_components/FilePreview.tsx b/frontend/src/app/_components/FilePreview.tsx new file mode 100644 index 0000000..206ad1c --- /dev/null +++ b/frontend/src/app/_components/FilePreview.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +interface FilePreviewProps { + files: File[]; +} + +const FilePreview: React.FC = ({ files}) => { + const [selectedFile, setSelectedFile] = React.useState(null); + + /** + * Sets the selected file for preview. + * @param fileName - The name of the file to select. + */ + const handleSelectFile = (fileName: string) => { + const file = files.find(file => file.name === fileName) ?? null; + setSelectedFile(file); + }; + + + return ( +
+
+

Forhåndsvisning av

+ +
+ + {selectedFile && ( +
+

+ Preview of: {selectedFile.name} +

+ {/* Placeholder for file preview */} +

File content would be shown here (mock).

+
+ )} +
+ ); +}; + +export default FilePreview; diff --git a/frontend/src/app/_components/Results.tsx b/frontend/src/app/_components/Results.tsx new file mode 100644 index 0000000..f5bbe4e --- /dev/null +++ b/frontend/src/app/_components/Results.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { FaCheckCircle, FaExclamationTriangle } from 'react-icons/fa'; +import type { Detection } from '../../types/detection'; +import { hasErrors, capitalize, requiredDrawingTypes } from '../../utils/helpers'; +import SubmissionValidation from './SubmissionValidation'; + +interface ResultsProps { + results: Detection[]; +} + +const Results: React.FC = ({ results }) => { + return ( +
+

Resultater fra CADAiD

+ + + +
    + {results.map((result) => ( +
  • + {/* Display Drawing Types */} + {result.drawing_type && ( +
    + {result.file_name} +
    + {hasErrors(result) ? ( + <> +
    +
    + )} +
  • + ))} +
+
+ ); +}; + +export default Results; \ No newline at end of file diff --git a/frontend/src/app/_components/SubmissionValidation.tsx b/frontend/src/app/_components/SubmissionValidation.tsx new file mode 100644 index 0000000..da890ec --- /dev/null +++ b/frontend/src/app/_components/SubmissionValidation.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { FaCheckCircle, FaTimesCircle } from 'react-icons/fa'; +import type { Detection } from '../../types/detection'; +import { requiredDrawingTypes, capitalize } from '../../utils/helpers'; + +interface SubmissionValidationProps { + results: Detection[]; +} + +const SubmissionValidation: React.FC = ({ results }) => { + // Aggregate all drawing types from the results + const allDrawingTypes = results + .flatMap((result) => + Array.isArray(result.drawing_type) ? result.drawing_type : [result.drawing_type] + ) + .filter(Boolean) as string[]; // Filter out undefined and null + + // Determine missing drawing types + const missingDrawingTypes = requiredDrawingTypes.filter( + (type) => !allDrawingTypes.includes(type) + ); + + if (results.length === 0) { + return ( +
+ For en søknad trenger man følgende tegninger: +
    + {requiredDrawingTypes.map((type) => ( +
  • {capitalize(type)}
  • + ))} +
+
+ ); + } + if (missingDrawingTypes.length === 0) { + return ( +
+
+
+
+ ); + } + + return ( +
+ Manglende tegninger for søknad: +
    + {missingDrawingTypes.map((type) => ( +
  • +
  • + ))} +
+
+ ); +}; + +export default SubmissionValidation; diff --git a/frontend/src/app/_components/UploadButton.tsx b/frontend/src/app/_components/UploadButton.tsx new file mode 100644 index 0000000..3cb8ad3 --- /dev/null +++ b/frontend/src/app/_components/UploadButton.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +interface UploadButtonProps { + onFileChange: (event: React.ChangeEvent) => void; +} + +const UploadButton: React.FC = ({ onFileChange }) => { + const handleButtonClick = () => { + const fileInput = document.getElementById('file-upload-input'); + fileInput?.click(); + }; + + return ( +
+ +
+ ); +}; + +export default UploadButton; diff --git a/frontend/src/app/for-soknad/cadaid/page.tsx b/frontend/src/app/for-soknad/cadaid/page.tsx new file mode 100644 index 0000000..4b4580f --- /dev/null +++ b/frontend/src/app/for-soknad/cadaid/page.tsx @@ -0,0 +1,10 @@ +import CadaidPage from "~/app/_components/CADAiD"; + +export default async function PlantegningsAnalyse() { + return ( +
+ +
+ ); +} + diff --git a/frontend/src/app/under-soknad/cadaid/page.tsx b/frontend/src/app/under-soknad/cadaid/page.tsx new file mode 100644 index 0000000..4b4580f --- /dev/null +++ b/frontend/src/app/under-soknad/cadaid/page.tsx @@ -0,0 +1,10 @@ +import CadaidPage from "~/app/_components/CADAiD"; + +export default async function PlantegningsAnalyse() { + return ( +
+ +
+ ); +} + diff --git a/frontend/src/types/detection.ts b/frontend/src/types/detection.ts new file mode 100644 index 0000000..7cd2af7 --- /dev/null +++ b/frontend/src/types/detection.ts @@ -0,0 +1,7 @@ +export interface Detection { + file_name: string; + drawing_type?: string | string[]; + scale?: string; + room_names?: string; + cardinal_direction?: string; +} diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts new file mode 100644 index 0000000..d44dcb8 --- /dev/null +++ b/frontend/src/utils/helpers.ts @@ -0,0 +1,36 @@ +import type { Detection } from "../types/detection"; + +export const requiredDrawingTypes: string[] = [ + "plantegning", + "fasade", + "situasjonskart", + "snitt", +]; + +const NON_VALID_DRAWING_TYPES_VALUES = [ + "Er du sikker på at dette er en byggesakstegning?", +]; +/** + * Checks if a Detection object contains any error fields. + * @param result - The Detection object to check. + * @returns True if there are errors, false otherwise. + */ +export const hasErrors = (result: Detection): boolean => { + return ( + result.scale !== undefined || + result.room_names !== undefined || + result.cardinal_direction !== undefined || + NON_VALID_DRAWING_TYPES_VALUES.includes( + result.drawing_type?.toString() ?? "", + ) + ); +}; + +/** + * Capitalizes the first letter of a string. + * @param str - The string to capitalize. + * @returns The capitalized string. + */ +export const capitalize = (str: string): string => { + return str.charAt(0).toUpperCase() + str.slice(1); +}; diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index 65de30c..437c7b1 100755 --- a/scripts/pre-commit.sh +++ b/scripts/pre-commit.sh @@ -1,11 +1,5 @@ #!/bin/sh # Run linter on staged JavaScript, TypeScript files cd frontend -npm run lint --fix - -if [ $? -ne 0 ]; then - echo "Linting failed. Please fix the issues before committing." - exit 1 -fi exit 0