Adding Upload button to the FileBrowser. Adding ErrorBoundaries to prevent entire app from crashing.

This commit is contained in:
Roland 2025-12-25 09:14:00 -07:00
parent 166d6a47c3
commit 5c8f164c1a
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
7 changed files with 508 additions and 29 deletions

View file

@ -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() {
<header>
<NavBar/>
</header>
<Outlet/>
<ErrorBoundary>
<Outlet/>
</ErrorBoundary>
<Footer/>
</FilePreviewProvider>
</TagsProvider>
@ -73,19 +76,19 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
element={<Root/>}
errorElement={<PageNotFound/>}
>
<Route index element={<DashboardPage/>}/>
<Route path='search/*' element={<DashboardPage/>}/>
<Route index element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
<Route path='search/*' element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
<Route path='donate' element={<DonatePage/>}/>
<Route path='videos/video/:videoId' exact element={<VideoWrapper/>}/>
<Route path='videos/channel/:channelId/video/:videoId' exact element={<VideoWrapper/>}/>
<Route path="videos/*" element={<VideosRoute/>}/>
<Route path="admin/*" element={<AdminRoute/>}/>
<Route path="more/*" element={<MoreRoute/>}/>
<Route path="inventory/*" element={<InventoryRoute/>}/>
<Route path='archive/*' element={<ArchiveRoute/>}/>
<Route path='map/*' element={<MapRoute/>}/>
<Route path='zim/*' element={<ZimRoute/>}/>
<Route path='files/*' element={<FilesRoute/>}/>
<Route path='videos/video/:videoId' exact element={<ErrorBoundary><VideoWrapper/></ErrorBoundary>}/>
<Route path='videos/channel/:channelId/video/:videoId' exact element={<ErrorBoundary><VideoWrapper/></ErrorBoundary>}/>
<Route path="videos/*" element={<ErrorBoundary><VideosRoute/></ErrorBoundary>}/>
<Route path="admin/*" element={<ErrorBoundary><AdminRoute/></ErrorBoundary>}/>
<Route path="more/*" element={<ErrorBoundary><MoreRoute/></ErrorBoundary>}/>
<Route path="inventory/*" element={<ErrorBoundary><InventoryRoute/></ErrorBoundary>}/>
<Route path='archive/*' element={<ErrorBoundary><ArchiveRoute/></ErrorBoundary>}/>
<Route path='map/*' element={<ErrorBoundary><MapRoute/></ErrorBoundary>}/>
<Route path='zim/*' element={<ErrorBoundary><ZimRoute/></ErrorBoundary>}/>
<Route path='files/*' element={<ErrorBoundary><FilesRoute/></ErrorBoundary>}/>
</Route>));
export default function App() {

View file

@ -194,7 +194,7 @@ export function Getters() {
</Button>
</GridColumn>
<GridColumn>
<Button onClick={e => handleSetGetter(e, 'upload')}>
<Button color='green' onClick={e => handleSetGetter(e, 'upload')}>
<Icon name='upload'/>
Upload
</Button>

View file

@ -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() {
<Header as='h4'>Published: {publishedDatetimeString}</Header>
</GridColumn>
<GridColumn>
<Header as='h4'>Archived: {archivedDatetimeString}</Header>
<Header as='h4'>Downloaded: {downloadDatetimeString}</Header>
</GridColumn>
</GridRow>
</Grid>

View file

@ -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:
* <ErrorBoundary>
* <ComponentThatMightThrow />
* </ErrorBoundary>
*
* With custom fallback:
* <ErrorBoundary fallback={<div>Something went wrong</div>}>
* <ComponentThatMightThrow />
* </ErrorBoundary>
*/
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 (
<Container style={{padding: '2em'}}>
<Message negative icon>
<Icon name='warning sign'/>
<Message.Content>
<Message.Header>Something went wrong</Message.Header>
<p>{errorMessage}</p>
<Button.Group>
<Button onClick={this.handleReset} primary>
<Icon name='refresh'/> Try Again
</Button>
<Button onClick={this.handleReload}>
<Icon name='redo'/> Reload Page
</Button>
</Button.Group>
</Message.Content>
</Message>
</Container>
);
}
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 (
<Segment basic>
<Message negative size='small'>
<Icon name='warning'/> Failed to load this section
</Message>
</Segment>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
export {ErrorBoundary, InlineErrorBoundary};

View file

@ -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,
<Path key={k} path={p} onFolderClick={onFolderClick} sortData={sortData} onFileClick={onFileClick}
selectedPaths={selectedPaths} onSelect={onSelect} disabled={disabled}/>];
<InlineErrorBoundary key={k}>
<Path path={p} onFolderClick={onFolderClick} sortData={sortData} onFileClick={onFileClick}
selectedPaths={selectedPaths} onSelect={onSelect} disabled={disabled}/>
</InlineErrorBoundary>];
});
return <React.Fragment>
{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}
/>
<Button
icon='upload'
color='green'
disabled={wrolModeEnabled || (selectedPathsCount > 0 && !singleDirectorySelected)}
onClick={() => setUploadOpen(true)}
/>
<FileBrowserUploadModal
open={uploadOpen}
onClose={() => setUploadOpen(false)}
destination={singleDirectorySelected && selectedPaths[0] ? selectedPaths[0].slice(0, -1) : ''}
onComplete={reset}
/>
<Button
color='grey'
icon={isDirectoryIgnored ? 'eye' : 'eye slash'}
@ -564,3 +591,151 @@ export function MakeDirectoryModal({open, onClose, parent, onSubmit}) {
</Modal.Actions>
</Modal>
}
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 <Grid.Row key={name}>
<Grid.Column mobile={3} tablet={2} computer={1}>
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='big'/>
</Grid.Column>
<Grid.Column mobile={13} tablet={14} computer={15}>
<Progress progress indicating={indicating} percent={percent} color={color}>
<p {...t}>{statusString} {name}</p>
</Progress>
</Grid.Column>
</Grid.Row>
})
}
const displayPath = destination ? `${mediaDirectory}/${destination}` : mediaDirectory;
return <Modal closeIcon open={open} onClose={handleClose} size='large'>
<Modal.Header>Upload to: {displayPath}</Modal.Header>
<Modal.Content>
<Form>
{tagsSelector}
<div style={{marginTop: '1em'}}>
<Toggle
checked={overwrite}
label='Overwrite existing files'
onChange={() => setOverwrite(!overwrite)}
disabled={inProgress}
/>
</div>
</Form>
<Segment>
<Grid columns={1}>
<Grid.Row>
<Grid.Column style={{padding: '1em'}}>
<Form>
<div {...getRootProps()}>
<input {...getInputProps()}/>
<Grid textAlign='center'>
<Grid.Row>
<Grid.Column>
<Header icon>
<Icon name='file text'/>
Click here, or drop files here to upload
</Header>
</Grid.Column>
</Grid.Row>
</Grid>
</div>
</Form>
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>
<Grid>
<Grid.Row columns={1}>
<Grid.Column>
{progresses && Object.keys(progresses).length > 1 && <Progress progress
percent={overallProgress}
color='blue'
label='Overall Progress'
/>}
</Grid.Column>
</Grid.Row>
</Grid>
<Grid columns={2}>
{progressBars}
</Grid>
</Modal.Content>
<Modal.Actions>
<Button
onClick={handleClose}
disabled={inProgress}
>
Close
</Button>
</Modal.Actions>
</Modal>
}

View file

@ -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 (
<SettingsContext.Provider value={settingsValue}>
<FilePreviewContext.Provider value={filePreviewValue}>
{children}
</FilePreviewContext.Provider>
</SettingsContext.Provider>
);
};
// Custom render with all contexts
const renderFileBrowser = (ui, options = {}) => {
return renderWithProviders(
<AllContextsWrapper>{ui}</AllContextsWrapper>,
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(<FileBrowser/>);
// Should render the file browser table
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('handles selecting and unselecting a directory without error', async () => {
renderFileBrowser(<FileBrowser/>);
// 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(<FileBrowser/>);
// 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(<FileBrowser/>);
// 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(<FileBrowser/>);
// 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();
});
});
});

View file

@ -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 <React.Fragment>
<Modal.Header>{name}</Modal.Header>
<Modal.Content>
<StlViewer orbitControls shadows
url={url}
style={{height: '80vw', width: '80vw'}}
modelProps={{color: '#7353a8'}}
/>
<InlineErrorBoundary>
<StlViewer orbitControls shadows
url={url}
style={{height: '80vw', width: '80vw'}}
modelProps={{color: '#7353a8'}}
/>
</InlineErrorBoundary>
</Modal.Content>
</React.Fragment>
}