1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-26 22:56:23 -04:00

Refactor Series index to use react-window

This commit is contained in:
Mark McDowall
2023-01-05 18:20:49 -08:00
committed by Mark McDowall
parent de56862bb9
commit d022679b7d
92 changed files with 3527 additions and 4462 deletions
@@ -1,102 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import { icons } from 'Helpers/Props';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
class SeriesIndexActionsCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditSeriesModalOpen: false,
isDeleteSeriesModalOpen: false
};
}
//
// Listeners
onEditSeriesPress = () => {
this.setState({ isEditSeriesModalOpen: true });
};
onEditSeriesModalClose = () => {
this.setState({ isEditSeriesModalOpen: false });
};
onDeleteSeriesPress = () => {
this.setState({
isEditSeriesModalOpen: false,
isDeleteSeriesModalOpen: true
});
};
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteSeriesModalOpen: false });
};
//
// Render
render() {
const {
id,
isRefreshingSeries,
onRefreshSeriesPress,
...otherProps
} = this.props;
const {
isEditSeriesModalOpen,
isDeleteSeriesModalOpen
} = this.state;
return (
<VirtualTableRowCell
{...otherProps}
>
<SpinnerIconButton
name={icons.REFRESH}
title="Refresh series"
isSpinning={isRefreshingSeries}
onPress={onRefreshSeriesPress}
/>
<IconButton
name={icons.EDIT}
title="Edit Series"
onPress={this.onEditSeriesPress}
/>
<EditSeriesModalConnector
isOpen={isEditSeriesModalOpen}
seriesId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen}
seriesId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</VirtualTableRowCell>
);
}
}
SeriesIndexActionsCell.propTypes = {
id: PropTypes.number.isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired
};
export default SeriesIndexActionsCell;
@@ -1,86 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import IconButton from 'Components/Link/IconButton';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import { icons } from 'Helpers/Props';
import hasGrowableColumns from './hasGrowableColumns';
import SeriesIndexTableOptionsConnector from './SeriesIndexTableOptionsConnector';
import styles from './SeriesIndexHeader.css';
function SeriesIndexHeader(props) {
const {
showBanners,
columns,
onTableOptionChange,
...otherProps
} = props;
return (
<VirtualTableHeader>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
>
<TableOptionsModalWrapper
columns={columns}
optionsComponent={SeriesIndexTableOptionsConnector}
onTableOptionChange={onTableOptionChange}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
/>
</TableOptionsModalWrapper>
</VirtualTableHeaderCell>
);
}
return (
<VirtualTableHeaderCell
key={name}
className={classNames(
styles[name],
name === 'sortTitle' && showBanners && styles.banner,
name === 'sortTitle' && showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
)}
name={name}
isSortable={isSortable}
{...otherProps}
>
{label}
</VirtualTableHeaderCell>
);
})
}
</VirtualTableHeader>
);
}
SeriesIndexHeader.propTypes = {
showBanners: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default SeriesIndexHeader;
@@ -1,13 +0,0 @@
import { connect } from 'react-redux';
import { setSeriesTableOption } from 'Store/Actions/seriesIndexActions';
import SeriesIndexHeader from './SeriesIndexHeader';
function createMapDispatchToProps(dispatch, props) {
return {
onTableOptionChange(payload) {
dispatch(setSeriesTableOption(payload));
}
};
}
export default connect(undefined, createMapDispatchToProps)(SeriesIndexHeader);
@@ -1,563 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import HeartRating from 'Components/HeartRating';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import TagListConnector from 'Components/TagListConnector';
import { icons } from 'Helpers/Props';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import SeriesBanner from 'Series/SeriesBanner';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import formatBytes from 'Utilities/Number/formatBytes';
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
import titleCase from 'Utilities/String/titleCase';
import hasGrowableColumns from './hasGrowableColumns';
import SeriesStatusCell from './SeriesStatusCell';
import styles from './SeriesIndexRow.css';
class SeriesIndexRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
hasBannerError: false,
isEditSeriesModalOpen: false,
isDeleteSeriesModalOpen: false
};
}
onEditSeriesPress = () => {
this.setState({ isEditSeriesModalOpen: true });
};
onEditSeriesModalClose = () => {
this.setState({ isEditSeriesModalOpen: false });
};
onDeleteSeriesPress = () => {
this.setState({
isEditSeriesModalOpen: false,
isDeleteSeriesModalOpen: true
});
};
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteSeriesModalOpen: false });
};
onUseSceneNumberingChange = () => {
// Mock handler to satisfy `onChange` being required for `CheckInput`.
//
};
onBannerLoad = () => {
if (this.state.hasBannerError) {
this.setState({ hasBannerError: false });
}
};
onBannerLoadError = () => {
if (!this.state.hasBannerError) {
this.setState({ hasBannerError: true });
}
};
//
// Render
render() {
const {
id,
monitored,
status,
title,
titleSlug,
seriesType,
network,
originalLanguage,
qualityProfile,
nextAiring,
previousAiring,
added,
statistics,
latestSeason,
year,
path,
genres,
ratings,
certification,
tags,
images,
useSceneNumbering,
showBanners,
showSearchAction,
columns,
isRefreshingSeries,
isSearchingSeries,
onRefreshSeriesPress,
onSearchPress
} = this.props;
const {
seasonCount,
episodeCount,
episodeFileCount,
totalEpisodeCount,
releaseGroups,
sizeOnDisk
} = statistics;
const {
hasBannerError,
isEditSeriesModalOpen,
isDeleteSeriesModalOpen
} = this.state;
return (
<>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<SeriesStatusCell
key={name}
className={styles[name]}
monitored={monitored}
status={status}
component={VirtualTableRowCell}
/>
);
}
if (name === 'sortTitle') {
return (
<VirtualTableRowCell
key={name}
className={classNames(
styles[name],
showBanners && styles.banner,
showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
)}
>
{
showBanners ?
<Link
className={styles.link}
to={`/series/${titleSlug}`}
>
<SeriesBanner
className={styles.bannerImage}
images={images}
lazy={false}
overflow={true}
onError={this.onBannerLoadError}
onLoad={this.onBannerLoad}
/>
{
hasBannerError &&
<div className={styles.overlayTitle}>
{title}
</div>
}
</Link> :
<SeriesTitleLink
titleSlug={titleSlug}
title={title}
/>
}
</VirtualTableRowCell>
);
}
if (name === 'seriesType') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{titleCase(seriesType)}
</VirtualTableRowCell>
);
}
if (name === 'network') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{network}
</VirtualTableRowCell>
);
}
if (name === 'originalLanguage') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{originalLanguage.name}
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{qualityProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'nextAiring') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={nextAiring}
component={VirtualTableRowCell}
/>
);
}
if (name === 'previousAiring') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={previousAiring}
component={VirtualTableRowCell}
/>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={added}
component={VirtualTableRowCell}
/>
);
}
if (name === 'seasonCount') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{seasonCount}
</VirtualTableRowCell>
);
}
if (name === 'episodeProgress') {
const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100;
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${episodeFileCount} / ${episodeCount}`}
title={`${episodeFileCount} / ${episodeCount} (Total: ${totalEpisodeCount})`}
width={125}
/>
</VirtualTableRowCell>
);
}
if (name === 'latestSeason') {
if (!latestSeason) {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
/>
);
}
const seasonStatistics = latestSeason.statistics || {};
const progress = seasonStatistics.episodeCount ?
seasonStatistics.episodeFileCount / seasonStatistics.episodeCount * 100 :
100;
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${seasonStatistics.episodeFileCount} / ${seasonStatistics.episodeCount}`}
title={`${seasonStatistics.episodeFileCount} / ${seasonStatistics.episodeCount} (Total: ${seasonStatistics.totalEpisodeCount})`}
width={125}
/>
</VirtualTableRowCell>
);
}
if (name === 'episodeCount') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{totalEpisodeCount}
</VirtualTableRowCell>
);
}
if (name === 'year') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{year}
</VirtualTableRowCell>
);
}
if (name === 'path') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{path}
</VirtualTableRowCell>
);
}
if (name === 'sizeOnDisk') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{formatBytes(sizeOnDisk)}
</VirtualTableRowCell>
);
}
if (name === 'genres') {
const joinedGenres = genres.join(', ');
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<span title={joinedGenres}>
{joinedGenres}
</span>
</VirtualTableRowCell>
);
}
if (name === 'ratings') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<HeartRating
rating={ratings.value}
/>
</VirtualTableRowCell>
);
}
if (name === 'certification') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{certification}
</VirtualTableRowCell>
);
}
if (name === 'releaseGroups') {
const joinedReleaseGroups = releaseGroups.join(', ');
const truncatedReleaseGroups = releaseGroups.length > 3 ?
`${releaseGroups.slice(0, 3).join(', ')}...` :
joinedReleaseGroups;
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<span title={joinedReleaseGroups}>
{truncatedReleaseGroups}
</span>
</VirtualTableRowCell>
);
}
if (name === 'tags') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<TagListConnector
tags={tags}
/>
</VirtualTableRowCell>
);
}
if (name === 'useSceneNumbering') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<CheckInput
className={styles.checkInput}
name="useSceneNumbering"
value={useSceneNumbering}
isDisabled={true}
onChange={this.onUseSceneNumberingChange}
/>
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<SpinnerIconButton
name={icons.REFRESH}
title="Refresh series"
isSpinning={isRefreshingSeries}
onPress={onRefreshSeriesPress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title="Search for monitored episodes"
isSpinning={isSearchingSeries}
onPress={onSearchPress}
/>
}
<IconButton
name={icons.EDIT}
title="Edit Series"
onPress={this.onEditSeriesPress}
/>
</VirtualTableRowCell>
);
}
return null;
})
}
<EditSeriesModalConnector
isOpen={isEditSeriesModalOpen}
seriesId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen}
seriesId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</>
);
}
}
SeriesIndexRow.propTypes = {
id: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
seriesType: PropTypes.string.isRequired,
originalLanguage: PropTypes.object.isRequired,
network: PropTypes.string,
qualityProfile: PropTypes.object.isRequired,
nextAiring: PropTypes.string,
previousAiring: PropTypes.string,
added: PropTypes.string,
statistics: PropTypes.object.isRequired,
latestSeason: PropTypes.object,
year: PropTypes.number,
path: PropTypes.string.isRequired,
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
ratings: PropTypes.object.isRequired,
certification: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
useSceneNumbering: PropTypes.bool.isRequired,
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
isSearchingSeries: PropTypes.bool.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
SeriesIndexRow.defaultProps = {
statistics: {
seasonCount: 0,
episodeCount: 0,
episodeFileCount: 0,
totalEpisodeCount: 0,
releaseGroups: []
},
genres: [],
tags: []
};
export default SeriesIndexRow;
@@ -0,0 +1,445 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SelectActionType, useSelect } from 'App/SelectContext';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import CheckInput from 'Components/Form/CheckInput';
import HeartRating from 'Components/HeartRating';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import Column from 'Components/Table/Column';
import TagListConnector from 'Components/TagListConnector';
import { icons } from 'Helpers/Props';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import createSeriesIndexItemSelector from 'Series/Index/createSeriesIndexItemSelector';
import SeriesBanner from 'Series/SeriesBanner';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import { executeCommand } from 'Store/Actions/commandActions';
import formatBytes from 'Utilities/Number/formatBytes';
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
import titleCase from 'Utilities/String/titleCase';
import hasGrowableColumns from './hasGrowableColumns';
import selectTableOptions from './selectTableOptions';
import SeriesStatusCell from './SeriesStatusCell';
import styles from './SeriesIndexRow.css';
interface SeriesIndexRowProps {
seriesId: number;
sortKey: string;
columns: Column[];
}
function SeriesIndexRow(props: SeriesIndexRowProps) {
const { seriesId, columns } = props;
const {
series,
qualityProfile,
latestSeason,
isRefreshingSeries,
isSearchingSeries,
} = useSelector(createSeriesIndexItemSelector(props.seriesId));
const { showBanners, showSearchAction } = useSelector(selectTableOptions);
const {
title,
monitored,
status,
path,
titleSlug,
nextAiring,
previousAiring,
added,
statistics = {},
images,
seriesType,
network,
originalLanguage,
certification,
year,
useSceneNumbering,
genres = [],
ratings,
tags = [],
} = series;
const {
seasonCount = 0,
episodeCount = 0,
episodeFileCount = 0,
totalEpisodeCount = 0,
sizeOnDisk = 0,
releaseGroups = [],
} = statistics;
const dispatch = useDispatch();
const [hasBannerError, setHasBannerError] = useState(false);
const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false);
const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false);
const onRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: REFRESH_SERIES,
seriesId,
})
);
}, [seriesId, dispatch]);
const onSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: SERIES_SEARCH,
seriesId,
})
);
}, [seriesId, dispatch]);
const onBannerLoadError = useCallback(() => {
setHasBannerError(true);
}, [setHasBannerError]);
const onBannerLoad = useCallback(() => {
setHasBannerError(false);
}, [setHasBannerError]);
const onEditSeriesPress = useCallback(() => {
setIsEditSeriesModalOpen(true);
}, [setIsEditSeriesModalOpen]);
const onEditSeriesModalClose = useCallback(() => {
setIsEditSeriesModalOpen(false);
}, [setIsEditSeriesModalOpen]);
const onDeleteSeriesPress = useCallback(() => {
setIsEditSeriesModalOpen(false);
setIsDeleteSeriesModalOpen(true);
}, [setIsDeleteSeriesModalOpen]);
const onDeleteSeriesModalClose = useCallback(() => {
setIsDeleteSeriesModalOpen(false);
}, [setIsDeleteSeriesModalOpen]);
const onUseSceneNumberingChange = useCallback(() => {
// Mock handler to satisfy `onChange` being required for `CheckInput`.
}, []);
return (
<>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<SeriesStatusCell
key={name}
className={styles[name]}
monitored={monitored}
status={status}
component={VirtualTableRowCell}
/>
);
}
if (name === 'sortTitle') {
return (
<VirtualTableRowCell
key={name}
className={classNames(
styles[name],
showBanners && styles.banner,
showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
)}
>
{showBanners ? (
<Link className={styles.link} to={`/series/${titleSlug}`}>
<SeriesBanner
className={styles.bannerImage}
images={images}
lazy={false}
overflow={true}
onError={onBannerLoadError}
onLoad={onBannerLoad}
/>
{hasBannerError && (
<div className={styles.overlayTitle}>{title}</div>
)}
</Link>
) : (
<SeriesTitleLink titleSlug={titleSlug} title={title} />
)}
</VirtualTableRowCell>
);
}
if (name === 'seriesType') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{titleCase(seriesType)}
</VirtualTableRowCell>
);
}
if (name === 'network') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{network}
</VirtualTableRowCell>
);
}
if (name === 'originalLanguage') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{originalLanguage.name}
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{qualityProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'nextAiring') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={nextAiring}
component={VirtualTableRowCell}
/>
);
}
if (name === 'previousAiring') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={previousAiring}
component={VirtualTableRowCell}
/>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={added}
component={VirtualTableRowCell}
/>
);
}
if (name === 'seasonCount') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{seasonCount}
</VirtualTableRowCell>
);
}
if (name === 'episodeProgress') {
const progress = episodeCount
? (episodeFileCount / episodeCount) * 100
: 100;
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${episodeFileCount} / ${episodeCount}`}
title={`${episodeFileCount} / ${episodeCount} (Total: ${totalEpisodeCount})`}
width={125}
/>
</VirtualTableRowCell>
);
}
if (name === 'latestSeason') {
if (!latestSeason) {
return <VirtualTableRowCell key={name} className={styles[name]} />;
}
const seasonStatistics = latestSeason.statistics || {};
const progress = seasonStatistics.episodeCount
? (seasonStatistics.episodeFileCount /
seasonStatistics.episodeCount) *
100
: 100;
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${seasonStatistics.episodeFileCount} / ${seasonStatistics.episodeCount}`}
title={`${seasonStatistics.episodeFileCount} / ${seasonStatistics.episodeCount} (Total: ${seasonStatistics.totalEpisodeCount})`}
width={125}
/>
</VirtualTableRowCell>
);
}
if (name === 'episodeCount') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{totalEpisodeCount}
</VirtualTableRowCell>
);
}
if (name === 'year') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{year}
</VirtualTableRowCell>
);
}
if (name === 'path') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{path}
</VirtualTableRowCell>
);
}
if (name === 'sizeOnDisk') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{formatBytes(sizeOnDisk)}
</VirtualTableRowCell>
);
}
if (name === 'genres') {
const joinedGenres = genres.join(', ');
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<span title={joinedGenres}>{joinedGenres}</span>
</VirtualTableRowCell>
);
}
if (name === 'ratings') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<HeartRating rating={ratings.value} />
</VirtualTableRowCell>
);
}
if (name === 'certification') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{certification}
</VirtualTableRowCell>
);
}
if (name === 'releaseGroups') {
const joinedReleaseGroups = releaseGroups.join(', ');
const truncatedReleaseGroups =
releaseGroups.length > 3
? `${releaseGroups.slice(0, 3).join(', ')}...`
: joinedReleaseGroups;
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<span title={joinedReleaseGroups}>{truncatedReleaseGroups}</span>
</VirtualTableRowCell>
);
}
if (name === 'tags') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<TagListConnector tags={tags} />
</VirtualTableRowCell>
);
}
if (name === 'useSceneNumbering') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<CheckInput
className={styles.checkInput}
name="useSceneNumbering"
value={useSceneNumbering}
isDisabled={true}
onChange={onUseSceneNumberingChange}
/>
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<SpinnerIconButton
name={icons.REFRESH}
title="Refresh series"
isSpinning={isRefreshingSeries}
onPress={onRefreshPress}
/>
{showSearchAction ? (
<SpinnerIconButton
name={icons.SEARCH}
title="Search for monitored episodes"
isSpinning={isSearchingSeries}
onPress={onSearchPress}
/>
) : null}
<IconButton
name={icons.EDIT}
title="Edit Series"
onPress={onEditSeriesPress}
/>
</VirtualTableRowCell>
);
}
return null;
})}
<EditSeriesModalConnector
isOpen={isEditSeriesModalOpen}
seriesId={seriesId}
onModalClose={onEditSeriesModalClose}
onDeleteSeriesPress={onDeleteSeriesPress}
/>
<DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen}
seriesId={seriesId}
onModalClose={onDeleteSeriesModalClose}
/>
</>
);
}
export default SeriesIndexRow;
@@ -1,5 +1,3 @@
.tableContainer {
composes: tableContainer from '~Components/Table/VirtualTable.css';
flex: 1 0 auto;
.tableScroller {
position: relative;
}
@@ -1,127 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import VirtualTable from 'Components/Table/VirtualTable';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import { sortDirections } from 'Helpers/Props';
import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import SeriesIndexHeaderConnector from './SeriesIndexHeaderConnector';
import SeriesIndexRow from './SeriesIndexRow';
import styles from './SeriesIndexTable.css';
class SeriesIndexTable extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
scrollIndex: null
};
}
componentDidUpdate(prevProps) {
const {
items,
jumpToCharacter
} = this.props;
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter);
if (scrollIndex != null) {
this.setState({ scrollIndex });
}
} else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) {
this.setState({ scrollIndex: null });
}
}
//
// Control
rowRenderer = ({ key, rowIndex, style }) => {
const {
items,
columns,
showBanners
} = this.props;
const series = items[rowIndex];
return (
<VirtualTableRow
key={key}
style={style}
>
<SeriesIndexItemConnector
key={series.id}
component={SeriesIndexRow}
columns={columns}
seriesId={series.id}
qualityProfileId={series.qualityProfileId}
showBanners={showBanners}
/>
</VirtualTableRow>
);
};
//
// Render
render() {
const {
items,
columns,
sortKey,
sortDirection,
showBanners,
isSmallScreen,
onSortPress,
scroller,
scrollTop
} = this.props;
return (
<VirtualTable
className={styles.tableContainer}
items={items}
scrollIndex={this.state.scrollIndex}
scrollTop={scrollTop}
scroller={scroller}
isSmallScreen={isSmallScreen}
rowHeight={showBanners ? 70 : 38}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<SeriesIndexHeaderConnector
showBanners={showBanners}
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
/>
}
columns={columns}
/>
);
}
}
SeriesIndexTable.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
showBanners: PropTypes.bool.isRequired,
jumpToCharacter: PropTypes.string,
scrollTop: PropTypes.number,
scroller: PropTypes.instanceOf(Element).isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired
};
export default SeriesIndexTable;
@@ -0,0 +1,205 @@
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column';
import useMeasure from 'Helpers/Hooks/useMeasure';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import SortDirection from 'Helpers/Props/SortDirection';
import Series from 'Series/Series';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import selectTableOptions from './selectTableOptions';
import SeriesIndexRow from './SeriesIndexRow';
import SeriesIndexTableHeader from './SeriesIndexTableHeader';
import styles from './SeriesIndexTable.css';
const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
const bodyPaddingSmallScreen = parseInt(
dimensions.pageContentBodyPaddingSmallScreen
);
interface RowItemData {
items: Series[];
sortKey: string;
columns: Column[];
}
interface SeriesIndexTableProps {
items: Series[];
sortKey?: string;
sortDirection?: SortDirection;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
isSmallScreen: boolean;
}
const columnsSelector = createSelector(
(state) => state.seriesIndex.columns,
(columns) => columns
);
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
const { items, sortKey, columns } = data;
if (index >= items.length) {
return null;
}
const series = items[index];
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
...style,
}}
>
<SeriesIndexRow
seriesId={series.id}
sortKey={sortKey}
columns={columns}
/>
</div>
);
};
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;
}
function SeriesIndexTable(props: SeriesIndexTableProps) {
const {
items,
sortKey,
sortDirection,
jumpToCharacter,
isSmallScreen,
scrollerRef,
} = props;
const columns = useSelector(columnsSelector);
const { showBanners } = useSelector(selectTableOptions);
const listRef: React.MutableRefObject<List> = useRef();
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
const rowHeight = useMemo(() => {
return showBanners ? 70 : 38;
}, [showBanners]);
useEffect(() => {
const current = scrollerRef.current as HTMLElement;
if (isSmallScreen) {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
return;
}
if (current) {
const width = current.clientWidth;
const padding =
(isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5;
setSize({
width: width - padding * 2,
height: window.innerHeight,
});
}
}, [isSmallScreen, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
const currentScrollerRef = scrollerRef.current;
const handleScroll = throttle(() => {
const { offsetTop = 0 } = currentScrollerRef;
const scrollTop =
(isSmallScreen
? getWindowScrollTopPosition()
: currentScrollerRef.scrollTop) - offsetTop;
listRef.current.scrollTo(scrollTop);
}, 10);
currentScrollListener.addEventListener('scroll', handleScroll);
return () => {
handleScroll.cancel();
if (currentScrollListener) {
currentScrollListener.removeEventListener('scroll', handleScroll);
}
};
}, [isSmallScreen, listRef, scrollerRef]);
useEffect(() => {
if (jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (index != null) {
let scrollTop = index * rowHeight;
// If the offset is zero go to the top, otherwise offset
// by the approximate size of the header + padding (37 + 20).
if (scrollTop > 0) {
const offset = 57;
scrollTop += offset;
}
listRef.current.scrollTo(scrollTop);
scrollerRef.current.scrollTo(0, scrollTop);
}
}
}, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]);
return (
<div ref={measureRef}>
<Scroller
className={styles.tableScroller}
scrollDirection={ScrollDirection.Horizontal}
>
<SeriesIndexTableHeader
showBanners={showBanners}
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
/>
<List<RowItemData>
ref={listRef}
style={{
width: '100%',
height: '100%',
overflow: 'none',
}}
width={size.width}
height={size.height}
itemCount={items.length}
itemSize={rowHeight}
itemData={{
items,
sortKey,
columns,
}}
>
{Row}
</List>
</Scroller>
</div>
);
}
export default SeriesIndexTable;
@@ -1,29 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setSeriesSort } from 'Store/Actions/seriesIndexActions';
import SeriesIndexTable from './SeriesIndexTable';
function createMapStateToProps() {
return createSelector(
(state) => state.app.dimensions,
(state) => state.seriesIndex.tableOptions,
(state) => state.seriesIndex.columns,
(dimensions, tableOptions, columns) => {
return {
isSmallScreen: dimensions.isSmallScreen,
showBanners: tableOptions.showBanners,
columns
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSortPress(sortKey) {
dispatch(setSeriesSort({ sortKey }));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexTable);
@@ -0,0 +1,99 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { SelectActionType, useSelect } from 'App/SelectContext';
import IconButton from 'Components/Link/IconButton';
import Column from 'Components/Table/Column';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import { icons } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
setSeriesSort,
setSeriesTableOption,
} from 'Store/Actions/seriesIndexActions';
import hasGrowableColumns from './hasGrowableColumns';
import SeriesIndexTableOptions from './SeriesIndexTableOptions';
import styles from './SeriesIndexTableHeader.css';
interface SeriesIndexTableHeaderProps {
showBanners: boolean;
columns: Column[];
sortKey?: string;
sortDirection?: SortDirection;
}
function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) {
const { showBanners, columns, sortKey, sortDirection } = props;
const dispatch = useDispatch();
const onSortPress = useCallback(
(value) => {
dispatch(setSeriesSort({ sortKey: value }));
},
[dispatch]
);
const onTableOptionChange = useCallback(
(payload) => {
dispatch(setSeriesTableOption(payload));
},
[dispatch]
);
return (
<VirtualTableHeader>
{columns.map((column) => {
const { name, label, isSortable, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
>
<TableOptionsModalWrapper
columns={columns}
optionsComponent={SeriesIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<IconButton name={icons.ADVANCED_SETTINGS} />
</TableOptionsModalWrapper>
</VirtualTableHeaderCell>
);
}
return (
<VirtualTableHeaderCell
key={name}
className={classNames(
styles[name],
name === 'sortTitle' && showBanners && styles.banner,
name === 'sortTitle' &&
showBanners &&
!hasGrowableColumns(columns) &&
styles.bannerGrow
)}
name={name}
sortKey={sortKey}
sortDirection={sortDirection}
isSortable={isSortable}
onSortPress={onSortPress}
>
{label}
</VirtualTableHeaderCell>
);
})}
</VirtualTableHeader>
);
}
export default SeriesIndexTableHeader;
@@ -1,100 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
class SeriesIndexTableOptions extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
showBanners: props.showBanners,
showSearchAction: props.showSearchAction
};
}
componentDidUpdate(prevProps) {
const {
showBanners,
showSearchAction
} = this.props;
if (
showBanners !== prevProps.showBanners ||
showSearchAction !== prevProps.showSearchAction
) {
this.setState({
showBanners,
showSearchAction
});
}
}
//
// Listeners
onTableOptionChange = ({ name, value }) => {
this.setState({
[name]: value
}, () => {
this.props.onTableOptionChange({
tableOptions: {
...this.state,
[name]: value
}
});
});
};
//
// Render
render() {
const {
showBanners,
showSearchAction
} = this.state;
return (
<Fragment>
<FormGroup>
<FormLabel>Show Banners</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showBanners"
value={showBanners}
helpText="Show banners instead of titles"
onChange={this.onTableOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText="Show search button on hover"
onChange={this.onTableOptionChange}
/>
</FormGroup>
</Fragment>
);
}
}
SeriesIndexTableOptions.propTypes = {
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default SeriesIndexTableOptions;
@@ -0,0 +1,61 @@
import React, { Fragment, useCallback } from 'react';
import { useSelector } from 'react-redux';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import selectTableOptions from './selectTableOptions';
interface SeriesIndexTableOptionsProps {
onTableOptionChange(...args: unknown[]): unknown;
}
function SeriesIndexTableOptions(props: SeriesIndexTableOptionsProps) {
const { onTableOptionChange } = props;
const tableOptions = useSelector(selectTableOptions);
const { showBanners, showSearchAction } = tableOptions;
const onTableOptionChangeWrapper = useCallback(
({ name, value }) => {
onTableOptionChange({
tableOptions: {
...tableOptions,
[name]: value,
},
});
},
[tableOptions, onTableOptionChange]
);
return (
<Fragment>
<FormGroup>
<FormLabel>Show Banners</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showBanners"
value={showBanners}
helpText="Show banners instead of titles"
onChange={onTableOptionChangeWrapper}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText="Show search button on hover"
onChange={onTableOptionChangeWrapper}
/>
</FormGroup>
</Fragment>
);
}
export default SeriesIndexTableOptions;
@@ -1,14 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import SeriesIndexTableOptions from './SeriesIndexTableOptions';
function createMapStateToProps() {
return createSelector(
(state) => state.seriesIndex.tableOptions,
(tableOptions) => {
return tableOptions;
}
);
}
export default connect(createMapStateToProps)(SeriesIndexTableOptions);
@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -6,22 +5,26 @@ import { icons } from 'Helpers/Props';
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
import styles from './SeriesStatusCell.css';
function SeriesStatusCell(props) {
interface SeriesStatusCellProps {
className: string;
monitored: boolean;
status: string;
component?: React.ElementType;
}
function SeriesStatusCell(props: SeriesStatusCellProps) {
const {
className,
monitored,
status,
component: Component,
component: Component = VirtualTableRowCell,
...otherProps
} = props;
const statusDetails = getSeriesStatusDetails(status);
return (
<Component
className={className}
{...otherProps}
>
<Component className={className} {...otherProps}>
<Icon
className={styles.statusIcon}
name={monitored ? icons.MONITORED : icons.UNMONITORED}
@@ -32,22 +35,9 @@ function SeriesStatusCell(props) {
className={styles.statusIcon}
name={statusDetails.icon}
title={`${statusDetails.title}: ${statusDetails.message}`}
/>
</Component>
);
}
SeriesStatusCell.propTypes = {
className: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
component: PropTypes.elementType
};
SeriesStatusCell.defaultProps = {
className: styles.status,
component: VirtualTableRowCell
};
export default SeriesStatusCell;
@@ -1,16 +1,8 @@
const growableColumns = [
'network',
'qualityProfileId',
'path',
'tags'
];
const growableColumns = ['network', 'qualityProfileId', 'path', 'tags'];
export default function hasGrowableColumns(columns) {
return columns.some((column) => {
const {
name,
isVisible
} = column;
const { name, isVisible } = column;
return growableColumns.includes(name) && isVisible;
});
@@ -0,0 +1,8 @@
import { createSelector } from 'reselect';
const selectTableOptions = createSelector(
(state) => state.seriesIndex.tableOptions,
(tableOptions) => tableOptions
);
export default selectTableOptions;