mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
d359eea2d3
commit
6ea9d3645f
35 changed files with 978 additions and 1095 deletions
|
|
@ -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 & Add</Button>
|
||||
: <Button primary disabled={!selected} loading={adding} onClick={handleAddExisting}>Add</Button>}
|
||||
? <Button role='primary' loading={adding} onClick={handleCreateAndAdd}>Create & 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}/>
|
||||
</>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
—
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'/>);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1044,7 +1044,7 @@ describe('Form Components', () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue