ui: Common.js and thirteen more files off Semantic

Phase 2, part 3.  Baseline 63 -> 50.

Common.js is the one that mattered: 51 files import it, so its Semantic usage
was propping up the rest of the migration.  Everything in it now comes from the
library -- Paginator, the two search inputs, DirectorySearch, TabLinks, the
toggles, the messages, APIButton, Breadcrumbs, SimpleAccordion, IframeViewer,
CardPoster, FileIcon, LoadStatistic, the theme menu, and the hotspot icons.

Also migrated: Placeholder, Apps, ArchivePreview, CbzViewer, AddToPlaylist,
TaggedDeleteConfirmModal, the collection edit/tag forms, and four inventory
components.

New in the library, all in the gallery and tested:

  SearchBox     Semantic's `<Search category>` with its grouped results shape
                kept, because four call sites already build it.  A real
                combobox rather than a div that listens for clicks.
  Pagination    Semantic's prop names; onPageChange gets the page number.
  TabBar        the strip only -- routing stays with the caller.
  IconStack     Semantic's IconGroup, named once for assistive technology.
  Table.HeaderCell sorted/onSort -- a real button carrying aria-sort.
  Button/IconButton translate Semantic's size names.

That last one is defensive: unmigrated callers still pass size='tiny', and
Mantine drops a size it does not recognise without a word, so the button
quietly renders at the wrong size.  Mapping here keeps the mixed state honest.

Three real bugs found and fixed on the way, none of them cosmetic:

  * `fileSuffixIconName` returned fourteen icon names the map did not have, so
    opening a folder with a PDF, a Word document or an archive in it logged an
    error and drew a fallback glyph.  All fourteen mapped, with a test that
    walks the mapper's own return values, and another asserting every mapped
    name resolves to a Tabler component that exists.
  * `Statistic` relied on Semantic's `Statistic.Group` for its spacing, so on
    the dashboard the load averages ran together into "1 MIN. LOAD5 MIN.
    LOAD15 MIN. LOAD".  It carries its own spacing now.
  * Semantic's inverted stylesheet colors every descendant of an inverted
    segment, so Toggle labels inherited #dddddd -- white pixels in night mode,
    on the Settings page, which is exactly where someone adjusts their WROLPi
    in the dark.  Restated from tokens.

The baseline ratchet gained a third assertion: a migrated file may still lean
on Semantic's CSS classes without importing the library, which the import check
cannot see and which would collapse silently when Phase 4 deletes the
stylesheet.  There is one such bridge today and it is now declared.
This commit is contained in:
Roland 2026-07-30 10:00:48 -06:00
parent d359eea2d3
commit 6ea9d3645f
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
35 changed files with 978 additions and 1095 deletions

View file

@ -1,8 +1,5 @@
import React, {useEffect, useState} from "react";
import {Input, Message} from "semantic-ui-react";
import {toast} from "react-semantic-toasts-2";
import {Button, Form, List, Loader, Modal} from "./Theme";
import {Button, IconButton, Loading, Message, Modal, Radio, Stack, TextInput, toast} from "./ui";
import {addPlaylistItem, createPlaylist, fetchPlaylists} from "../api";
@ -68,32 +65,38 @@ export function AddToPlaylistModal({open, onClose, items = [], onComplete}) {
return <Modal open={open} onClose={onClose} size='tiny'>
<Modal.Header>Add to Playlist</Modal.Header>
<Modal.Content>
{playlists === undefined && <Message error><Message.Header>Could not load playlists</Message.Header></Message>}
{playlists === null && <Loader active inline='centered'/>}
{playlists === undefined && <Message kind='error' title='Could not load playlists'/>}
{playlists === null && <Loading/>}
{Array.isArray(playlists) && playlists.length > 0 &&
<List divided selection>
{playlists.map(p => <List.Item key={p.id} active={selected === p.id}
onClick={() => setSelected(p.id)}>
<List.Icon name={selected === p.id ? 'check circle' : 'circle outline'}
verticalAlign='middle'/>
<List.Content><List.Header>{p.name}</List.Header></List.Content>
</List.Item>)}
</List>}
<Stack gap={4}>
{playlists.map(p => <Radio
key={p.id}
name='playlist'
label={p.name}
checked={selected === p.id}
onChange={() => setSelected(p.id)}
/>)}
</Stack>}
{Array.isArray(playlists) && playlists.length === 0 &&
<p>No playlists yet — create one below.</p>}
<Form onSubmit={handleCreateAndAdd} style={{marginTop: '1em'}}>
<Form.Field>
<label>Or create a new playlist</label>
<Input autoFocus placeholder='New playlist name...' value={newName}
onChange={(e, {value}) => setNewName(value)}/>
</Form.Field>
</Form>
<form onSubmit={(e) => {
e.preventDefault();
handleCreateAndAdd();
}} style={{marginTop: '1em'}}>
<TextInput
label='Or create a new playlist'
autoFocus
placeholder='New playlist name...'
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
/>
</form>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button role='cancel' onClick={onClose}>Cancel</Button>
{creating
? <Button primary loading={adding} onClick={handleCreateAndAdd}>Create &amp; Add</Button>
: <Button primary disabled={!selected} loading={adding} onClick={handleAddExisting}>Add</Button>}
? <Button role='primary' loading={adding} onClick={handleCreateAndAdd}>Create &amp; Add</Button>
: <Button role='primary' disabled={!selected} loading={adding} onClick={handleAddExisting}>Add</Button>}
</Modal.Actions>
</Modal>;
}
@ -104,9 +107,12 @@ export function AddToPlaylistModal({open, onClose, items = [], onComplete}) {
* - one FileGroup (fileGroupId) or many (fileGroupIds),
* - a Zim article (zim={zimId, entry, title}), or
* - a URL (url={url, title}) — link to anything the WROLPi browser can open (e.g. a map location).
* Extra props are forwarded to the Button (color, size, disabled, icon, content, ...).
* Extra props are forwarded to the Button (size, disabled, ...).
*/
export function AddToPlaylistButton({fileGroupId, fileGroupIds, zim, url, content = 'Add to Playlist', onComplete, ...buttonProps}) {
export function AddToPlaylistButton({
fileGroupId, fileGroupIds, zim, url, content = 'Add to Playlist',
title, onComplete, ...buttonProps
}) {
const [open, setOpen] = useState(false);
let items;
@ -119,13 +125,16 @@ export function AddToPlaylistButton({fileGroupId, fileGroupIds, zim, url, conten
items = ids.map(id => ({item_kind: 'file', file_group_id: id}));
}
// An empty `content` makes a compact icon-only button (omit content entirely so Semantic does
// not reserve label padding); otherwise render the labeled "Add to Playlist" button.
const contentProps = content ? {content} : {};
// An empty/null `content` makes a compact icon-only button; the accessible name comes
// from `title` (kept for callers that already pass it) or falls back to the action name.
const iconOnly = !content;
return <>
<Button color='green' icon='list' onClick={() => setOpen(true)}
disabled={items.length === 0} {...contentProps} {...buttonProps}/>
{iconOnly
? <IconButton role='save' icon='list' label={title || 'Add to Playlist'} onClick={() => setOpen(true)}
disabled={items.length === 0} {...buttonProps}/>
: <Button role='save' icon='list' onClick={() => setOpen(true)}
disabled={items.length === 0} {...buttonProps}>{content}</Button>}
<AddToPlaylistModal open={open} onClose={() => setOpen(false)} items={items}
onComplete={onComplete}/>
</>;

View file

@ -1,5 +1,4 @@
import React, {useContext} from 'react';
import {Input, Label, StatisticLabel, StatisticValue} from "semantic-ui-react";
import React from 'react';
import {Link, Route, Routes} from "react-router";
import {
ErrorMessage,
@ -10,16 +9,13 @@ import {
toLocaleString,
useTitle
} from "./Common";
import {ThemeContext} from "../contexts/contexts";
import {Header, Loader, Segment, Statistic} from "./Theme";
import {Header, Label, Loading, Panel, Statistic, StatisticGroup, TextInput} from "./ui";
import {useStatistics} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
function StatisticsPage() {
useTitle('Statistics');
const {s} = useContext(ThemeContext);
const {statistics} = useStatistics();
if (statistics === undefined) {
@ -49,90 +45,56 @@ function StatisticsPage() {
return <>
<Header as='h1'>Statistics</Header>
<Header as='h2'>Files</Header>
<Segment>
<Statistic.Group>
<Statistic>
<StatisticValue>{toLocaleString(total_count)}</StatisticValue>
<StatisticLabel>All Files</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanFileSize(total_size)}</StatisticValue>
<StatisticLabel>Total Size</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Segment>
<Statistic.Group size='small'>
<Panel>
<StatisticGroup>
<Statistic value={toLocaleString(total_count)} label='All Files'/>
<Statistic value={humanFileSize(total_size)} label='Total Size'/>
</StatisticGroup>
</Panel>
<Panel>
<StatisticGroup>
<Link to={'/videos/statistics'}>
<Statistic color={mimetypeColor('video/')}>
<StatisticValue>{toLocaleString(video_count)}</StatisticValue>
<StatisticLabel>Videos</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('video/')} value={toLocaleString(video_count)}
label='Videos'/>
</Link>
<Statistic color={mimetypeColor('application/pdf')}>
<StatisticValue>{toLocaleString(pdf_count)}</StatisticValue>
<StatisticLabel>PDFs</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('application/epub')}>
<StatisticValue>{toLocaleString(ebook_count)}</StatisticValue>
<StatisticLabel>eBooks</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('text/html')}>
<StatisticValue>{toLocaleString(archive_count)}</StatisticValue>
<StatisticLabel>Archives</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('image/')}>
<StatisticValue>{toLocaleString(image_count)}</StatisticValue>
<StatisticLabel>Images</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Segment>
<Statistic.Group size='tiny'>
<Statistic>
<StatisticValue>{toLocaleString(zip_count)}</StatisticValue>
<StatisticLabel>ZIP</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('audio/')}>
<StatisticValue>{toLocaleString(audio_count)}</StatisticValue>
<StatisticLabel>Audio</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Statistic color={mimetypeColor('application/pdf')} value={toLocaleString(pdf_count)}
label='PDFs'/>
<Statistic color={mimetypeColor('application/epub')} value={toLocaleString(ebook_count)}
label='eBooks'/>
<Statistic color={mimetypeColor('text/html')} value={toLocaleString(archive_count)}
label='Archives'/>
<Statistic color={mimetypeColor('image/')} value={toLocaleString(image_count)}
label='Images'/>
</StatisticGroup>
</Panel>
<Panel>
<StatisticGroup>
<Statistic value={toLocaleString(zip_count)} label='ZIP'/>
<Statistic color={mimetypeColor('audio/')} value={toLocaleString(audio_count)} label='Audio'/>
</StatisticGroup>
</Panel>
<Header as='h2'>Tags</Header>
<Segment>
<Statistic.Group>
<Statistic>
<StatisticValue>{humanNumber(tags_count)}</StatisticValue>
<StatisticLabel>Tags</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanNumber(tagged_files)}</StatisticValue>
<StatisticLabel>Tagged Files</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanNumber(tagged_zims)}</StatisticValue>
<StatisticLabel>Tagged Zims</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Panel>
<StatisticGroup>
<Statistic value={humanNumber(tags_count)} label='Tags'/>
<Statistic value={humanNumber(tagged_files)} label='Tagged Files'/>
<Statistic value={humanNumber(tagged_zims)} label='Tagged Zims'/>
</StatisticGroup>
</Panel>
<Header as='h2'>Database</Header>
<Segment>
<Statistic.Group>
<Statistic>
<StatisticValue>{humanFileSize(db_size)}</StatisticValue>
<StatisticLabel>Size</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Panel>
<StatisticGroup>
<Statistic value={humanFileSize(db_size)} label='Size'/>
</StatisticGroup>
</Panel>
</>;
}
return <>
<Header as='h1'>Statistics</Header>
<Segment><Loader inline active/></Segment>
<Panel><Loading/></Panel>
</>;
}
@ -146,7 +108,16 @@ export function MoreRoute(props) {
</PageContainer>
}
export function ColoredInput({name, value, label, color, ...props}) {
label = label ? <Label color={color}>{label}</Label> : null;
return <Input value={value} name={name} label={label} {...props}/>
/**
* A text input with a colored tag attached to its leading (or trailing) edge.
* Replaces Semantic's `<Input label={<Label color={...}/>} labelPosition=.../>`.
*/
export function ColoredInput({name, value, label, color, labelPosition = 'left', fluid, style, ...props}) {
const labelNode = label ? <Label color={color || 'grey'}>{label}</Label> : null;
return <div style={{display: 'flex', alignItems: 'stretch', gap: 6, width: fluid ? '100%' : undefined, ...style}}>
{labelNode && labelPosition === 'left' && labelNode}
<TextInput name={name} value={value} style={{flex: fluid ? 1 : undefined}} {...props}/>
{labelNode && labelPosition !== 'left' && labelNode}
</div>
}

View file

@ -1,8 +1,7 @@
import React from "react";
import {Icon, Loader, Message, Placeholder, PlaceholderLine} from "semantic-ui-react";
import {downloadArchiveMember, getArchiveContents} from "../api";
import {fileSuffixIconName, humanFileSize} from "./Common";
import {Modal, Segment, Table} from "./Theme";
import {Icon, Message, Modal, Panel, Placeholder, Table} from "./ui";
function ArchiveEntry({entry, depth, archivePath}) {
const [expanded, setExpanded] = React.useState(depth === 0);
@ -19,9 +18,9 @@ function ArchiveEntry({entry, depth, archivePath}) {
<Table.Cell className='file-path'>
{indent}
<Icon name={expanded ? 'folder open' : 'folder'}/>
{entry.name}/
{' '}{entry.name}/
</Table.Cell>
<Table.Cell collapsing textAlign='right'>
<Table.Cell numeric>
&mdash;
</Table.Cell>
</Table.Row>
@ -37,11 +36,11 @@ function ArchiveEntry({entry, depth, archivePath}) {
<Table.Cell className='file-path' onClick={handleDownload} style={{cursor: 'pointer'}}>
{indent}
<Icon name={fileSuffixIconName(entry.name)}/>
{entry.name}
{' '}{entry.name}
{' '}
<Icon name='download' size='small' color='grey'/>
<Icon name='download' size='small'/>
</Table.Cell>
<Table.Cell collapsing textAlign='right'>
<Table.Cell numeric>
{humanFileSize(entry.size)}
</Table.Cell>
</Table.Row>
@ -52,7 +51,7 @@ function SkeletonRows() {
{[1, 2, 3].map(i =>
<Table.Row key={i}>
<Table.Cell colSpan={2}>
<Placeholder><PlaceholderLine/></Placeholder>
<Placeholder lines={1}/>
</Table.Cell>
</Table.Row>
)}
@ -88,24 +87,24 @@ export function ArchivePreviewContent({previewFile}) {
if (error) {
return <Modal.Content>
<Message negative>
<Message.Header>Cannot read archive</Message.Header>
<Message kind='error' title='Cannot read archive'>
<p>{error}</p>
</Message>
</Modal.Content>
}
return <Modal.Content scrolling>
{!loading && contents && <Segment>
{!loading && contents && <Panel>
<Icon name='archive'/>
{' '}
<strong>{contents.total_files}</strong> files,{' '}
<strong>{humanFileSize(contents.total_size)}</strong> total
</Segment>}
<Table striped selectable unstackable>
</Panel>}
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell collapsing textAlign='right'>Size</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>

View file

@ -1,7 +1,7 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {IconArrowLeft, IconArrowRight} from '@tabler/icons-react';
import {getArchiveContents, getArchiveMemberUrl} from '../api';
import {Icon, Label, Loader, Message} from 'semantic-ui-react';
import {Button} from './Theme';
import {IconButton, Label, Loading, Message} from './ui';
import {useLocalStorage} from './Common';
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i;
@ -77,15 +77,13 @@ export function CbzViewer({path}) {
}, [onLeftClick, onRightClick]);
if (error) {
return <Message negative>
<Message.Header>Cannot load comic</Message.Header>
return <Message kind='error' title='Cannot load comic'>
<p>{error}</p>
</Message>;
}
if (!pages) return <Loader active inline='centered'/>;
if (!pages) return <Loading/>;
if (pages.length === 0) {
return <Message warning>
<Message.Header>No images found</Message.Header>
return <Message kind='warning' title='No images found'>
<p>This archive does not contain any image files.</p>
</Message>;
}
@ -94,12 +92,12 @@ export function CbzViewer({path}) {
const hasNext = currentPage < pages.length - 1;
return <div style={{marginBottom: '1em'}}>
<div ref={imageContainerRef} style={{
<div ref={imageContainerRef} className='media' style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: '#222',
borderRadius: fullscreen ? 0 : '4px',
background: 'var(--bg)',
borderRadius: 0,
padding: '0.5em',
minHeight: '300px',
position: fullscreen ? 'fixed' : 'relative',
@ -133,20 +131,14 @@ export function CbzViewer({path}) {
gap: '0.5em',
marginTop: '0.5em',
}}>
<Button icon basic active={rtl} onClick={() => setRtl(r => !r)}
aria-label='Reading direction'>
<Icon name={rtl ? 'hand point left' : 'hand point right'}/>
</Button>
<IconButton icon={rtl ? IconArrowLeft : IconArrowRight} label='Reading direction'
variant={rtl ? 'filled' : 'default'} onClick={() => setRtl(r => !r)}/>
<span style={{flex: 1}}/>
<Button icon basic disabled={rtl ? !hasNext : !hasPrev}
onClick={onLeftClick} aria-label='Previous page'>
<Icon name='chevron left'/>
</Button>
<IconButton icon='chevron left' label='Previous page'
disabled={rtl ? !hasNext : !hasPrev} onClick={onLeftClick}/>
<Label>{currentPage + 1} / {pages.length}</Label>
<Button icon basic disabled={rtl ? !hasPrev : !hasNext}
onClick={onRightClick} aria-label='Next page'>
<Icon name='chevron right'/>
</Button>
<IconButton icon='chevron right' label='Next page'
disabled={rtl ? !hasPrev : !hasNext} onClick={onRightClick}/>
</div>
</div>;
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import React from 'react';
import {act, render, screen, waitFor} from '@testing-library/react';
import {act, render, screen, waitFor} from '../test-utils';
import userEvent from '@testing-library/user-event';
import {DirectorySearch} from './Common';
@ -123,13 +123,14 @@ describe('DirectorySearch', () => {
});
it('displays with required indicator', () => {
const {container} = render(
render(
<DirectorySearch onSelect={mockOnSelect} value="" required/>
);
// Semantic UI doesn't add required attribute to Search input,
// but we verify the prop is passed
expect(container.querySelector('.ui.search')).toBeInTheDocument();
// Unlike Semantic UI's Search, the SearchBox forwards `required` straight
// onto the input.
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toBeRequired();
});
});
@ -153,11 +154,10 @@ describe('DirectorySearch', () => {
loading: true,
});
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const searchContainer = screen.getByPlaceholderText(/search directory names/i)
.closest('.ui.search');
expect(searchContainer).toHaveClass('loading');
// SearchBox renders a Loader alongside the input while `loading` is true.
expect(container.querySelector('.wrolpi-searchbox-loading')).toBeInTheDocument();
});
it('hides loading indicator when loading state is false', () => {
@ -166,11 +166,9 @@ describe('DirectorySearch', () => {
loading: false,
});
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const searchContainer = screen.getByPlaceholderText(/search directory names/i)
.closest('.ui.search');
expect(searchContainer).not.toHaveClass('loading');
expect(container.querySelector('.wrolpi-searchbox-loading')).not.toBeInTheDocument();
});
it('displays categorized results', async () => {
@ -265,16 +263,16 @@ describe('DirectorySearch', () => {
});
// Render with empty value prop (different from directoryName)
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
// Find the Search component container and trigger blur on it
const searchComponent = container.querySelector('.ui.search');
// The SearchBox wires `onBlur` straight to the input (there is no longer a
// wrapping Search component to blur instead).
const input = screen.getByPlaceholderText(/search directory names/i);
// Blur the Search component - should trigger onBlur which calls onSelect
// Blur the input - should trigger onBlur which calls onSelect
await act(async () => {
// Use fireEvent.blur which better simulates the Semantic UI Search blur behavior
const {fireEvent} = require('@testing-library/react');
fireEvent.blur(searchComponent);
const {fireEvent} = require('../test-utils');
fireEvent.blur(input);
});
// Should call onSelect with directoryName from hook state
@ -319,8 +317,16 @@ describe('DirectorySearch', () => {
expect(screen.getByText('videos/nature')).toBeInTheDocument();
});
// Click multiple results in succession
// Click multiple results in succession. Choosing a result closes the
// dropdown (as any combobox would) without moving focus off the input
// (the option is chosen on mousedown, with the default prevented), so
// pressing a key that reopens the list -- same as a real user -- is
// needed before the next selection.
await userEvent.click(screen.getByText('videos/nature'));
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => {
expect(screen.getByText('videos/tech')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('videos/tech'));
// Should call onSelect for each selection

View file

@ -1,5 +1,5 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import {render, screen} from '../test-utils';
import userEvent from '@testing-library/user-event';
import {DesktopToggle} from './Common';
@ -34,7 +34,9 @@ describe('DesktopToggle', () => {
await userEvent.click(screen.getByTestId('toggle'));
// Not stopped yet; the Confirm modal is shown instead.
expect(mockSetDesktop).not.toHaveBeenCalled();
expect(screen.getByText('Stop the desktop')).toBeInTheDocument();
// Mantine's Modal mounts its content on a subsequent render pass, so the
// text isn't present synchronously after the click.
expect(await screen.findByText('Stop the desktop')).toBeInTheDocument();
await userEvent.click(screen.getByText('Stop'));
expect(mockSetDesktop).toHaveBeenCalledWith(false);
@ -43,7 +45,7 @@ describe('DesktopToggle', () => {
it('cancelling the confirmation leaves the desktop running', async () => {
render(<DesktopToggle/>);
await userEvent.click(screen.getByTestId('toggle'));
await userEvent.click(screen.getByText('Cancel'));
await userEvent.click(await screen.findByText('Cancel'));
expect(mockSetDesktop).not.toHaveBeenCalled();
expect(screen.getByTestId('toggle')).toBeChecked();
});

View file

@ -409,7 +409,7 @@ describe('FileBrowser', () => {
});
// Click delete button (red trash button) - uses APIButton which has confirm modal
const deleteButton = screen.getAllByRole('button').find(btn => btn.classList.contains('red'));
const deleteButton = document.querySelector('.tabler-icon-trash').closest('button');
await act(async () => {
fireEvent.click(deleteButton);
});
@ -454,7 +454,7 @@ describe('FileBrowser', () => {
});
// Click delete button
const deleteButton = screen.getAllByRole('button').find(btn => btn.classList.contains('red'));
const deleteButton = document.querySelector('.tabler-icon-trash').closest('button');
await act(async () => {
fireEvent.click(deleteButton);
});

View file

@ -1,61 +1,38 @@
import {CardContent, CardGroup, PlaceholderImage, PlaceholderLine} from "semantic-ui-react";
import React, {useContext} from "react";
import {ThemeContext} from "../contexts/contexts";
import {Card, Placeholder} from "./Theme";
import React from "react";
import {Card, CardGroup, Placeholder, Skeleton} from "./ui";
export function CardPlaceholder() {
const {s} = useContext(ThemeContext);
return <Card>
<Placeholder>
<PlaceholderImage rectangular/>
</Placeholder>
<CardContent {...s}>
<Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
</CardContent>
return <Card media={<Skeleton height={140} radius={0} animate/>}>
<Placeholder lines={3}/>
</Card>
}
export function VideoPlaceholder() {
return <CardGroup doubling stackable>
return <CardGroup>
<CardPlaceholder/>
</CardGroup>
}
export function ChannelPlaceholder() {
return <Placeholder>
<PlaceholderLine length='long'/>
<PlaceholderLine length='short'/>
</Placeholder>
return <Placeholder lines={2}/>
}
export function ProgressPlaceholder() {
return <Placeholder style={{marginBottom: '1em'}}>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <div style={{marginBottom: '1em'}}>
<Placeholder lines={2}/>
</div>
}
export function TextPlaceholder() {
return <Placeholder style={{marginBottom: '1em'}}>
<PlaceholderLine/>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <div style={{marginBottom: '1em'}}>
<Placeholder lines={3}/>
</div>
}
export function TagPlaceholder() {
return <Placeholder style={{height: 30, width: 80}}>
<PlaceholderImage/>
</Placeholder>
return <Skeleton height={30} width={80} radius={0} animate/>
}
export function TableRowPlaceholder() {
return <Placeholder>
<PlaceholderLine/>
</Placeholder>
}
return <Placeholder lines={1}/>
}

View file

@ -1,12 +1,15 @@
import React from 'react';
import {fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router';
import {fireEvent, render, screen} from '../test-utils';
import {SearchFilterButton} from './Files';
import {QueryContext, ThemeContext} from '../contexts/contexts';
import {QueryContext} from '../contexts/contexts';
// Render SearchFilterButton with a controlled QueryContext so the search hooks
// (useSearchFilter/useSearchOrder/useSearchDate/useSearch) can read the URL
// query params without a real router/provider.
//
// `render` here comes from `../test-utils`, which already supplies MantineProvider
// (needed by components under src/components/ui, e.g. the Toggle/Switch) and a
// BrowserRouter, plus a default ThemeContext -- so only QueryContext needs wiring here.
function renderButton(props = {}, initialParams = {}, onUpdateQuery = null) {
const params = new URLSearchParams(initialParams);
@ -29,17 +32,10 @@ function renderButton(props = {}, initialParams = {}, onUpdateQuery = null) {
});
setSearchParams(next);
};
const theme = {
i: {}, s: {}, t: {}, theme: 'light', inverted: false, setInverted: () => {},
};
return (
<MemoryRouter>
<ThemeContext.Provider value={theme}>
<QueryContext.Provider value={{searchParams, updateQuery, getLocationStr: () => '/'}}>
{children}
</QueryContext.Provider>
</ThemeContext.Provider>
</MemoryRouter>
<QueryContext.Provider value={{searchParams, updateQuery, getLocationStr: () => '/'}}>
{children}
</QueryContext.Provider>
);
}
@ -126,7 +122,7 @@ describe('SearchFilterButton', () => {
fireEvent.click(screen.getByText('Filter'));
// The Availability section/toggle is shown.
expect(screen.getByText('Availability')).toBeInTheDocument();
fireEvent.mouseUp(screen.getByTestId('toggle'));
fireEvent.click(screen.getByTestId('toggle'));
// Draft only — nothing applied yet.
expect(spy).not.toHaveBeenCalled();
@ -156,7 +152,7 @@ describe('SearchFilterButton', () => {
fireEvent.click(screen.getByText('Filter'));
// The Search Depth section/toggle is shown.
expect(screen.getByText('Search Depth')).toBeInTheDocument();
fireEvent.mouseUp(screen.getByTestId('toggle'));
fireEvent.click(screen.getByTestId('toggle'));
// Draft only — nothing applied yet.
expect(spy).not.toHaveBeenCalled();

View file

@ -1,34 +1,31 @@
import React from "react";
import {Button, Icon, Modal, Table} from "./Theme";
import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Confirm, Icon, Table} from "./ui";
export function TaggedDeleteConfirmModal({open, taggedFileGroups, onConfirm, onCancel}) {
return <Modal open={open} onClose={onCancel} closeIcon>
<Modal.Header><Icon name='warning sign'/> Tagged Files Will Be Deleted</Modal.Header>
<Modal.Content>
<p>The following tagged files will be deleted:</p>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>File</TableHeaderCell>
<TableHeaderCell>Tags</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{(taggedFileGroups || []).map((fg, idx) => (
<TableRow key={fg.id ?? idx}>
<TableCell>{fg.primary_path || fg.name}</TableCell>
<TableCell>{(fg.tags || []).join(', ')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Modal.Content>
<Modal.Actions>
<Button onClick={onCancel}>Cancel</Button>
<Button color='red' onClick={onConfirm}>
<Icon name='trash'/> Delete
</Button>
</Modal.Actions>
</Modal>;
return <Confirm
open={open}
title={<><Icon name='warning sign'/> Tagged Files Will Be Deleted</>}
confirmLabel='Delete'
destructive
onConfirm={onConfirm}
onCancel={onCancel}
>
<p>The following tagged files will be deleted:</p>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell>Tags</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{(taggedFileGroups || []).map((fg, idx) => (
<Table.Row key={fg.id ?? idx}>
<Table.Cell>{fg.primary_path || fg.name}</Table.Cell>
<Table.Cell>{(fg.tags || []).join(', ')}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Confirm>;
}

View file

@ -1,5 +1,5 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import {render, screen} from '../test-utils';
import userEvent from '@testing-library/user-event';
import {VncToggle} from './Common';

View file

@ -1,5 +1,5 @@
import React from "react";
import {fireEvent, render, screen, within} from "@testing-library/react";
import {fireEvent, render, screen, within} from "../../test-utils";
import {
dailyCalorieDemand, daysOfFood, findCaloriesKey, findCountKey, RationEstimatePanel, totalCalories,
} from "./RationCalculator";

View file

@ -1,6 +1,5 @@
import React from 'react';
import {Button, Form, Grid, Message} from 'semantic-ui-react';
import {Header, Segment} from '../Theme';
import {Button, Header, Message, Panel} from '../ui';
import {TagsContext} from '../../Tags';
import {WROLModeMessage} from '../Common';
@ -37,51 +36,63 @@ export function CollectionEditForm({
}
};
return <Segment>
return <Panel>
{title && <Header as="h1">{title}</Header>}
{wrolModeContent && <WROLModeMessage content={wrolModeContent}/>}
{/* Display form-level errors */}
{form.error && <Message error>
<Message.Header>Error</Message.Header>
{form.error && <Message kind='error' title='Error'>
<p>{form.error}</p>
</Message>}
<Form onSubmit={handleSubmit} loading={form.loading} autoComplete="off">
<Grid stackable>
<form onSubmit={handleSubmit} autoComplete="off">
{/*
* `ui stackable grid` replicates Semantic's own Grid CSS classes (still loaded
* globally via semantic-ui-offline while other call sites remain unmigrated).
* Callers of this component (Channels.js, Archive.js, Playlists.js) still pass
* Semantic <Grid.Row>/<Grid.Column> children -- they have not migrated to the
* component library yet. This div keeps their layout without importing
* semantic-ui-react here. Replace with the real `Grid`/`Grid.Col` once every
* caller has migrated.
*/}
<div
className="ui stackable grid"
style={form.loading ? {opacity: 0.6, pointerEvents: 'none'} : undefined}
>
{children}
{appliedTagName && <Grid.Row>
<Grid.Column>
{appliedTagName && <div className="row">
<div className="column">
<SingleTag name={appliedTagName}/>
</Grid.Column>
</Grid.Row>}
</div>
</div>}
<Grid.Row columns={2}>
<Grid.Column>
<div className="two column row">
<div className="column">
{actionButtons}
{onCancel && <Button
type='button'
role='cancel'
onClick={onCancel}
disabled={form.disabled}
className="action-button-spacing"
>
Cancel
</Button>}
</Grid.Column>
<Grid.Column>
</div>
<div className="column">
<Button
type='submit'
color='violet'
size='big'
floated='right'
role='save'
size='lg'
style={{float: 'right'}}
disabled={form.disabled}
>
Save
</Button>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
</Segment>;
</div>
</div>
</div>
</form>
</Panel>;
}

View file

@ -2,7 +2,6 @@ import React from 'react';
import {
createMockDomain,
createTestForm,
hasInvertedStyling,
render,
renderInDarkMode,
renderInLightMode,
@ -210,7 +209,11 @@ describe('CollectionEditForm', () => {
});
describe('Theme Integration', () => {
it('applies inverted styling to Segment in dark mode', () => {
// Panel/Header are token-driven (see ui/Surfaces.tsx): their colors come from CSS
// variables scoped on `[data-theme]`, not from JS branching on the theme. There is
// no more "inverted" prop/class to assert on -- just that the same markup renders
// in both modes.
it('renders a token-driven Panel in dark mode', () => {
const form = createTestForm(mockCollection);
const {container} = renderInDarkMode(
@ -219,12 +222,10 @@ describe('CollectionEditForm', () => {
</CollectionEditForm>
);
const segment = container.querySelector('.ui.segment');
expect(segment).toBeInTheDocument();
expect(hasInvertedStyling(segment)).toBe(true);
expect(container.querySelector('.wrolpi-panel')).toBeInTheDocument();
});
it('does not apply inverted styling in light mode', () => {
it('renders a token-driven Panel in light mode', () => {
const form = createTestForm(mockCollection);
const {container} = renderInLightMode(
@ -233,12 +234,10 @@ describe('CollectionEditForm', () => {
</CollectionEditForm>
);
const segment = container.querySelector('.ui.segment');
expect(segment).toBeInTheDocument();
expect(hasInvertedStyling(segment)).toBe(false);
expect(container.querySelector('.wrolpi-panel')).toBeInTheDocument();
});
it('applies dark theme styling to Header in dark mode', () => {
it('renders a token-driven Header for the title', () => {
const form = createTestForm(mockCollection);
const {container} = renderInDarkMode(
@ -247,9 +246,9 @@ describe('CollectionEditForm', () => {
</CollectionEditForm>
);
const header = container.querySelector('.ui.header');
const header = container.querySelector('.wrolpi-header');
expect(header).toBeInTheDocument();
expect(header.style.color).toBe('rgb(238, 238, 238)'); // #eeeeee
expect(header).toHaveTextContent('Test Collection');
});
});

View file

@ -1,7 +1,5 @@
import React, {useCallback, useEffect, useState} from 'react';
import {Grid, Input} from 'semantic-ui-react';
import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
import {Button, Modal} from '../Theme';
import {Button, Message, Modal, Stack, TextInput} from '../ui';
import {TagsSelector} from '../../Tags';
import {APIButton, Toggle} from '../Common';
@ -107,54 +105,36 @@ export function CollectionTagModal({
>
<Modal.Header>{modalTitle}</Modal.Header>
<Modal.Content>
<Grid columns={1}>
<Grid.Row>
<Grid.Column>
<TagsSelector
limit={1}
selectedTagNames={newTagName ? [newTagName] : []}
onAdd={handleTagChange}
onRemove={() => handleTagChange(null)}
/>
</Grid.Column>
</Grid.Row>
<Stack>
<TagsSelector
limit={1}
selectedTagNames={newTagName ? [newTagName] : []}
onAdd={handleTagChange}
onRemove={() => handleTagChange(null)}
/>
{hasDirectory && (
<Grid.Row>
<Grid.Column>
<Toggle
label='Move to directory: '
checked={moveToTagDirectory}
onChange={setMoveToTagDirectory}
/>
</Grid.Column>
</Grid.Row>
<Toggle
label='Move to directory: '
checked={moveToTagDirectory}
onChange={setMoveToTagDirectory}
/>
)}
{hasDirectory && (
<Grid.Row>
<Grid.Column>
<Input
fluid
value={newTagDirectory}
onChange={(e, {value}) => setNewTagDirectory(value)}
disabled={!moveToTagDirectory}
/>
</Grid.Column>
</Grid.Row>
<TextInput
value={newTagDirectory}
onChange={(e) => setNewTagDirectory(e.currentTarget.value)}
disabled={!moveToTagDirectory}
/>
)}
{hasDirectory && conflictMessage && (
<Grid.Row>
<Grid.Column>
<Message warning>
<Message.Header>Directory Conflict</Message.Header>
<p>{conflictMessage}</p>
</Message>
</Grid.Column>
</Grid.Row>
<Message kind='warning' title='Directory Conflict'>
<p>{conflictMessage}</p>
</Message>
)}
</Grid>
</Stack>
</Modal.Content>
<Modal.Actions>
<Button onClick={handleClose}>Cancel</Button>
<Button role='cancel' onClick={handleClose}>Cancel</Button>
<APIButton
color='violet'
onClick={handleSave}

View file

@ -3,31 +3,6 @@ import {act, render, screen, waitFor} from '../../test-utils';
import userEvent from '@testing-library/user-event';
import {CollectionTagModal} from './CollectionTagModal';
// Mock Theme components with compound patterns
jest.mock('../Theme', () => {
const MockModal = ({open, onClose, children, closeIcon}) => {
if (!open) return null;
return (
<div data-testid="modal">
{closeIcon && <button data-testid="close-icon" onClick={onClose}>×</button>}
{children}
</div>
);
};
MockModal.Header = ({children}) => <div data-testid="modal-header">{children}</div>;
MockModal.Content = ({children}) => <div data-testid="modal-content">{children}</div>;
MockModal.Actions = ({children}) => <div data-testid="modal-actions">{children}</div>;
MockModal.Description = ({children}) => <div data-testid="modal-description">{children}</div>;
return {
...jest.requireActual('../Theme'),
Modal: MockModal,
Button: ({children, onClick, ...props}) => (
<button onClick={onClick} data-testid={`button-${children}`} {...props}>{children}</button>
),
};
});
// Mock Common components
jest.mock('../Common', () => ({
...jest.requireActual('../Common'),
@ -76,22 +51,22 @@ describe('CollectionTagModal', () => {
describe('Modal Rendering', () => {
it('renders modal when open is true', () => {
render(<CollectionTagModal {...defaultProps} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('does not render modal when open is false', () => {
render(<CollectionTagModal {...defaultProps} open={false}/>);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('shows "Add Tag" header when no current tag', () => {
render(<CollectionTagModal {...defaultProps} currentTagName={null}/>);
expect(screen.getByTestId('modal-header')).toHaveTextContent('Add Tag');
expect(screen.getByRole('heading')).toHaveTextContent('Add Tag');
});
it('shows "Modify Tag" header when there is a current tag', () => {
render(<CollectionTagModal {...defaultProps} currentTagName="existing-tag"/>);
expect(screen.getByTestId('modal-header')).toHaveTextContent('Modify Tag');
expect(screen.getByRole('heading')).toHaveTextContent('Modify Tag');
});
});
@ -308,7 +283,7 @@ describe('CollectionTagModal', () => {
const mockOnClose = jest.fn();
render(<CollectionTagModal {...defaultProps} onClose={mockOnClose}/>);
const cancelButton = screen.getByTestId('button-Cancel');
const cancelButton = screen.getByRole('button', {name: 'Cancel'});
await userEvent.click(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
@ -341,7 +316,7 @@ describe('CollectionTagModal', () => {
});
// Close the modal
const cancelButton = screen.getByTestId('button-Cancel');
const cancelButton = screen.getByRole('button', {name: 'Cancel'});
await userEvent.click(cancelButton);
// Reopen modal

View file

@ -340,10 +340,9 @@ describe('ConflictResolutionModal', () => {
// Confirming should re-call deleteFileGroups with force=true.
api.deleteFileGroups.mockResolvedValueOnce({});
// The TaggedDeleteConfirmModal's Delete button has the trash icon.
const confirmButton = screen.getAllByRole('button')
.find(b => b.classList.contains('red') && b.textContent.includes('Delete')
&& b.querySelector('.trash.icon'));
// The TaggedDeleteConfirmModal (a Confirm) names its destructive action "Delete",
// distinct from the "Delete this file" icon buttons queried above.
const confirmButton = screen.getByRole('button', {name: 'Delete'});
fireEvent.click(confirmButton);
await waitFor(() => {

View file

@ -1,18 +1,16 @@
import React, {useState} from "react";
import {Input, Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Header, Icon, Modal, Table} from "../Theme";
import {Button, IconButton, Modal, Select, Table, Text, TextInput} from "../ui";
import {ALL_UNITS} from "./units";
import {ThemeContext} from "../../contexts/contexts";
const UNIT_OPTIONS = [{key: '', value: '', text: '—'}, ...ALL_UNITS.map(u => ({key: u, value: u, text: u}))];
const UNIT_OPTIONS = [{value: '', label: '—'}, ...ALL_UNITS.map(u => ({value: u, label: u}))];
const COLUMNS = [
{key: 'name', label: 'Name', width: 4},
{key: 'category', label: 'Category', width: 3},
{key: 'subcategory', label: 'Subcategory', width: 3},
{key: 'item_size', label: 'Size', width: 2},
{key: 'item_size_unit', label: 'Unit', width: 2},
{key: 'calories', label: 'kcal', width: 2},
{key: 'name', label: 'Name'},
{key: 'category', label: 'Category'},
{key: 'subcategory', label: 'Subcategory'},
{key: 'item_size', label: 'Size'},
{key: 'item_size_unit', label: 'Unit'},
{key: 'calories', label: 'kcal'},
];
function blankEntry() {
@ -25,7 +23,6 @@ function blankEntry() {
*/
export function CatalogEditor({catalog, open, onClose, onSave}) {
const [draft, setDraft] = useState([]);
const {t} = React.useContext(ThemeContext);
React.useEffect(() => {
if (open) {
@ -45,66 +42,66 @@ export function CatalogEditor({catalog, open, onClose, onSave}) {
onClose();
};
return <Modal open={open} onClose={onClose} closeIcon size='fullscreen'>
return <Modal open={open} onClose={onClose} size='fullscreen'>
<Modal.Header>Food Catalog</Modal.Header>
<Modal.Content scrolling>
<p {...t} style={{...t.style, opacity: 0.8}}>
<Modal.Content>
<Text size='sm' c='dimmed' mb='sm'>
Known items used to autocomplete and pre-fill the entry form. <strong>kcal</strong> is the total
calories for one package of the given size.
</p>
<Button onClick={add} style={{marginBottom: '0.5em'}}><Icon name='plus'/>Add Item</Button>
<Table celled unstackable compact>
<TableHeader>
<TableRow>
{COLUMNS.map(c => <TableHeaderCell key={c.key} width={c.width}>{c.label}</TableHeaderCell>)}
<TableHeaderCell collapsing/>
</TableRow>
</TableHeader>
<TableBody>
</Text>
<Button onClick={add} icon='plus' style={{marginBottom: '0.5em'}}>Add Item</Button>
<Table>
<Table.Header>
<Table.Row>
{COLUMNS.map(c => <Table.HeaderCell key={c.key}>{c.label}</Table.HeaderCell>)}
<Table.HeaderCell/>
</Table.Row>
</Table.Header>
<Table.Body>
{draft.map((entry, index) => {
const aria = (col) => `${entry.name || 'new item'} ${col}`;
return <TableRow key={entry.id ?? `new-${index}`}>
<TableCell>
<Input fluid name='catalog-name' aria-label={aria('name')} value={entry.name || ''}
placeholder='Name' onChange={e => update(index, 'name', e.target.value)}/>
</TableCell>
<TableCell>
<Input fluid name='catalog-category' aria-label={aria('category')}
return <Table.Row key={entry.id ?? `new-${index}`}>
<Table.Cell>
<TextInput name='catalog-name' aria-label={aria('name')} value={entry.name || ''}
placeholder='Name' onChange={e => update(index, 'name', e.currentTarget.value)}/>
</Table.Cell>
<Table.Cell>
<TextInput name='catalog-category' aria-label={aria('category')}
value={entry.category || ''}
onChange={e => update(index, 'category', e.target.value)}/>
</TableCell>
<TableCell>
<Input fluid name='catalog-subcategory' aria-label={aria('subcategory')}
onChange={e => update(index, 'category', e.currentTarget.value)}/>
</Table.Cell>
<Table.Cell>
<TextInput name='catalog-subcategory' aria-label={aria('subcategory')}
value={entry.subcategory || ''}
onChange={e => update(index, 'subcategory', e.target.value)}/>
</TableCell>
<TableCell>
<Input fluid type='number' name='catalog-size' aria-label={aria('size')}
onChange={e => update(index, 'subcategory', e.currentTarget.value)}/>
</Table.Cell>
<Table.Cell>
<TextInput type='number' name='catalog-size' aria-label={aria('size')}
value={entry.item_size || ''}
onChange={e => update(index, 'item_size', e.target.value)}/>
</TableCell>
<TableCell>
<Select compact search name='catalog-unit' aria-label={aria('unit')} options={UNIT_OPTIONS}
onChange={e => update(index, 'item_size', e.currentTarget.value)}/>
</Table.Cell>
<Table.Cell>
<Select searchable name='catalog-unit' aria-label={aria('unit')} data={UNIT_OPTIONS}
value={entry.item_size_unit || ''}
onChange={(e, data) => update(index, 'item_size_unit', data.value)}/>
</TableCell>
<TableCell>
<Input fluid type='number' name='catalog-calories' aria-label={aria('calories')}
onChange={value => update(index, 'item_size_unit', value)}/>
</Table.Cell>
<Table.Cell>
<TextInput type='number' name='catalog-calories' aria-label={aria('calories')}
value={entry.calories || ''}
onChange={e => update(index, 'calories', e.target.value)}/>
</TableCell>
<TableCell collapsing>
<Button color='red' icon size='mini' aria-label='Remove'
onClick={() => remove(index)}><Icon name='trash'/></Button>
</TableCell>
</TableRow>;
onChange={e => update(index, 'calories', e.currentTarget.value)}/>
</Table.Cell>
<Table.Cell>
<IconButton role='danger' icon='trash' label='Remove'
onClick={() => remove(index)}/>
</Table.Cell>
</Table.Row>;
})}
</TableBody>
</Table.Body>
</Table>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button primary onClick={save}>Save Catalog</Button>
<Button role='cancel' onClick={onClose}>Cancel</Button>
<Button role='save' onClick={save}>Save Catalog</Button>
</Modal.Actions>
</Modal>;
}

View file

@ -1,11 +1,9 @@
import React from "react";
import {Select} from "semantic-ui-react";
import {Button, Header, Icon, Segment} from "../Theme";
import {Button, Group, Header, Panel, Select, Text} from "../ui";
import {downloadCSV, inventoryExportFilename, toCSV} from "./inventoryExport";
import {groupFieldsOf, summableFieldsOf} from "./summarize";
import {ThemeContext} from "../../contexts/contexts";
const fieldOptions = (fs) => fs.map(f => ({key: f.key, value: f.key, text: f.label}));
const fieldOptions = (fs) => fs.map(f => ({value: f.key, label: f.label}));
/**
* The "Export" tab: explains each export format and provides the action to produce it. CSV is the raw items table,
@ -16,47 +14,46 @@ export function InventoryExportPanel({name, fields, items, groupKey, sumKey, onG
const count = (items || []).length;
const groupFields = groupFieldsOf(fields);
const summableFields = summableFieldsOf(fields);
const {t} = React.useContext(ThemeContext);
const exportCSV = () =>
downloadCSV(inventoryExportFilename(name, 'csv'), toCSV(fields, items));
return <>
<Segment>
<Header as='h3'><Icon name='file alternate outline'/> CSV</Header>
<p {...t}>
<Panel>
<Header as='h3' icon='file alternate outline'>CSV</Header>
<Text>
A comma-separated spreadsheet of all {count} item{count === 1 ? '' : 's'} and every field, openable
in Excel, Numbers, or Google Sheets. Generated entirely on this device.
</p>
<Button primary onClick={exportCSV} disabled={count === 0}>
<Icon name='download'/> Download CSV
</Text>
<Button role='primary' onClick={exportCSV} disabled={count === 0} icon='download'>
Download CSV
</Button>
</Segment>
</Panel>
<Segment>
<Header as='h3'><Icon name='file pdf outline'/> PDF</Header>
<p {...t}>
<Panel>
<Header as='h3' icon='file pdf outline'>PDF</Header>
<Text>
A printable table of the whole inventory, followed by a summary grouped by the field you choose.
This opens your browser's print dialog — choose "Save as PDF" as the destination to get a PDF file,
or print it on paper.
</p>
</Text>
{groupFields.length > 0 &&
<div style={{marginBottom: '1em', display: 'flex', gap: '1em', flexWrap: 'wrap'}}>
<Group mb='1em' gap='1em' wrap='wrap'>
<span>
Summary group:{' '}
<Select options={fieldOptions(groupFields)} value={groupKey || ''}
onChange={(e, data) => onGroupKey(data.value)}/>
<Select data={fieldOptions(groupFields)} value={groupKey || ''}
onChange={value => onGroupKey(value)}/>
</span>
{summableFields.length > 0 &&
<span>
Sum:{' '}
<Select clearable options={fieldOptions(summableFields)} value={sumKey || ''}
onChange={(e, data) => onSumKey(data.value || undefined)}/>
<Select clearable data={fieldOptions(summableFields)} value={sumKey || ''}
onChange={value => onSumKey(value || undefined)}/>
</span>}
</div>}
<Button onClick={() => window.print()} disabled={count === 0}>
<Icon name='print'/> Print / Save as PDF
</Group>}
<Button onClick={() => window.print()} disabled={count === 0} icon='print'>
Print / Save as PDF
</Button>
</Segment>
</Panel>
</>;
}

View file

@ -1,8 +1,6 @@
import React from "react";
import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Icon, Table} from "../Theme";
import {Icon, Table, Text} from "../ui";
import {formatValue, isItemExpired} from "./InventoryTable";
import {ThemeContext} from "../../contexts/contexts";
// Fallback columns for inventories whose schema predates the per-field `mobile` flag: these preferred keys in
// order, else the first few fields.
@ -31,33 +29,32 @@ export function mobileColumns(fields) {
*/
export function InventoryItemsMobile({fields, items}) {
const columns = mobileColumns(fields);
const {t} = React.useContext(ThemeContext);
return <>
<p {...t} style={{...t.style, opacity: 0.7, fontSize: '0.85em', marginBottom: '0.5em'}}>
<Text size='sm' c='dimmed' mb='0.5em'>
Read-only — rotate to landscape to edit.
</p>
</Text>
{(items || []).length === 0
? <p {...t}>No items yet.</p>
: <Table celled unstackable>
<TableHeader>
<TableRow>
{columns.map(f => <TableHeaderCell key={f.key}>{f.label}</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
? <Text>No items yet.</Text>
: <Table>
<Table.Header>
<Table.Row>
{columns.map(f => <Table.HeaderCell key={f.key}>{f.label}</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{items.map(item => {
const expired = isItemExpired(item, fields);
return <TableRow key={item.id} negative={expired}>
{columns.map((f, idx) => <TableCell key={f.key}>
return <Table.Row key={item.id} failed={expired}>
{columns.map((f, idx) => <Table.Cell key={f.key}>
{idx === 0 && expired &&
<Icon name='warning sign' color='red' title='Expired'
style={{marginRight: '0.4em'}}/>}
<Icon name='warning sign' label='Expired'
style={{color: 'var(--red)', marginRight: '0.4em'}}/>}
{formatValue(item, f)}
</TableCell>)}
</TableRow>;
</Table.Cell>)}
</Table.Row>;
})}
</TableBody>
</Table.Body>
</Table>}
</>;
}

View file

@ -1,5 +1,6 @@
import React from "react";
import {render, screen} from "@testing-library/react";
import {screen} from "@testing-library/react";
import {renderWithProviders} from "../../test-utils";
import {InventoryItemsMobile} from "./InventoryItemsMobile";
const FIELDS = [
@ -19,7 +20,7 @@ describe('InventoryItemsMobile', () => {
{key: 'count', label: 'Count', type: 'number', order: 2, mobile: true},
{key: 'expiration_date', label: 'Expires', type: 'date', order: 3, mobile: true},
];
render(<InventoryItemsMobile fields={flagged} items={[{id: 1, name: 'Rice'}]}/>);
renderWithProviders(<InventoryItemsMobile fields={flagged} items={[{id: 1, name: 'Rice'}]}/>);
const headers = [...document.querySelectorAll('thead th')].map(th => th.textContent);
expect(headers).toEqual(['Name', 'Count', 'Expires']);
@ -28,7 +29,7 @@ describe('InventoryItemsMobile', () => {
});
test('falls back to default columns when no field is flagged mobile', () => {
render(<InventoryItemsMobile fields={FIELDS} items={[{id: 1, name: 'Rice'}]}/>);
renderWithProviders(<InventoryItemsMobile fields={FIELDS} items={[{id: 1, name: 'Rice'}]}/>);
const headers = [...document.querySelectorAll('thead th')].map(th => th.textContent);
expect(headers).toEqual(['Name', 'Sub-category', 'Size', 'Count']);
@ -44,7 +45,7 @@ describe('InventoryItemsMobile', () => {
{key: 'purchase_date', label: 'Purchased', type: 'date', order: 3},
{key: 'location', label: 'Location', type: 'location', order: 4},
];
render(<InventoryItemsMobile fields={fields} items={[{id: 1, fuel_type: 'Diesel'}]}/>);
renderWithProviders(<InventoryItemsMobile fields={fields} items={[{id: 1, fuel_type: 'Diesel'}]}/>);
const headers = [...document.querySelectorAll('thead th')].map(th => th.textContent);
expect(headers).toEqual(['Fuel', 'Container', 'Volume', 'Purchased']);
});
@ -56,17 +57,17 @@ describe('InventoryItemsMobile', () => {
{id: 2, name: 'Fresh', subcategory: 'beans', item_size: '10', item_size_unit: 'lb', count: '4',
expiration_date: '2999-01-01'},
];
const {container} = render(<InventoryItemsMobile fields={FIELDS} items={items}/>);
renderWithProviders(<InventoryItemsMobile fields={FIELDS} items={items}/>);
expect(screen.getByText('5 lb')).toBeTruthy();
const rowOf = (name) => screen.getByText(name).closest('tr');
expect(rowOf('Old').className).toContain('negative');
expect(rowOf('Fresh').className).not.toContain('negative');
expect(container.querySelectorAll('i.warning.sign.icon').length).toBe(1);
expect(rowOf('Old').className).toContain('wrolpi-row-failed');
expect(rowOf('Fresh').className).not.toContain('wrolpi-row-failed');
expect(screen.getAllByLabelText('Expired').length).toBe(1);
});
test('shows an empty state when there are no items', () => {
render(<InventoryItemsMobile fields={FIELDS} items={[]}/>);
renderWithProviders(<InventoryItemsMobile fields={FIELDS} items={[]}/>);
expect(screen.getByText('No items yet.')).toBeTruthy();
});
});

View file

@ -1,11 +1,9 @@
import React, {useMemo, useState} from "react";
import {Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Header, Table} from "../Theme";
import {Group, Header, Select, Table, Text} from "../ui";
import {
defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey, groupFieldsOf, sortSummaryRows,
summableFieldsOf, summarizeInventory,
} from "./summarize";
import {ThemeContext} from "../../contexts/contexts";
// Aggregate the inventory client-side: group items by a chosen text/select field and sum a chosen quantity, number,
// or calories field. The grouping math lives in summarize.js (shared with the PDF export). The ration estimate
@ -15,7 +13,6 @@ export function InventorySummary({fields, items}) {
const summableFields = summableFieldsOf(fields);
const caloriesKey = findCaloriesKey(fields);
const countKey = findCountKey(fields);
const {t} = React.useContext(ThemeContext);
// Default to grouping by Category (easier to see grains/dairy/etc.), falling back to the first group field.
const [groupKey, setGroupKey] = useState(defaultGroupKey(fields));
@ -35,50 +32,53 @@ export function InventorySummary({fields, items}) {
const sortDir = (key) => sort.key === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined;
if (!items || items.length === 0) {
return <p {...t}>No items to summarize yet.</p>;
return <Text>No items to summarize yet.</Text>;
}
const fieldOptions = (fs) => fs.map(f => ({key: f.key, value: f.key, text: f.label}));
const fieldOptions = (fs) => fs.map(f => ({value: f.key, label: f.label}));
return <>
<Header as='h3'>Summary</Header>
<div style={{marginBottom: '1em', display: 'flex', gap: '1em', flexWrap: 'wrap'}}>
<span {...t}>
<Group mb='1em' gap='1em' wrap='wrap'>
<span>
Group by:{' '}
<Select options={fieldOptions(groupFields)} value={groupKey}
onChange={(e, data) => setGroupKey(data.value)}/>
<Select data={fieldOptions(groupFields)} value={groupKey}
onChange={value => setGroupKey(value)}/>
</span>
{summableFields.length > 0 &&
<span>
Sum:{' '}
<Select clearable options={fieldOptions(summableFields)} value={sumKey || ''}
onChange={(e, data) => setSumKey(data.value || undefined)}/>
<Select clearable data={fieldOptions(summableFields)} value={sumKey || ''}
onChange={value => setSumKey(value || undefined)}/>
</span>}
</div>
<Table celled unstackable sortable>
<TableHeader>
<TableRow>
<TableHeaderCell sorted={sortDir('name')} onClick={() => toggleSort('name')}
style={{cursor: 'pointer'}}>
</Group>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell sorted={sortDir('name')} onSort={() => toggleSort('name')}>
{groupFields.find(f => f.key === groupKey)?.label || 'Group'}
</TableHeaderCell>
<TableHeaderCell sorted={sortDir('count')} onClick={() => toggleSort('count')}
style={{cursor: 'pointer'}}>Items</TableHeaderCell>
{sumKey && <TableHeaderCell sorted={sortDir('total')} onClick={() => toggleSort('total')}
style={{cursor: 'pointer'}}>Total</TableHeaderCell>}
{caloriesKey && <TableHeaderCell sorted={sortDir('calories')} onClick={() => toggleSort('calories')}
style={{cursor: 'pointer'}}>Calories</TableHeaderCell>}
</TableRow>
</TableHeader>
<TableBody>
{rows.map(row => <TableRow key={row.name}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.count}</TableCell>
{sumKey && <TableCell>{row.total}</TableCell>}
</Table.HeaderCell>
<Table.HeaderCell sorted={sortDir('count')} onSort={() => toggleSort('count')}>
Items
</Table.HeaderCell>
{sumKey && <Table.HeaderCell sorted={sortDir('total')} onSort={() => toggleSort('total')}>
Total
</Table.HeaderCell>}
{caloriesKey && <Table.HeaderCell sorted={sortDir('calories')}
onSort={() => toggleSort('calories')}>
Calories
</Table.HeaderCell>}
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map(row => <Table.Row key={row.name}>
<Table.Cell>{row.name}</Table.Cell>
<Table.Cell>{row.count}</Table.Cell>
{sumKey && <Table.Cell>{row.total}</Table.Cell>}
{caloriesKey &&
<TableCell>{row.calories > 0 ? `${row.calories.toLocaleString()} kcal` : '—'}</TableCell>}
</TableRow>)}
</TableBody>
<Table.Cell>{row.calories > 0 ? `${row.calories.toLocaleString()} kcal` : '—'}</Table.Cell>}
</Table.Row>)}
</Table.Body>
</Table>
</>;
}

View file

@ -1,5 +1,6 @@
import React from "react";
import {fireEvent, render, screen} from "@testing-library/react";
import {fireEvent, screen} from "@testing-library/react";
import {renderWithProviders} from "../../test-utils";
import {InventorySummary} from "./InventorySummary";
// Group by the first text field (category); two groups with different item counts and size totals.
@ -19,12 +20,12 @@ const groupOrder = () =>
describe('InventorySummary sortable columns', () => {
test('defaults to group name ascending', () => {
render(<InventorySummary fields={FIELDS} items={ITEMS}/>);
renderWithProviders(<InventorySummary fields={FIELDS} items={ITEMS}/>);
expect(groupOrder()).toEqual(['beans', 'grains']);
});
test('sorting by Items uses the numeric count', () => {
render(<InventorySummary fields={FIELDS} items={ITEMS}/>);
renderWithProviders(<InventorySummary fields={FIELDS} items={ITEMS}/>);
fireEvent.click(screen.getByText('Items')); // first click -> descending
expect(groupOrder()).toEqual(['grains', 'beans']); // grains has 2 items, beans 1
fireEvent.click(screen.getByText('Items')); // toggle -> ascending
@ -32,7 +33,7 @@ describe('InventorySummary sortable columns', () => {
});
test('sorting by Total uses the summed magnitude, not the formatted string', () => {
render(<InventorySummary fields={FIELDS} items={ITEMS}/>);
renderWithProviders(<InventorySummary fields={FIELDS} items={ITEMS}/>);
fireEvent.click(screen.getByText('Total')); // descending
expect(groupOrder()).toEqual(['beans', 'grains']); // beans 100 lb > grains 30 lb
});

View file

@ -39,6 +39,30 @@ const renderIcon = (icon?: string | React.ComponentType<any>) => {
return typeof icon === 'string' ? <Icon name={icon}/> : <Icon component={icon}/>;
}
/*
* Semantic's size names, mapped onto Mantine's scale.
*
* Unmigrated call sites still pass `size='tiny'`, and Mantine silently ignores a
* size it does not know — the button renders at the default size and nobody sees
* a warning. Translating here keeps those call sites looking right until they
* migrate, and costs nothing once they have.
*/
const semanticSizes: Record<string, string> = {
mini: 'xs',
tiny: 'xs',
small: 'sm',
medium: 'md',
large: 'lg',
big: 'lg',
huge: 'xl',
massive: 'xl',
};
// Generic so it passes each component's own size type straight through; only a
// string that Semantic knew about is rewritten.
export const resolveSize = <T, >(size: T): T =>
typeof size === 'string' ? ((semanticSizes[size] ?? size) as T) : size;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((
{role, icon, iconAfter, className, ...props}, ref
) => {
@ -52,6 +76,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>((
leftSection={renderIcon(icon)}
rightSection={renderIcon(iconAfter)}
{...props}
size={resolveSize(props.size)}
/>
});
Button.displayName = 'Button';
@ -78,6 +103,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((
variant={props.variant ?? fromRole?.variant ?? 'default'}
className={[fromRole?.className, className].filter(Boolean).join(' ') || undefined}
{...props}
size={resolveSize(props.size)}
>
{renderIcon(icon)}
</ActionIcon>

View file

@ -25,6 +25,7 @@ const sizes: Record<IconSize, number> = {small: 16, medium: 20, large: 24};
*/
const semanticNames: Record<string, keyof typeof Tabler> = {
'add': 'IconPlus',
'apple': 'IconBrandApple',
'archive': 'IconArchive',
'arrow alternate circle up': 'IconCircleArrowUp',
'arrow down': 'IconArrowDown',
@ -35,6 +36,7 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'bug': 'IconBug',
'calculator': 'IconCalculator',
'calendar': 'IconCalendar',
'certificate': 'IconCertificate',
'charset': 'IconLanguage',
'check': 'IconCheck',
'check circle': 'IconCircleCheck',
@ -46,11 +48,13 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'circle notch': 'IconLoader2',
'circle notched': 'IconLoader2',
'close': 'IconX',
'closed captioning': 'IconBadgeCc',
'columns': 'IconColumns',
'copy': 'IconCopy',
'cube': 'IconCube',
'directory': 'IconFolder',
'disk': 'IconDatabase',
'dot circle': 'IconCircleDot',
'download': 'IconDownload',
'dropdown': 'IconChevronDown',
'edit': 'IconEdit',
@ -63,10 +67,16 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'file': 'IconFile',
'file alternate': 'IconFileDescription',
'file alternate outline': 'IconFileDescription',
'file archive': 'IconFileZip',
'file audio': 'IconFileMusic',
'file code': 'IconFileCode',
'file excel': 'IconFileSpreadsheet',
'file pdf': 'IconFileTypePdf',
'file pdf outline': 'IconFileTypePdf',
'file powerpoint': 'IconPresentation',
'file text': 'IconFileText',
'file video': 'IconFileSmile',
'file word': 'IconFileText',
'film': 'IconMovie',
'filter': 'IconFilter',
'firefox': 'IconBrandFirefox',
@ -74,6 +84,7 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'folder open': 'IconFolderOpen',
'folder open outline': 'IconFolderOpen',
'folder outline': 'IconFolder',
'font': 'IconTypography',
'globe': 'IconWorld',
'hand point right': 'IconHandFinger',
'hdd': 'IconDeviceSdCard',
@ -84,14 +95,16 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'lightbulb': 'IconBulb',
'lightbulb outline': 'IconBulb',
'lightning': 'IconBolt',
'moon': 'IconMoon',
'moon outline': 'IconMoon',
'linkify': 'IconLink',
'linux': 'IconBrandUbuntu',
'list': 'IconList',
'lock': 'IconLock',
'mail': 'IconMail',
'map outline': 'IconMap',
'microchip': 'IconCpu',
'minus': 'IconMinus',
'moon': 'IconMoon',
'moon outline': 'IconMoon',
'paypal': 'IconBrandPaypal',
'play': 'IconPlayerPlay',
'plug': 'IconPlug',
@ -131,6 +144,7 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'warning circle': 'IconAlertCircle',
'warning sign': 'IconAlertTriangle',
'wifi': 'IconWifi',
'windows': 'IconBrandWindows',
'wrench': 'IconTool',
'x': 'IconX',
};

View file

@ -68,10 +68,12 @@ export interface StatisticProps {
value: React.ReactNode;
label: React.ReactNode;
color?: string;
className?: string;
style?: React.CSSProperties;
}
export function Statistic({value, label, color}: StatisticProps) {
return <div>
export function Statistic({value, label, color, className, style}: StatisticProps) {
return <div className={['wrolpi-statistic', className].filter(Boolean).join(' ')} style={style}>
<div className='wrolpi-statistic-value' style={{color: color ? `var(--${color})` : undefined}}>
{value}
</div>
@ -89,6 +91,7 @@ export function StatisticGroup({children}: {children: React.ReactNode}) {
}}>
{items.map((child, index) => <div
key={index}
className='wrolpi-statistic-cell'
style={{
background: 'var(--panel)',
padding: '14px 16px',

View file

@ -368,6 +368,26 @@ html[data-theme="amber"] .wrolpi-searchbox-control:focus-within {
* non-red pixel on screen in night mode. Scoped to `:not(:checked)` so the
* checked track keeps coming from the `color` prop.
*/
/*
* Semantic's inverted stylesheet colors *every* descendant of `.ui.inverted.segment`
* (rgba(255,255,255,.9), and #dddddd on labels). Our components are mounted inside
* unmigrated pages that still use those wrappers, so an inherited color puts white
* pixels on screen in night mode. Restating from tokens is the fix; the rule can go
* when the last Semantic wrapper does.
*/
.wrolpi-switch .mantine-Switch-label {
color: var(--text);
}
.wrolpi-switch .mantine-Switch-description {
color: var(--muted);
}
.wrolpi-switch input:disabled + * .mantine-Switch-label,
.wrolpi-switch input:disabled ~ * .mantine-Switch-label {
color: var(--muted);
}
.wrolpi-switch input:not(:checked) + .mantine-Switch-track {
--switch-bg: var(--border);
}
@ -599,7 +619,60 @@ html[data-theme="amber"] .wrolpi-progress-fill {
border: 1.5px dashed var(--red);
}
/* ----------------------------------------------------------- Card posters */
/*
* The icon shown when a file has no poster, and the corner mark for a tagged
* file. Both live here rather than in Common.js so the card surface and the
* poster area agree in every theme.
*/
.wrolpi-card-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
background: var(--head);
}
.wrolpi-card-tag {
position: absolute;
top: 0;
left: 0;
display: flex;
padding: 3px 4px;
background: var(--green);
color: var(--btn-text);
}
/* Night and amber have no green to spend; the mark reads as an outline instead. */
html[data-theme="night"] .wrolpi-card-tag,
html[data-theme="amber"] .wrolpi-card-tag {
background: var(--panel);
color: var(--text);
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
/* ------------------------------------------------------------- Statistics */
/*
* A statistic carries its own spacing rather than relying on a parent to supply
* it: unmigrated pages still drop these inside Semantic's `Statistic.Group`,
* whose CSS only spaces elements carrying Semantic's own classes, and without
* this the labels run together into one unreadable line.
*/
.wrolpi-statistic {
display: inline-block;
vertical-align: top;
margin: 0 26px 10px 0;
}
/* Inside our own group the grid does the spacing, so the intrinsic margin goes. */
.wrolpi-statistic-cell .wrolpi-statistic {
display: block;
margin: 0;
}
.wrolpi-statistic-value {
font-size: 24px;

View file

@ -1,3 +1,5 @@
import fs from 'fs';
import path from 'path';
import React from 'react';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@ -78,6 +80,30 @@ describe('Icon', () => {
expect(unmapped.map(c => `${c.text}: ${c.icon}`)).toEqual([]);
});
it('maps every name the mimetype and file-suffix helpers can return', () => {
// These two build icon names from arbitrary files in the user's library, so an
// unmapped one is not hypothetical — it is a console error and a fallback glyph
// the moment somebody opens a folder containing that file type.
const common = fs.readFileSync(path.join(__dirname, '..', 'Common.js'), 'utf8');
const section = common.slice(
common.indexOf('export function mimetypeIconName'),
common.indexOf('export function FileIcon'));
const returned = [...new Set([...section.matchAll(/return '([^']+)'/g)].map(m => m[1]))];
expect(returned.length).toBeGreaterThan(10);
expect(returned.filter(name => !resolveIconName(name))).toEqual([]);
});
it('points every mapped name at a Tabler component that exists', () => {
// A typo in the map resolves to undefined and renders the fallback glyph, which
// looks like a design choice rather than a bug.
const source = fs.readFileSync(path.join(__dirname, 'Icon.tsx'), 'utf8');
const names = [...source.matchAll(/^ {4}'([^']+)': '([^']+)',$/gm)].map(m => m[1]);
expect(names.length).toBeGreaterThan(100);
expect(names.filter(name => !resolveIconName(name))).toEqual([]);
});
it('reports an unknown name instead of failing silently', () => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
@ -116,6 +142,16 @@ describe('Button', () => {
expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
});
it('translates Semantic size names, which Mantine would otherwise ignore', () => {
// Unmigrated call sites still pass size='tiny'. Mantine drops a size it does not
// recognise without warning, so the button quietly renders at the default size.
renderUI(<><Button size='tiny'>Old</Button><Button size='sm'>New</Button></>);
expect(screen.getByRole('button', {name: 'Old'})).toHaveAttribute('data-size', 'xs');
// A Mantine size still passes through untouched.
expect(screen.getByRole('button', {name: 'New'})).toHaveAttribute('data-size', 'sm');
});
it('gives icon-only buttons an accessible name', () => {
renderUI(<IconButton icon='trash' label='Delete channel'/>);

View file

@ -1,6 +1,6 @@
import React from 'react';
import {act, renderHook} from '@testing-library/react';
import {render, screen} from '@testing-library/react';
import {render, screen} from '../test-utils';
import {usePages, useDriveTemperature, useDriveHealth} from './customHooks';
import {QueryContext, StatusContext} from '../contexts/contexts';
import {Paginator} from '../components/Common';

View file

@ -1044,7 +1044,7 @@ describe('Form Components', () => {
/>
);
expect(screen.getByRole('checkbox')).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeInTheDocument();
});
});

View file

@ -1,6 +1,5 @@
import React from 'react';
import {render, screen, fireEvent, act} from '@testing-library/react';
import {MemoryRouter} from 'react-router';
import {render, screen, fireEvent, act} from '../test-utils';
import {KeyboardShortcutsContext} from '../contexts/KeyboardShortcutsContext';
import {KeyboardShortcutsProvider, SHORTCUTS} from '../components/KeyboardShortcutsProvider';
import {ThemeContext} from '../contexts/contexts';
@ -43,17 +42,15 @@ function TestWrapper({children}) {
};
return (
<MemoryRouter>
<ThemeContext.Provider value={mockThemeContext}>
<QueryContext.Provider value={mockQueryContext}>
<TagsContext.Provider value={mockTagsContext}>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</TagsContext.Provider>
</QueryContext.Provider>
</ThemeContext.Provider>
</MemoryRouter>
<ThemeContext.Provider value={mockThemeContext}>
<QueryContext.Provider value={mockQueryContext}>
<TagsContext.Provider value={mockTagsContext}>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</TagsContext.Provider>
</QueryContext.Provider>
</ThemeContext.Provider>
);
}

View file

@ -4,14 +4,9 @@
"src/Events.js",
"src/Tags.js",
"src/api.js",
"src/components/AddToPlaylist.js",
"src/components/Apps.js",
"src/components/Archive.js",
"src/components/ArchivePreview.js",
"src/components/BulkTagModal.js",
"src/components/CbzViewer.js",
"src/components/Channels.js",
"src/components/Common.js",
"src/components/DatesSelector.js",
"src/components/Docs.js",
"src/components/Download.js",
@ -24,10 +19,8 @@
"src/components/MapSearchView.js",
"src/components/MapViewer.js",
"src/components/Nav.js",
"src/components/Placeholder.js",
"src/components/Playlists.js",
"src/components/Search.js",
"src/components/TaggedDeleteConfirmModal.js",
"src/components/Theme.tsx",
"src/components/Upload.js",
"src/components/VideoPlayer.js",
@ -46,19 +39,13 @@
"src/components/calculators/RationCalculator.js",
"src/components/calculators/WaterCalculator.js",
"src/components/collections/BatchReorganizeModal.js",
"src/components/collections/CollectionEditForm.js",
"src/components/collections/CollectionReorganizeModal.js",
"src/components/collections/CollectionTable.js",
"src/components/collections/CollectionTagModal.js",
"src/components/collections/ConflictResolutionModal.js",
"src/components/inventory/CatalogEditor.js",
"src/components/inventory/FieldCell.js",
"src/components/inventory/FieldSchemaEditor.js",
"src/components/inventory/InventoryExportPanel.js",
"src/components/inventory/InventoryImportModal.js",
"src/components/inventory/InventoryItemsMobile.js",
"src/components/inventory/InventoryRoute.js",
"src/components/inventory/InventorySummary.js",
"src/components/inventory/InventoryTable.js",
"src/hooks/customHooks.js",
"src/hooks/useForm.js"

View file

@ -48,6 +48,23 @@ describe('Semantic UI removal', () => {
expect(stale).toEqual([]);
});
it('declares every migrated file that still leans on Semantic CSS classes', () => {
/*
* A file can stop importing semantic-ui-react and still depend on its stylesheet
* by writing `className="ui stackable grid"`. The import ratchet above cannot see
* that, so when Phase 4 deletes semantic.min.css the layout would silently
* collapse. Anything on this list is a deliberate bridge that must be revisited
* then; anything NOT on it is an accident.
*/
const allowed = ['src/components/collections/CollectionEditForm.js'];
const bridges = sourceFiles()
.filter(file => !baseline.includes(file))
.filter(file => /className=["']ui /.test(fs.readFileSync(path.join(SRC, '..', file), 'utf8')));
expect(bridges.sort()).toEqual(allowed.sort());
});
it('reports how much of the migration is left', () => {
// Not an assertion so much as a progress readout in the test output.
const remaining = importers().length;

View file

@ -52,6 +52,21 @@ global.IntersectionObserver = class IntersectionObserver {
}
};
// Mock ResizeObserver (used by Mantine's ScrollArea, e.g. inside Select/Combobox dropdowns).
global.ResizeObserver = class ResizeObserver {
constructor() {
}
disconnect() {
}
observe() {
}
unobserve() {
}
};
// Suppress console errors during tests (optional - uncomment if needed)
// const originalError = console.error;
// beforeAll(() => {