diff --git a/app/src/App.js b/app/src/App.js index a042c109..9eaf6784 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -21,6 +21,7 @@ import {SemanticToastContainer} from "react-semantic-toasts-2"; import {FilePreviewProvider} from "./components/FilePreview"; import {TagsProvider} from "./Tags"; import {ZimRoute} from "./components/Zim"; +import ErrorBoundary from "./components/ErrorBoundary"; function PageNotFound() { const {t} = useContext(ThemeContext); @@ -60,7 +61,9 @@ function Root() { - + + + @@ -73,19 +76,19 @@ const router = createBrowserRouter(createRoutesFromElements(} errorElement={} > - }/> - }/> + }/> + }/> }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> )); export default function App() { diff --git a/app/src/DashboardPage.js b/app/src/DashboardPage.js index 7fba0d20..29331b88 100644 --- a/app/src/DashboardPage.js +++ b/app/src/DashboardPage.js @@ -194,7 +194,7 @@ export function Getters() { - handleSetGetter(e, 'upload')}> + handleSetGetter(e, 'upload')}> Upload diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js index e8de0e7e..f7bbf8a3 100644 --- a/app/src/components/Archive.js +++ b/app/src/components/Archive.js @@ -59,7 +59,15 @@ import {InputForm, useForm} from "../hooks/useForm"; import {CollectionTagModal} from "./collections/CollectionTagModal"; import {Link, Route, Routes, useLocation, useNavigate, useParams} from "react-router"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; -import {useArchive, useDomain, useDomains, useOneQuery, useSearchArchives, useSearchDomain, useSearchOrder} from "../hooks/customHooks"; +import { + useArchive, + useDomain, + useDomains, + useOneQuery, + useSearchArchives, + useSearchDomain, + useSearchOrder +} from "../hooks/customHooks"; import {FileCards, FileRowTagIcon, FilesView} from "./Files"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import _ from "lodash"; @@ -218,9 +226,10 @@ function ArchivePage() { await fetchArchive(); } - const archivedDatetimeString = isoDatetimeToAgoPopup(archiveFile.download_datetime, true); - const publishedDatetimeString = archiveFile.published_datetime ? isoDatetimeToAgoPopup(archiveFile.published_datetime, true) - : 'unknown'; + const downloadDatetimeString = archiveFile.download_datetime ? + isoDatetimeToAgoPopup(archiveFile.download_datetime, true) : 'unknown'; + const publishedDatetimeString = archiveFile.published_datetime ? + isoDatetimeToAgoPopup(archiveFile.published_datetime, true) : 'unknown'; const modifiedDatetimeString = archiveFile.published_modified_datetime ? isoDatetimeToAgoPopup(archiveFile.published_modified_datetime, true) : 'unknown'; @@ -295,7 +304,7 @@ function ArchivePage() { Published: {publishedDatetimeString} - Archived: {archivedDatetimeString} + Downloaded: {downloadDatetimeString} diff --git a/app/src/components/ErrorBoundary.js b/app/src/components/ErrorBoundary.js new file mode 100644 index 00000000..c417d688 --- /dev/null +++ b/app/src/components/ErrorBoundary.js @@ -0,0 +1,111 @@ +import React from 'react'; +import {Button, Container, Icon, Message, Segment} from 'semantic-ui-react'; + +/** + * Error Boundary component that catches JavaScript errors in child components + * and displays a fallback UI instead of crashing the entire app. + * + * Usage: + * + * + * + * + * With custom fallback: + * Something went wrong}> + * + * + */ +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = {hasError: false, error: null, errorInfo: null}; + } + + static getDerivedStateFromError(error) { + // Update state so next render shows fallback UI + return {hasError: true, error}; + } + + componentDidCatch(error, errorInfo) { + // Log the error for debugging + console.error('ErrorBoundary caught an error:', error); + console.error('Component stack:', errorInfo?.componentStack); + this.setState({errorInfo}); + } + + handleReset = () => { + this.setState({hasError: false, error: null, errorInfo: null}); + }; + + handleReload = () => { + window.location.reload(); + }; + + render() { + if (this.state.hasError) { + // Allow custom fallback UI + if (this.props.fallback) { + return this.props.fallback; + } + + const {error} = this.state; + const errorMessage = error?.message || 'An unexpected error occurred'; + + return ( + + + + + Something went wrong + {errorMessage} + + + Try Again + + + Reload Page + + + + + + ); + } + + return this.props.children; + } +} + +/** + * A minimal error boundary for inline components that shows a simple error message. + */ +class InlineErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = {hasError: false}; + } + + static getDerivedStateFromError() { + return {hasError: true}; + } + + componentDidCatch(error, errorInfo) { + console.error('InlineErrorBoundary caught:', error, errorInfo?.componentStack); + } + + render() { + if (this.state.hasError) { + return ( + + + Failed to load this section + + + ); + } + return this.props.children; + } +} + +export default ErrorBoundary; +export {ErrorBoundary, InlineErrorBoundary}; diff --git a/app/src/components/FileBrowser.js b/app/src/components/FileBrowser.js index fffdf4b0..9c3c7bb2 100644 --- a/app/src/components/FileBrowser.js +++ b/app/src/components/FileBrowser.js @@ -1,7 +1,8 @@ -import {Button, Header, Icon, Modal, Placeholder, Table} from "./Theme"; +import {Button, Header, Icon, Modal, Placeholder, Progress, Segment, Table} from "./Theme"; import { Checkbox, Form, + Grid, IconGroup, Input, PlaceholderLine, @@ -11,16 +12,27 @@ import { TableHeaderCell, TableRow } from "semantic-ui-react"; -import {APIButton, DirectorySearch, ErrorMessage, FileIcon, humanFileSize, useIsIgnoredDirectory} from "./Common"; +import { + APIButton, + DirectorySearch, + ErrorMessage, + FileIcon, + humanFileSize, + mimetypeIconName, + Toggle, + useIsIgnoredDirectory +} from "./Common"; import React, {useEffect, useState} from "react"; +import {useDropzone} from "react-dropzone"; import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api"; import _ from 'lodash'; import {SortableTable} from "./SortableTable"; -import {useBrowseFiles, useMediaDirectory, useWROLMode} from "../hooks/customHooks"; +import {useBrowseFiles, useMediaDirectory, useUploadFile, useWROLMode} from "../hooks/customHooks"; import {FileRowTagIcon, FilesRefreshButton} from "./Files"; import {FilePreviewContext} from "./FilePreview"; import {SettingsContext, ThemeContext} from "../contexts/contexts"; import {BulkTagModal} from "./BulkTagModal"; +import {InlineErrorBoundary} from "./ErrorBoundary"; function depthIndentation(path) { // Repeated spaces for every folder a path is in. @@ -77,8 +89,10 @@ function Folder({folder, onFolderClick, sortData, selectedPaths, onFileClick, on let childPaths = []; _.forEach(children, (p, k) => { childPaths = [...childPaths, - ]; + + + ]; }); return {f} @@ -138,6 +152,7 @@ export function FileBrowser() { const [makeDirectoryOpen, setMakeDirectoryOpen] = React.useState(false); const [ignoreDirectoryOpen, setIgnoreDirectoryOpen] = React.useState(false); const [bulkTagOpen, setBulkTagOpen] = React.useState(false); + const [uploadOpen, setUploadOpen] = React.useState(false); const [pending, setPending] = React.useState(false); // Only true if only one path is selected, and it is a directory. const [singleDirectorySelected, setSingleDirectorySelected] = React.useState(false); @@ -270,6 +285,18 @@ export function FileBrowser() { parent={selectedPaths.length ? selectedPaths[0] : null} onSubmit={handleMakeDirectory} /> + 0 && !singleDirectorySelected)} + onClick={() => setUploadOpen(true)} + /> + setUploadOpen(false)} + destination={singleDirectorySelected && selectedPaths[0] ? selectedPaths[0].slice(0, -1) : ''} + onComplete={reset} + /> } + +export function FileBrowserUploadModal({open, onClose, destination, onComplete}) { + const mediaDirectory = useMediaDirectory(); + const {t} = React.useContext(ThemeContext); + + const { + setFiles, + progresses, + setDestination, + doClear, + tagsSelector, + overwrite, + setOverwrite, + overallProgress, + inProgress, + } = useUploadFile(); + + const onDrop = React.useCallback(async (acceptedFiles) => { + if (acceptedFiles && acceptedFiles.length > 0) { + setFiles(acceptedFiles); + } + }, [setFiles]); + + const {getRootProps, getInputProps} = useDropzone({onDrop, disabled: inProgress}); + + // Set destination when modal opens or destination changes + useEffect(() => { + if (open) { + setDestination(destination); + } + }, [open, destination, setDestination]); + + // Clear upload state when destination is cleared + useEffect(() => { + if (!destination && open) { + doClear(); + } + }, [destination, open, doClear]); + + const handleClose = () => { + if (!inProgress) { + doClear(); + onClose(); + if (onComplete) { + onComplete(); + } + } + }; + + let progressBars; + if (!_.isEmpty(progresses)) { + progressBars = Object.entries(progresses).map(([name, value]) => { + const {percent, status, type} = value; + let color = 'grey'; + let statusString; + if (status === 'complete') { + color = 'green'; + statusString = 'Complete:'; + } else if (status === 'pending') { + statusString = 'Pending:'; + } else if (status === 'failed') { + color = 'red'; + statusString = 'Failed:'; + } else if (status === 'conflicting') { + color = 'orange'; + statusString = 'Already Exists:'; + } + let indicating = status === 'pending'; + + return + + + + + + {statusString} {name} + + + + }) + } + + const displayPath = destination ? `${mediaDirectory}/${destination}` : mediaDirectory; + + return + Upload to: {displayPath} + + + {tagsSelector} + + setOverwrite(!overwrite)} + disabled={inProgress} + /> + + + + + + + + + + + + + + + + Click here, or drop files here to upload + + + + + + + + + + + + + + + {progresses && Object.keys(progresses).length > 1 && } + + + + + {progressBars} + + + + + Close + + + +} diff --git a/app/src/components/FileBrowser.test.js b/app/src/components/FileBrowser.test.js new file mode 100644 index 00000000..545a5436 --- /dev/null +++ b/app/src/components/FileBrowser.test.js @@ -0,0 +1,178 @@ +import React from 'react'; +import {act, fireEvent, screen, waitFor} from '@testing-library/react'; +import {FileBrowser} from './FileBrowser'; +import {renderWithProviders} from '../test-utils'; +import {SettingsContext} from '../contexts/contexts'; +import {FilePreviewContext} from './FilePreview'; + +// Mock the API functions +jest.mock('../api', () => ({ + deleteFile: jest.fn(), + ignoreDirectory: jest.fn(), + makeDirectory: jest.fn(), + movePaths: jest.fn(), + renamePath: jest.fn(), + unignoreDirectory: jest.fn(), +})); + +// Mock the custom hooks +const mockUseUploadFile = { + setFiles: jest.fn(), + progresses: {}, + setDestination: jest.fn(), + doClear: jest.fn(), + tagsSelector: null, + overwrite: false, + setOverwrite: jest.fn(), + overallProgress: 0, + inProgress: false, +}; + +jest.mock('../hooks/customHooks', () => ({ + useBrowseFiles: jest.fn(), + useMediaDirectory: jest.fn(() => '/media/wrolpi'), + useWROLMode: jest.fn(() => false), + useStatusFlag: jest.fn(() => false), + useUploadFile: () => mockUseUploadFile, +})); + +// Mock react-dropzone +jest.mock('react-dropzone', () => ({ + useDropzone: () => ({ + getRootProps: () => ({}), + getInputProps: () => ({}), + }), +})); + +// Import mocked hooks for manipulation +import {useBrowseFiles, useWROLMode} from '../hooks/customHooks'; + +// Create wrapper with all required contexts +const AllContextsWrapper = ({children}) => { + const settingsValue = { + settings: {ignored_directories: []}, + fetchSettings: jest.fn(), + }; + const filePreviewValue = { + setPreviewFile: jest.fn(), + setCallbacks: jest.fn(), + }; + + return ( + + + {children} + + + ); +}; + +// Custom render with all contexts +const renderFileBrowser = (ui, options = {}) => { + return renderWithProviders( + {ui}, + options + ); +}; + +describe('FileBrowser', () => { + // Note: Files are sorted alphabetically by path, so file.txt comes before testdir/ + const mockBrowseFiles = [ + {path: 'file.txt', size: 1024}, + {path: 'testdir/', children: null, is_empty: true}, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + useBrowseFiles.mockReturnValue({ + browseFiles: mockBrowseFiles, + openFolders: [], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + useWROLMode.mockReturnValue(false); + }); + + describe('Directory Selection', () => { + it('renders without crashing', async () => { + renderFileBrowser(); + // Should render the file browser table + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('handles selecting and unselecting a directory without error', async () => { + renderFileBrowser(); + + // Find the directory - text includes trailing slash + const directoryRow = screen.getByText('testdir/'); + expect(directoryRow).toBeInTheDocument(); + + // Find the checkbox in the same row - file.txt is first (index 0), testdir/ is second (index 1) + const checkboxes = screen.getAllByRole('checkbox'); + const directoryCheckbox = checkboxes[1]; // Second checkbox is for the directory + + // Select the directory + await act(async () => { + fireEvent.click(directoryCheckbox); + }); + + // Unselect the directory - this should NOT throw an error + await act(async () => { + fireEvent.click(directoryCheckbox); + }); + + // If we get here without error, the test passes + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('upload button is enabled when nothing is selected', async () => { + renderFileBrowser(); + + // Find the upload button (green button with upload icon) + const buttons = screen.getAllByRole('button'); + const uploadButton = buttons.find(btn => + btn.classList.contains('green') || btn.querySelector('[class*="upload"]') + ); + + // Button should exist and not be disabled + expect(uploadButton).toBeInTheDocument(); + expect(uploadButton).not.toBeDisabled(); + }); + + it('upload button is enabled when single directory is selected', async () => { + renderFileBrowser(); + + // Select the directory (second checkbox - testdir/) + const checkboxes = screen.getAllByRole('checkbox'); + const directoryCheckbox = checkboxes[1]; + + await act(async () => { + fireEvent.click(directoryCheckbox); + }); + + // Find the upload button + const buttons = screen.getAllByRole('button'); + const uploadButton = buttons.find(btn => btn.classList.contains('green')); + + expect(uploadButton).not.toBeDisabled(); + }); + + it('upload button is disabled when file is selected', async () => { + renderFileBrowser(); + + // Select the file (first checkbox - file.txt) + const checkboxes = screen.getAllByRole('checkbox'); + const fileCheckbox = checkboxes[0]; + + await act(async () => { + fireEvent.click(fileCheckbox); + }); + + // Find the upload button + const buttons = screen.getAllByRole('button'); + const uploadButton = buttons.find(btn => btn.classList.contains('green')); + + expect(uploadButton).toBeDisabled(); + }); + }); +}); diff --git a/app/src/components/FilePreview.js b/app/src/components/FilePreview.js index f4ac8d86..0eb493b9 100644 --- a/app/src/components/FilePreview.js +++ b/app/src/components/FilePreview.js @@ -11,6 +11,7 @@ import {toast} from "react-semantic-toasts-2"; import {useOneQuery} from "../hooks/customHooks"; import {ShareButton} from "./Share"; import {pathDirectory} from "./FileBrowser"; +import {InlineErrorBoundary} from "./ErrorBoundary"; function getMediaPathURL(previewFile) { if (previewFile['primary_path']) { @@ -116,11 +117,13 @@ function getSTLPreviewModal(previewFile) { return {name} - + + + }
{errorMessage}
{statusString} {name}