New: Custom Filtering for UI (#234)

pull/250/head
Qstick 6 years ago committed by GitHub
parent c6873014c7
commit 7354e02bff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -12,8 +12,6 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import FilterMenu from 'Components/Menu/FilterMenu';
import MenuContent from 'Components/Menu/MenuContent';
import FilterMenuItem from 'Components/Menu/FilterMenuItem';
import HistoryRowConnector from './HistoryRowConnector';
class History extends Component {
@ -49,8 +47,8 @@ class History extends Component {
error,
items,
columns,
filterKey,
filterValue,
selectedFilterKey,
filters,
totalRecords,
isAlbumsFetching,
isAlbumsPopulated,
@ -77,67 +75,13 @@ class History extends Component {
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<FilterMenu alignMenu={align.RIGHT}>
<MenuContent>
<FilterMenuItem
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
All
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="1"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Grabbed
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="3"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Imported
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="4"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Failed
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="5"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Deleted
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="6"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Renamed
</FilterMenuItem>
</MenuContent>
</FilterMenu>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
@ -204,8 +148,8 @@ History.propTypes = {
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.string,
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isAlbumsFetching: PropTypes.bool.isRequired,
isAlbumsPopulated: PropTypes.bool.isRequired,

@ -105,8 +105,8 @@ class HistoryConnector extends Component {
this.props.setHistorySort({ sortKey });
}
onFilterSelect = (filterKey, filterValue) => {
this.props.setHistoryFilter({ filterKey, filterValue });
onFilterSelect = (selectedFilterKey) => {
this.props.setHistoryFilter({ selectedFilterKey });
}
onTableOptionChange = (payload) => {

@ -3,6 +3,6 @@
width: 100%;
&:hover {
background-color: $menuItemHoverColor;
background-color: $menuItemHoverBackgroundColor;
}
}

@ -0,0 +1,5 @@
.filterMenuContainer {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}

@ -1,8 +1,10 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, sortDirections } from 'Helpers/Props';
import { align, icons, sortDirections } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Icon from 'Components/Icon';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageMenuButton from 'Components/Menu/PageMenuButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
@ -10,7 +12,9 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector';
import InteractiveAlbumSearchRow from './InteractiveAlbumSearchRow';
import styles from './InteractiveAlbumSearchModalContent.css';
const columns = [
{
@ -81,12 +85,17 @@ class InteractiveAlbumSearchModalContent extends Component {
isFetching,
isPopulated,
error,
totalReleasesCount,
items,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
longDateFormat,
timeFormat,
onSortPress,
onFilterSelect,
onGrabPress,
onModalClose
} = this.props;
@ -117,28 +126,59 @@ class InteractiveAlbumSearchModalContent extends Component {
{
isPopulated && hasItems && !error &&
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{
items.map((item) => {
return (
<InteractiveAlbumSearchRow
key={item.guid}
{...item}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
onGrabPress={onGrabPress}
/>
);
})
}
</TableBody>
</Table>
<div>
<div className={styles.filterMenuContainer}>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
onFilterSelect={onFilterSelect}
buttonComponent={PageMenuButton}
filterModalConnectorComponent={InteractiveSearchFilterModalConnector}
/>
</div>
{
!!totalReleasesCount && !items.length &&
<div>
All results are hidden by {filters.length > 1 ? 'filters' : 'a filter'}.
</div>
}
{
!!items.length &&
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{
items.map((item) => {
return (
<InteractiveAlbumSearchRow
key={item.guid}
{...item}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
onGrabPress={onGrabPress}
/>
);
})
}
</TableBody>
</Table>
}
{
totalReleasesCount !== items.length && !!items.length &&
<div>
Some results are hidden by {filters.length > 1 ? 'filters' : 'a filter'}.
</div>
}
</div>
}
</ModalBody>
@ -156,12 +196,17 @@ InteractiveAlbumSearchModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
totalReleasesCount: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
onSortPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onGrabPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

@ -1,19 +1,20 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import connectSection from 'Store/connectSection';
import { createSelector } from 'reselect';
import { fetchReleases, clearReleases, cancelFetchReleases, setReleasesSort, grabRelease } from 'Store/Actions/releaseActions';
import connectSection from 'Store/connectSection';
import * as releaseActions from 'Store/Actions/releaseActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import InteractiveAlbumSearchModalContent from './InteractiveAlbumSearchModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.releases.items.length,
createClientSideCollectionSelector(),
createUISettingsSelector(),
(releases, uiSettings) => {
(totalReleasesCount, releases, uiSettings) => {
return {
totalReleasesCount,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat,
...releases
@ -25,23 +26,27 @@ function createMapStateToProps() {
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchReleases({ albumId }) {
dispatch(fetchReleases({ albumId }));
dispatch(releaseActions.fetchReleases({ albumId }));
},
dispatchCancelFetchReleases() {
dispatch(cancelFetchReleases());
dispatch(releaseActions.cancelFetchReleases());
},
dispatchClearReleases() {
dispatch(clearReleases());
dispatch(releaseActions.clearReleases());
},
onSortPress(sortKey, sortDirection) {
dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection }));
},
dispatchSetReleasesSort({ sortKey, sortDirection }) {
dispatch(setReleasesSort({ sortKey, sortDirection }));
onFilterSelect(selectedFilterKey) {
dispatch(releaseActions.setReleasesFilter({ selectedFilterKey }));
},
dispatchGrabRelease({ guid, indexerId }) {
dispatch(grabRelease({ guid, indexerId }));
onGrabPress({ guid, indexerId }) {
dispatch(releaseActions.grabRelease({ guid, indexerId }));
}
};
}
@ -66,26 +71,18 @@ class InteractiveAlbumSearchModalContentConnector extends Component {
this.props.dispatchClearReleases();
}
//
// Listeners
onSortPress = (sortKey, sortDirection) => {
this.props.dispatchSetReleasesSort({ sortKey, sortDirection });
}
onGrabPress = (guid, indexerId) => {
this.props.dispatchGrabRelease({ guid, indexerId });
}
//
// Render
render() {
const {
dispatchFetchReleases,
...otherProps
} = this.props;
return (
<InteractiveAlbumSearchModalContent
{...this.props}
onSortPress={this.onSortPress}
onGrabPress={this.onGrabPress}
{...otherProps}
/>
);
}
@ -95,9 +92,7 @@ InteractiveAlbumSearchModalContentConnector.propTypes = {
albumId: PropTypes.number,
dispatchFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchSetReleasesSort: PropTypes.func.isRequired,
dispatchGrabRelease: PropTypes.func.isRequired
dispatchCancelFetchReleases: PropTypes.func.isRequired
};
export default connectSection(

@ -0,0 +1,31 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as releaseActions from 'Store/Actions/releaseActions';
import FilterModal from 'Components/Filter/FilterModal';
function createMapStateToProps() {
return createSelector(
(state) => state.releases.items,
(state) => state.releases.filterBuilderProps,
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onRemoveCustomFilterPress(index) {
dispatch(releaseActions.removeReleasesCustomFilter({ index }));
},
onSaveCustomFilterPress(payload) {
dispatch(releaseActions.saveReleasesCustomFilter(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal);

@ -10,8 +10,6 @@ import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import FilterMenu from 'Components/Menu/FilterMenu';
import MenuContent from 'Components/Menu/MenuContent';
import FilterMenuItem from 'Components/Menu/FilterMenuItem';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import NoArtist from 'Artist/NoArtist';
@ -104,8 +102,9 @@ class AlbumStudio extends Component {
isPopulated,
error,
items,
filterKey,
filterValue,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
isSaving,
@ -125,57 +124,13 @@ class AlbumStudio extends Component {
<PageToolbar>
<PageToolbarSection />
<PageToolbarSection alignContent={align.RIGHT}>
<FilterMenu alignMenu={align.RIGHT}>
<MenuContent>
<FilterMenuItem
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
All
</FilterMenuItem>
<FilterMenuItem
name="monitored"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Monitored Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="continuing"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Continuing Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="ended"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Ended Only
</FilterMenuItem>
<FilterMenuItem
name="missing"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Missing Albums
</FilterMenuItem>
</MenuContent>
</FilterMenu>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
@ -245,8 +200,9 @@ AlbumStudio.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
onSortPress: PropTypes.func.isRequired,

@ -57,8 +57,8 @@ class AlbumStudioConnector extends Component {
this.props.setAlbumStudioSort({ sortKey });
}
onFilterSelect = (filterKey, filterValue, filterType) => {
this.props.setAlbumStudioFilter({ filterKey, filterValue, filterType });
onFilterSelect = (selectedFilterKey) => {
this.props.setAlbumStudioFilter({ selectedFilterKey });
}
onUpdateSelectedPress = (payload) => {

@ -47,11 +47,15 @@ class DeleteArtistModalContent extends Component {
const {
artistName,
path,
trackFileCount,
sizeOnDisk,
statistics,
onModalClose
} = this.props;
const {
trackFileCount,
sizeOnDisk
} = statistics;
const deleteFiles = this.state.deleteFiles;
let deleteFilesLabel = `Delete ${trackFileCount} Track Files`;
let deleteFilesHelpText = 'Delete the track files and artist folder';
@ -126,8 +130,7 @@ class DeleteArtistModalContent extends Component {
DeleteArtistModalContent.propTypes = {
artistName: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
trackFileCount: PropTypes.number.isRequired,
sizeOnDisk: PropTypes.number,
statistics: PropTypes.object.isRequired,
onDeletePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

@ -161,8 +161,7 @@ class ArtistDetails extends Component {
artistName,
ratings,
path,
sizeOnDisk,
trackFileCount,
statistics,
qualityProfileId,
monitored,
albumTypes,
@ -178,12 +177,18 @@ class ArtistDetails extends Component {
isPopulated,
albumsError,
trackFilesError,
hasMonitoredAlbums,
previousArtist,
nextArtist,
onRefreshPress,
onSearchPress
} = this.props;
const {
trackFileCount,
sizeOnDisk
} = statistics;
const {
isOrganizeModalOpen,
isManageTracksOpen,
@ -230,7 +235,9 @@ class ArtistDetails extends Component {
<PageToolbarButton
label="Search Monitored"
iconName={icons.SEARCH}
isDisabled={!monitored || !hasMonitoredAlbums}
isSpinning={isSearching}
title={hasMonitoredAlbums ? undefined : 'No monitored albums for this artist'}
onPress={onSearchPress}
/>
@ -583,8 +590,7 @@ ArtistDetails.propTypes = {
artistName: PropTypes.string.isRequired,
ratings: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
trackFileCount: PropTypes.number,
statistics: PropTypes.object.isRequired,
qualityProfileId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
albumTypes: PropTypes.arrayOf(PropTypes.string),
@ -600,6 +606,7 @@ ArtistDetails.propTypes = {
isPopulated: PropTypes.bool.isRequired,
albumsError: PropTypes.object,
trackFilesError: PropTypes.object,
hasMonitoredAlbums: PropTypes.bool.isRequired,
previousArtist: PropTypes.object.isRequired,
nextArtist: PropTypes.object.isRequired,
onRefreshPress: PropTypes.func.isRequired,

@ -64,6 +64,8 @@ function createMapStateToProps() {
return acc;
}, []);
const hasMonitoredAlbums = albums.items.some((e) => e.monitored);
return {
...artist,
albumTypes: sortedAlbumTypes,
@ -78,6 +80,7 @@ function createMapStateToProps() {
isPopulated,
albumsError,
trackFilesError,
hasMonitoredAlbums,
previousArtist,
nextArtist
};

@ -10,8 +10,6 @@ import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import FilterMenu from 'Components/Menu/FilterMenu';
import MenuContent from 'Components/Menu/MenuContent';
import FilterMenuItem from 'Components/Menu/FilterMenuItem';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import NoArtist from 'Artist/NoArtist';
@ -151,8 +149,9 @@ class ArtistEditor extends Component {
isPopulated,
error,
items,
filterKey,
filterValue,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
isSaving,
@ -180,57 +179,13 @@ class ArtistEditor extends Component {
<PageToolbar>
<PageToolbarSection />
<PageToolbarSection alignContent={align.RIGHT}>
<FilterMenu alignMenu={align.RIGHT}>
<MenuContent>
<FilterMenuItem
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
All
</FilterMenuItem>
<FilterMenuItem
name="monitored"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Monitored Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="continuing"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Continuing Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="ended"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Ended Only
</FilterMenuItem>
<FilterMenuItem
name="missing"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Missing Albums
</FilterMenuItem>
</MenuContent>
</FilterMenu>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
@ -314,8 +269,9 @@ ArtistEditor.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,

@ -51,8 +51,8 @@ class ArtistEditorConnector extends Component {
this.props.dispatchSetArtistEditorSort({ sortKey });
}
onFilterSelect = (filterKey, filterValue, filterType) => {
this.props.dispatchSetArtistEditorFilter({ filterKey, filterValue, filterType });
onFilterSelect = (selectedFilterKey) => {
this.props.dispatchSetArtistEditorFilter({ selectedFilterKey });
}
onSaveSelected = (payload) => {

@ -49,11 +49,10 @@ class ArtistIndex extends Component {
constructor(props, context) {
super(props, context);
this._viewComponent = null;
this.state = {
contentBody: null,
jumpBarItems: [],
jumpToCharacter: null,
isPosterOptionsModalOpen: false,
isBannerOptionsModalOpen: false,
isOverviewOptionsModalOpen: false,
@ -69,7 +68,8 @@ class ArtistIndex extends Component {
const {
items,
sortKey,
sortDirection
sortDirection,
scrollTop
} = this.props;
if (
@ -79,6 +79,10 @@ class ArtistIndex extends Component {
) {
this.setJumpBarItems();
}
if (this.state.jumpToCharacter != null && scrollTop !== prevProps.scrollTop) {
this.setState({ jumpToCharacter: null });
}
}
//
@ -88,10 +92,6 @@ class ArtistIndex extends Component {
this.setState({ contentBody: ref });
}
setViewComponentRef = (ref) => {
this._viewComponent = ref;
}
setJumpBarItems() {
const {
items,
@ -152,9 +152,8 @@ class ArtistIndex extends Component {
this.setState({ isOverviewOptionsModalOpen: false });
}
onJumpBarItemPress = (item) => {
const viewComponent = this._viewComponent.getWrappedInstance();
viewComponent.scrollToFirstCharacter(item);
onJumpBarItemPress = (jumpToCharacter) => {
this.setState({ jumpToCharacter });
}
onRender = () => {
@ -187,8 +186,9 @@ class ArtistIndex extends Component {
isPopulated,
error,
items,
filterKey,
filterValue,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
view,
@ -206,6 +206,7 @@ class ArtistIndex extends Component {
const {
contentBody,
jumpBarItems,
jumpToCharacter,
isPosterOptionsModalOpen,
isBannerOptionsModalOpen,
isOverviewOptionsModalOpen,
@ -294,8 +295,9 @@ class ArtistIndex extends Component {
/>
<ArtistIndexFilterMenu
filterKey={filterKey}
filterValue={filterValue}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoArtist}
onFilterSelect={onFilterSelect}
/>
@ -324,9 +326,9 @@ class ArtistIndex extends Component {
isLoaded &&
<div className={styles.contentBodyContainer}>
<ViewComponent
ref={this.setViewComponentRef}
contentBody={contentBody}
scrollTop={scrollTop}
jumpToCharacter={jumpToCharacter}
onRender={this.onRender}
{...otherProps}
/>
@ -378,8 +380,9 @@ ArtistIndex.propTypes = {
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
view: PropTypes.string.isRequired,

@ -100,8 +100,8 @@ class ArtistIndexConnector extends Component {
this.props.setArtistSort({ sortKey });
}
onFilterSelect = (filterKey, filterValue, filterType) => {
this.props.setArtistFilter({ filterKey, filterValue, filterType });
onFilterSelect = (selectedFilterKey) => {
this.props.setArtistFilter({ selectedFilterKey });
}
onViewSelect = (view) => {

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import styles from './ArtistIndexFooter.css';
@ -11,6 +12,7 @@ function ArtistIndexFooter({ artist }) {
let ended = 0;
let continuing = 0;
let monitored = 0;
let totalFileSize = 0;
artist.forEach((s) => {
tracks += s.trackCount || 0;
@ -25,6 +27,8 @@ function ArtistIndexFooter({ artist }) {
if (s.monitored) {
monitored++;
}
totalFileSize += s.statistics.sizeOnDisk || 0;
});
return (
@ -92,6 +96,13 @@ function ArtistIndexFooter({ artist }) {
data={trackFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Total File Size"
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
);

@ -61,8 +61,7 @@ class ArtistIndexBanner extends Component {
status,
foreignArtistId,
nextAiring,
trackCount,
trackFileCount,
statistics,
images,
bannerWidth,
bannerHeight,
@ -79,6 +78,12 @@ class ArtistIndexBanner extends Component {
...otherProps
} = this.props;
const {
trackCount,
trackFileCount,
totalTrackCount
} = statistics;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
@ -141,6 +146,7 @@ class ArtistIndexBanner extends Component {
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
totalTrackCount={totalTrackCount}
posterWidth={bannerWidth}
detailedProgressBar={detailedProgressBar}
/>
@ -165,20 +171,22 @@ class ArtistIndexBanner extends Component {
{qualityProfile.name}
</div>
}
<div className={styles.nextAiring}>
{
getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
nextAiring &&
<div className={styles.nextAiring}>
{
timeFormat,
timeForToday: true
getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
)
}
</div>
</div>
}
<ArtistIndexBannerInfo
qualityProfile={qualityProfile}
@ -186,6 +194,7 @@ class ArtistIndexBanner extends Component {
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
statistics={statistics}
{...otherProps}
/>
@ -215,8 +224,7 @@ ArtistIndexBanner.propTypes = {
status: PropTypes.string.isRequired,
foreignArtistId: PropTypes.string.isRequired,
nextAiring: PropTypes.string,
trackCount: PropTypes.number,
trackFileCount: PropTypes.number,
statistics: PropTypes.object.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
bannerWidth: PropTypes.number.isRequired,
bannerHeight: PropTypes.number.isRequired,
@ -234,7 +242,8 @@ ArtistIndexBanner.propTypes = {
ArtistIndexBanner.defaultProps = {
trackCount: 0,
trackFileCount: 0
trackFileCount: 0,
albumCount: 0
};
export default ArtistIndexBanner;

@ -1,6 +1,5 @@
.info {
background-color: $defaultColor;
color: $white;
background-color: #fafbfc;
text-align: center;
font-size: $smallFontSize;
}

@ -10,15 +10,19 @@ function ArtistIndexBannerInfo(props) {
showQualityProfile,
previousAiring,
added,
albumCount,
statistics,
path,
sizeOnDisk,
sortKey,
showRelativeDates,
shortDateFormat,
timeFormat
} = props;
const {
albumCount,
sizeOnDisk
} = statistics;
if (sortKey === 'qualityProfileId' && !showQualityProfile) {
return (
<div className={styles.info}>
@ -103,9 +107,8 @@ ArtistIndexBannerInfo.propTypes = {
showQualityProfile: PropTypes.bool.isRequired,
previousAiring: PropTypes.string,
added: PropTypes.string,
albumCount: PropTypes.number.isRequired,
statistics: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
sortKey: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,

@ -1,9 +1,9 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Measure from 'react-measure';
import { Grid, WindowScroller } from 'react-virtualized';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import dimensions from 'Styles/Variables/dimensions';
import { sortDirections } from 'Helpers/Props';
@ -116,11 +116,11 @@ class ArtistIndexBanners extends Component {
componentDidUpdate(prevProps) {
const {
items,
filterKey,
filterValue,
filters,
sortKey,
sortDirection,
bannerOptions
bannerOptions,
jumpToCharacter
} = this.props;
const itemsChanged = hasDifferentItems(prevProps.items, items);
@ -134,44 +134,34 @@ class ArtistIndexBanners extends Component {
}
if (
prevProps.filterKey !== filterKey ||
prevProps.filterValue !== filterValue ||
prevProps.filters !== filters ||
prevProps.sortKey !== sortKey ||
prevProps.sortDirection !== sortDirection ||
itemsChanged
) {
this._grid.recomputeGridSize();
}
}
//
// Control
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
scrollToFirstCharacter(character) {
const items = this.props.items;
const {
columnCount,
rowHeight
} = this.state;
if (index != null) {
const {
columnCount,
rowHeight
} = this.state;
const index = _.findIndex(items, (item) => {
const firstCharacter = item.sortName.charAt(0);
const row = Math.floor(index / columnCount);
const scrollTop = rowHeight * row;
if (character === '#') {
return !isNaN(firstCharacter);
this.props.onScroll({ scrollTop });
}
return firstCharacter === character;
});
if (index != null) {
const row = Math.floor(index / columnCount);
const scrollTop = rowHeight * row;
this.props.onScroll({ scrollTop });
}
}
//
// Control
setGridRef = (ref) => {
this._grid = ref;
}
@ -319,12 +309,12 @@ class ArtistIndexBanners extends Component {
ArtistIndexBanners.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
bannerOptions: PropTypes.object.isRequired,
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
contentBody: PropTypes.object.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,

@ -28,6 +28,6 @@ export default connectSection(
createMapStateToProps,
undefined,
undefined,
{ withRef: true },
undefined,
{ section: 'artist', uiSection: 'artistIndex' }
)(ArtistIndexBanners);

@ -2,80 +2,38 @@ import PropTypes from 'prop-types';
import React from 'react';
import { align } from 'Helpers/Props';
import FilterMenu from 'Components/Menu/FilterMenu';
import MenuContent from 'Components/Menu/MenuContent';
import FilterMenuItem from 'Components/Menu/FilterMenuItem';
function ArtistIndexFilterMenu(props) {
const {
filterKey,
filterValue,
selectedFilterKey,
filters,
customFilters,
isDisabled,
onFilterSelect
} = props;
return (
<FilterMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<MenuContent>
<FilterMenuItem
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
All
</FilterMenuItem>
<FilterMenuItem
name="monitored"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Monitored Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="continuing"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Continuing Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="ended"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Ended Only
</FilterMenuItem>
<FilterMenuItem
name="missing"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Missing Albums
</FilterMenuItem>
</MenuContent>
</FilterMenu>
isDisabled={isDisabled}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
onFilterSelect={onFilterSelect}
/>
);
}
ArtistIndexFilterMenu.propTypes = {
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
ArtistIndexFilterMenu.defaultProps = {
showCustomFilters: false
};
export default ArtistIndexFilterMenu;

@ -78,8 +78,7 @@ class ArtistIndexOverview extends Component {
status,
foreignArtistId,
nextAiring,
trackCount,
trackFileCount,
statistics,
images,
posterWidth,
posterHeight,
@ -95,6 +94,12 @@ class ArtistIndexOverview extends Component {
...otherProps
} = this.props;
const {
trackCount,
trackFileCount,
totalTrackCount
} = statistics;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
@ -144,6 +149,7 @@ class ArtistIndexOverview extends Component {
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
totalTrackCount={totalTrackCount}
posterWidth={posterWidth}
detailedProgressBar={overviewOptions.detailedProgressBar}
/>
@ -194,6 +200,7 @@ class ArtistIndexOverview extends Component {
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
statistics={statistics}
{...overviewOptions}
{...otherProps}
/>
@ -227,8 +234,7 @@ ArtistIndexOverview.propTypes = {
status: PropTypes.string.isRequired,
foreignArtistId: PropTypes.string.isRequired,
nextAiring: PropTypes.string,
trackCount: PropTypes.number,
trackFileCount: PropTypes.number,
statistics: PropTypes.object.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
posterWidth: PropTypes.number.isRequired,
posterHeight: PropTypes.number.isRequired,
@ -245,7 +251,8 @@ ArtistIndexOverview.propTypes = {
ArtistIndexOverview.defaultProps = {
trackCount: 0,
trackFileCount: 0
trackFileCount: 0,
albumCount: 0
};
export default ArtistIndexOverview;

@ -31,15 +31,19 @@ function ArtistIndexOverviewInfo(props) {
nextAiring,
qualityProfile,
added,
albumCount,
statistics,
path,
sizeOnDisk,
sortKey,
showRelativeDates,
shortDateFormat,
timeFormat
} = props;
const {
albumCount,
sizeOnDisk
} = statistics;
let albums = '1 album';
if (albumCount === 0) {
@ -203,9 +207,8 @@ ArtistIndexOverviewInfo.propTypes = {
qualityProfile: PropTypes.object.isRequired,
previousAiring: PropTypes.string,
added: PropTypes.string,
albumCount: PropTypes.number.isRequired,
statistics: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
sortKey: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,

@ -4,6 +4,7 @@ import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Measure from 'react-measure';
import { Grid, WindowScroller } from 'react-virtualized';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import dimensions from 'Styles/Variables/dimensions';
import { sortDirections } from 'Helpers/Props';
@ -76,11 +77,11 @@ class ArtistIndexOverviews extends Component {
componentDidUpdate(prevProps) {
const {
items,
filterKey,
filterValue,
filters,
sortKey,
sortDirection,
overviewOptions
overviewOptions,
jumpToCharacter
} = this.props;
const itemsChanged = hasDifferentItems(prevProps.items, items);
@ -95,8 +96,7 @@ class ArtistIndexOverviews extends Component {
}
if (
prevProps.filterKey !== filterKey ||
prevProps.filterValue !== filterValue ||
prevProps.filters !== filters ||
prevProps.sortKey !== sortKey ||
prevProps.sortDirection !== sortDirection ||
itemsChanged ||
@ -104,6 +104,20 @@ class ArtistIndexOverviews extends Component {
) {
this._grid.recomputeGridSize();
}
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (index != null) {
const {
rowHeight
} = this.state;
const scrollTop = rowHeight * index;
this.props.onScroll({ scrollTop });
}
}
}
//
@ -115,15 +129,7 @@ class ArtistIndexOverviews extends Component {
rowHeight
} = this.state;
const index = _.findIndex(items, (item) => {
const firstCharacter = item.sortTitle.charAt(0);
if (character === '#') {
return !isNaN(firstCharacter);
}
return firstCharacter === character;
});
const index = getIndexOfFirstCharacter(items, character);
if (index != null) {
const scrollTop = rowHeight * index;
@ -263,12 +269,12 @@ class ArtistIndexOverviews extends Component {
ArtistIndexOverviews.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
overviewOptions: PropTypes.object.isRequired,
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
contentBody: PropTypes.object.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,

@ -28,6 +28,6 @@ export default connectSection(
createMapStateToProps,
undefined,
undefined,
{ withRef: true },
undefined,
{ section: 'artist', uiSection: 'artistIndex' }
)(ArtistIndexOverviews);

@ -204,7 +204,7 @@ class ArtistIndexOverviewOptionsModalContent extends Component {
</FormGroup>
<FormGroup>
<FormLabel>Show Season Count</FormLabel>
<FormLabel>Show Album Count</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}

@ -61,8 +61,7 @@ class ArtistIndexPoster extends Component {
foreignArtistId,
status,
nextAiring,
trackCount,
trackFileCount,
statistics,
images,
posterWidth,
posterHeight,
@ -79,6 +78,12 @@ class ArtistIndexPoster extends Component {
...otherProps
} = this.props;
const {
trackCount,
trackFileCount,
totalTrackCount
} = statistics;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
@ -141,6 +146,7 @@ class ArtistIndexPoster extends Component {
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
totalTrackCount={totalTrackCount}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
/>
@ -165,26 +171,28 @@ class ArtistIndexPoster extends Component {
{qualityProfile.name}
</div>
}
<div className={styles.nextAiring}>
{
getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
nextAiring &&
<div className={styles.nextAiring}>
{
timeFormat,
timeForToday: true
getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
)
}
</div>
</div>
}
<ArtistIndexPosterInfo
qualityProfile={qualityProfile}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
statistics={statistics}
timeFormat={timeFormat}
{...otherProps}
/>
@ -215,8 +223,7 @@ ArtistIndexPoster.propTypes = {
status: PropTypes.string.isRequired,
foreignArtistId: PropTypes.string.isRequired,
nextAiring: PropTypes.string,
trackCount: PropTypes.number,
trackFileCount: PropTypes.number,
statistics: PropTypes.object.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
posterWidth: PropTypes.number.isRequired,
posterHeight: PropTypes.number.isRequired,
@ -234,7 +241,8 @@ ArtistIndexPoster.propTypes = {
ArtistIndexPoster.defaultProps = {
trackCount: 0,
trackFileCount: 0
trackFileCount: 0,
albumCount: 0
};
export default ArtistIndexPoster;

@ -1,6 +1,5 @@
.info {
background-color: $defaultColor;
color: $white;
background-color: #fafbfc;
text-align: center;
font-size: $smallFontSize;
}

@ -10,15 +10,19 @@ function ArtistIndexPosterInfo(props) {
showQualityProfile,
previousAiring,
added,
albumCount,
statistics,
path,
sizeOnDisk,
sortKey,
showRelativeDates,
shortDateFormat,
timeFormat
} = props;
const {
albumCount,
sizeOnDisk
} = statistics;
if (sortKey === 'qualityProfileId' && !showQualityProfile) {
return (
<div className={styles.info}>
@ -103,9 +107,8 @@ ArtistIndexPosterInfo.propTypes = {
showQualityProfile: PropTypes.bool.isRequired,
previousAiring: PropTypes.string,
added: PropTypes.string,
albumCount: PropTypes.number,
statistics: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
sortKey: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,

@ -1,9 +1,9 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Measure from 'react-measure';
import { Grid, WindowScroller } from 'react-virtualized';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import dimensions from 'Styles/Variables/dimensions';
import { sortDirections } from 'Helpers/Props';
@ -116,11 +116,11 @@ class ArtistIndexPosters extends Component {
componentDidUpdate(prevProps) {
const {
items,
filterKey,
filterValue,
filters,
sortKey,
sortDirection,
posterOptions
posterOptions,
jumpToCharacter
} = this.props;
const itemsChanged = hasDifferentItems(prevProps.items, items);
@ -134,44 +134,34 @@ class ArtistIndexPosters extends Component {
}
if (
prevProps.filterKey !== filterKey ||
prevProps.filterValue !== filterValue ||
prevProps.filters !== filters ||
prevProps.sortKey !== sortKey ||
prevProps.sortDirection !== sortDirection ||
itemsChanged
) {
this._grid.recomputeGridSize();
}
}
//
// Control
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
scrollToFirstCharacter(character) {
const items = this.props.items;
const {
columnCount,
rowHeight
} = this.state;
if (index != null) {
const {
columnCount,
rowHeight
} = this.state;
const index = _.findIndex(items, (item) => {
const firstCharacter = item.sortName.charAt(0);
const row = Math.floor(index / columnCount);
const scrollTop = rowHeight * row;
if (character === '#') {
return !isNaN(firstCharacter);
this.props.onScroll({ scrollTop });
}
return firstCharacter === character;
});
if (index != null) {
const row = Math.floor(index / columnCount);
const scrollTop = rowHeight * row;
this.props.onScroll({ scrollTop });
}
}
//
// Control
setGridRef = (ref) => {
this._grid = ref;
}
@ -319,12 +309,12 @@ class ArtistIndexPosters extends Component {
ArtistIndexPosters.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
posterOptions: PropTypes.object.isRequired,
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
contentBody: PropTypes.object.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,

@ -28,6 +28,6 @@ export default connectSection(
createMapStateToProps,
undefined,
undefined,
{ withRef: true },
undefined,
{ section: 'artist', uiSection: 'artistIndex' }
)(ArtistIndexPosters);

@ -11,6 +11,7 @@ function ArtistIndexProgressBar(props) {
status,
trackCount,
trackFileCount,
totalTrackCount,
posterWidth,
detailedProgressBar
} = props;
@ -27,7 +28,7 @@ function ArtistIndexProgressBar(props) {
size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL}
showText={detailedProgressBar}
text={text}
title={detailedProgressBar ? null : text}
title={`${trackFileCount} / ${trackCount} (Total: ${totalTrackCount})`}
width={posterWidth}
/>
);
@ -38,6 +39,7 @@ ArtistIndexProgressBar.propTypes = {
status: PropTypes.string.isRequired,
trackCount: PropTypes.number.isRequired,
trackFileCount: PropTypes.number.isRequired,
totalTrackCount: PropTypes.number.isRequired,
posterWidth: PropTypes.number.isRequired,
detailedProgressBar: PropTypes.bool.isRequired
};

@ -55,7 +55,7 @@
.sizeOnDisk {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 110px;
flex: 0 0 115px;
}
.tags {

@ -74,18 +74,22 @@ class ArtistIndexRow extends Component {
nextAlbum,
lastAlbum,
added,
albumCount,
trackCount,
trackFileCount,
totalTrackCount,
statistics,
path,
sizeOnDisk,
tags,
columns,
isRefreshingArtist,
onRefreshArtistPress
} = this.props;
const {
albumCount,
trackCount,
trackFileCount,
totalTrackCount,
sizeOnDisk
} = statistics;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
@ -367,13 +371,9 @@ ArtistIndexRow.propTypes = {
nextAlbum: PropTypes.object,
lastAlbum: PropTypes.object,
added: PropTypes.string,
albumCount: PropTypes.number,
trackCount: PropTypes.number,
trackFileCount: PropTypes.number,
totalTrackCount: PropTypes.number,
statistics: PropTypes.object.isRequired,
latestAlbum: PropTypes.object,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isRefreshingArtist: PropTypes.bool.isRequired,
@ -382,7 +382,8 @@ ArtistIndexRow.propTypes = {
ArtistIndexRow.defaultProps = {
trackCount: 0,
trackFileCount: 0
trackFileCount: 0,
albumCount: 0
};
export default ArtistIndexRow;

@ -1,6 +1,6 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import { sortDirections } from 'Helpers/Props';
import VirtualTable from 'Components/Table/VirtualTable';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
@ -9,40 +9,37 @@ import ArtistIndexRow from './ArtistIndexRow';
import styles from './ArtistIndexTable.css';
class ArtistIndexTable extends Component {
constructor(props, context) {
super(props, context);
this._table = null;
}
//
// Control
// Lifecycle
/**
* Sets the reference to the virtual table
* @param ref
*/
setTableRef = (ref) => {
this._table = ref;
};
constructor(props, context) {
super(props, context);
scrollToFirstCharacter(character) {
const items = this.props.items;
this.state = {
scrollIndex: null
};
}
const row = _.findIndex(items, (item) => {
const firstCharacter = item.sortName.charAt(0);
componentDidUpdate(prevProps) {
const jumpToCharacter = this.props.jumpToCharacter;
if (character === '#') {
return !isNaN(firstCharacter);
}
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const items = this.props.items;
return firstCharacter === character;
});
const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter);
if (row != null) {
this._table.scrollToRow(row);
if (scrollIndex != null) {
this.setState({ scrollIndex });
}
} else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) {
this.setState({ scrollIndex: null });
}
}
//
// Control
rowRenderer = ({ key, rowIndex, style }) => {
const {
items,
@ -72,8 +69,7 @@ class ArtistIndexTable extends Component {
const {
items,
columns,
filterKey,
filterValue,
filters,
sortKey,
sortDirection,
isSmallScreen,
@ -86,10 +82,10 @@ class ArtistIndexTable extends Component {
return (
<VirtualTable
ref={this.setTableRef}
className={styles.tableContainer}
items={items}
scrollTop={scrollTop}
scrollIndex={this.state.scrollIndex}
contentBody={contentBody}
isSmallScreen={isSmallScreen}
rowHeight={38}
@ -104,8 +100,7 @@ class ArtistIndexTable extends Component {
/>
}
columns={columns}
filterKey={filterKey}
filterValue={filterValue}
filters={filters}
sortKey={sortKey}
sortDirection={sortDirection}
onRender={onRender}
@ -118,11 +113,11 @@ class ArtistIndexTable extends Component {
ArtistIndexTable.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
contentBody: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired,

@ -29,6 +29,6 @@ export default connectSection(
createMapStateToProps,
createMapDispatchToProps,
undefined,
{ withRef: true },
undefined,
{ section: 'artist', uiSection: 'artistIndex' }
)(ArtistIndexTable);

@ -25,7 +25,7 @@ function MoveArtistModal(props) {
!destinationPath &&
!destinationRootFolder
) {
console.error('orginalPath and destinationPath OR destinationRootFolder must be provied');
console.error('orginalPath and destinationPath OR destinationRootFolder must be provided');
}
return (

@ -2,14 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'react-measure';
import { align, icons } from 'Helpers/Props';
import getFilterValue from 'Utilities/Filter/getFilterValue';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import FilterMenu from 'Components/Menu/FilterMenu';
import MenuContent from 'Components/Menu/MenuContent';
import FilterMenuItem from 'Components/Menu/FilterMenuItem';
import NoArtist from 'Artist/NoArtist';
import CalendarLinkModal from './iCal/CalendarLinkModal';
import Legend from './Legend/Legend';
@ -42,10 +41,6 @@ class CalendarPage extends Component {
this.props.onDaysCountChange(days);
}
onFilterMenuItemPress = (filterKey, unmonitored) => {
this.props.onUnmonitoredChange(unmonitored);
}
onGetCalendarLinkPress = () => {
this.setState({ isCalendarLinkModalOpen: true });
}
@ -59,12 +54,15 @@ class CalendarPage extends Component {
render() {
const {
unmonitored,
selectedFilterKey,
filters,
hasArtist,
colorImpairedMode
colorImpairedMode,
onFilterSelect
} = this.props;
const isMeasured = this.state.width > 0;
let PageComponent = 'div';
if (isMeasured) {
@ -85,30 +83,11 @@ class CalendarPage extends Component {
<PageToolbarSection alignContent={align.RIGHT}>
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={!hasArtist}
>
<MenuContent>
<FilterMenuItem
name="unmonitored"
value={true}
filterKey="unmonitored"
filterValue={unmonitored}
onPress={this.onFilterMenuItemPress}
>
All
</FilterMenuItem>
<FilterMenuItem
name="unmonitored"
value={false}
filterKey="unmonitored"
filterValue={unmonitored}
onPress={this.onFilterMenuItemPress}
>
Monitored Only
</FilterMenuItem>
</MenuContent>
</FilterMenu>
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
@ -139,11 +118,12 @@ class CalendarPage extends Component {
}
CalendarPage.propTypes = {
unmonitored: PropTypes.bool.isRequired,
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
hasArtist: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired,
onDaysCountChange: PropTypes.func.isRequired,
onUnmonitoredChange: PropTypes.func.isRequired
onFilterSelect: PropTypes.func.isRequired
};
export default CalendarPage;

@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setCalendarDaysCount, setCalendarIncludeUnmonitored } from 'Store/Actions/calendarActions';
import { setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CalendarPage from './CalendarPage';
@ -12,7 +12,8 @@ function createMapStateToProps() {
createUISettingsSelector(),
(calendar, artistCount, uiSettings) => {
return {
unmonitored: calendar.unmonitored,
filters: calendar.filters,
selectedFilterKey: calendar.selectedFilterKey,
showUpcoming: calendar.showUpcoming,
colorImpairedMode: uiSettings.enableColorImpairedMode,
hasArtist: !!artistCount
@ -27,8 +28,8 @@ function createMapDispatchToProps(dispatch, props) {
dispatch(setCalendarDaysCount({ dayCount }));
},
onUnmonitoredChange(unmonitored) {
dispatch(setCalendarIncludeUnmonitored({ unmonitored }));
onFilterSelect(selectedFilterKey) {
dispatch(setCalendarFilter({ selectedFilterKey }));
}
};
}

@ -0,0 +1,16 @@
.labelContainer {
margin-bottom: 20px;
}
.label {
margin-bottom: 5px;
font-weight: bold;
}
.labelInputContainer {
width: 300px;
}
.rows {
margin-bottom: 100px;
}

@ -0,0 +1,192 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import FormInputGroup from 'Components/Form/FormInputGroup';
import Button from 'Components/Link/Button';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import FilterBuilderRow from './FilterBuilderRow';
import styles from './FilterBuilderModalContent.css';
class FilterBuilderModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const filters = [...props.filters];
// Push an empty filter if there aren't any filters. FilterBuilderRow
// will handle initializing the filter.
if (!filters.length) {
filters.push({});
}
this.state = {
label: props.label,
filters,
labelErrors: []
};
}
//
// Listeners
onLabelChange = ({ value }) => {
this.setState({ label: value });
}
onFilterChange = (index, filter) => {
const filters = [...this.state.filters];
filters.splice(index, 1, filter);
this.setState({
filters
});
}
onAddFilterPress = () => {
const filters = [...this.state.filters];
filters.push({});
this.setState({
filters
});
}
onRemoveFilterPress = (index) => {
const filters = [...this.state.filters];
filters.splice(index, 1);
this.setState({
filters
});
}
onSaveFilterPress = () => {
const {
customFilterKey: key,
onSaveCustomFilterPress,
onModalClose
} = this.props;
const {
label,
filters
} = this.state;
if (!label) {
this.setState({
labelErrors: [
{
message: 'Label is required'
}
]
});
return;
}
onSaveCustomFilterPress({ key, label, filters });
onModalClose();
}
//
// Render
render() {
const {
sectionItems,
filterBuilderProps,
onModalClose
} = this.props;
const {
label,
filters,
labelErrors
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Custom Filter
</ModalHeader>
<ModalBody>
<div className={styles.labelContainer}>
<div className={styles.label}>
Label
</div>
<div className={styles.labelInputContainer}>
<FormInputGroup
name="label"
value={label}
type={inputTypes.TEXT}
errors={labelErrors}
onChange={this.onLabelChange}
/>
</div>
</div>
<div className={styles.label}>Filters</div>
<div className={styles.rows}>
{
filters.map((filter, index) => {
return (
<FilterBuilderRow
key={index}
index={index}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
filterKey={filter.key}
filterValue={filter.value}
filterType={filter.type}
filterCount={filters.length}
onAddPress={this.onAddFilterPress}
onRemovePress={this.onRemoveFilterPress}
onFilterChange={this.onFilterChange}
/>
);
})
}
</div>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Cancel
</Button>
<Button
onPress={this.onSaveFilterPress}
>
Apply
</Button>
</ModalFooter>
</ModalContent>
);
}
}
FilterBuilderModalContent.propTypes = {
customFilterKey: PropTypes.string,
label: PropTypes.string.isRequired,
sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
onRemoveCustomFilterPress: PropTypes.func.isRequired,
onSaveCustomFilterPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default FilterBuilderModalContent;

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterBuilderModalContent from './FilterBuilderModalContent';
function createMapStateToProps() {
return createSelector(
(state, { customFilters }) => customFilters,
(state, { customFilterKey }) => customFilterKey,
(customFilters, customFilterKey) => {
if (customFilterKey) {
const customFilter = customFilters.find((c) => c.key === customFilterKey);
return {
customFilterKey: customFilter.key,
label: customFilter.label,
filters: customFilter.filters
};
}
return {
label: '',
filters: []
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderModalContent);

@ -0,0 +1,32 @@
.filterRow {
display: flex;
margin-bottom: 5px;
&:hover {
background-color: $tableRowHoverBackgroundColor;
}
}
.inputContainer {
flex: 0 1 200px;
margin-right: 10px;
}
.valueInputContainer {
flex: 0 1 300px;
margin-right: 10px;
}
.actionsContainer {
display: flex;
}
@media only screen and (max-width: $breakpointSmall) {
.filterRow {
display: block;
}
.inputContainer {
margin-bottom: 10px;
}
}

@ -0,0 +1,248 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
import SelectInput from 'Components/Form/SelectInput';
import IconButton from 'Components/Link/IconButton';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import styles from './FilterBuilderRow.css';
function getselectedFilterBuilderProp(filterBuilderProps, name) {
return filterBuilderProps.find((a) => {
return a.name === name;
});
}
function getFilterTypeOptions(filterBuilderProps, filterKey) {
const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, filterKey);
if (!selectedFilterBuilderProp) {
return [];
}
return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type];
}
function getDefaultFilterType(selectedFilterBuilderProp) {
return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type][0].key;
}
function getRowValueConnector(selectedFilterBuilderProp) {
if (!selectedFilterBuilderProp) {
return FilterBuilderRowValueConnector;
}
const valueType = selectedFilterBuilderProp.valueType;
switch (valueType) {
case filterBuilderValueTypes.INDEXER:
return IndexerFilterBuilderRowValueConnector;
case filterBuilderValueTypes.PROTOCOL:
return ProtocolFilterBuilderRowValue;
case filterBuilderValueTypes.QUALITY:
return QualityFilterBuilderRowValueConnector;
default:
return FilterBuilderRowValueConnector;
}
}
class FilterBuilderRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
selectedFilterBuilderProp: null
};
}
componentDidMount() {
const {
index,
filterKey,
filterBuilderProps,
onFilterChange
} = this.props;
if (filterKey) {
const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey);
this.setState({ selectedFilterBuilderProp });
return;
}
const selectedFilterBuilderProp = filterBuilderProps[0];
const filter = {
key: selectedFilterBuilderProp.name,
value: [],
type: getDefaultFilterType(selectedFilterBuilderProp)
};
this.setState({ selectedFilterBuilderProp }, () => {
onFilterChange(index, filter);
});
}
//
// Listeners
onFilterKeyChange = ({ value: key }) => {
const {
index,
filterBuilderProps,
onFilterChange
} = this.props;
const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, key);
const type = getDefaultFilterType(selectedFilterBuilderProp);
const filter = {
key,
value: [],
type
};
this.setState({ selectedFilterBuilderProp }, () => {
onFilterChange(index, filter);
});
}
onFilterChange = ({ name, value }) => {
const {
index,
filterKey,
filterValue,
filterType,
onFilterChange
} = this.props;
const filter = {
key: filterKey,
value: filterValue,
type: filterType
};
filter[name] = value;
onFilterChange(index, filter);
}
onAddPress = () => {
const {
index,
onAddPress
} = this.props;
onAddPress(index);
}
onRemovePress = () => {
const {
index,
onRemovePress
} = this.props;
onRemovePress(index);
}
//
// Render
render() {
const {
filterKey,
filterType,
filterValue,
filterCount,
filterBuilderProps
} = this.props;
const {
selectedFilterBuilderProp
} = this.state;
const keyOptions = filterBuilderProps.map((availablePropFilter) => {
return {
key: availablePropFilter.name,
value: availablePropFilter.label
};
});
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
return (
<div className={styles.filterRow}>
<div className={styles.inputContainer}>
{
filterKey &&
<SelectInput
name="key"
value={filterKey}
values={keyOptions}
onChange={this.onFilterKeyChange}
/>
}
</div>
<div className={styles.inputContainer}>
{
filterType &&
<SelectInput
name="type"
value={filterType}
values={getFilterTypeOptions(filterBuilderProps, filterKey)}
onChange={this.onFilterChange}
/>
}
</div>
<div className={styles.valueInputContainer}>
{
filterValue != null && !!selectedFilterBuilderProp &&
<ValueComponent
filterValue={filterValue}
selectedFilterBuilderProp={selectedFilterBuilderProp}
onChange={this.onFilterChange}
/>
}
</div>
<div className={styles.actionsContainer}>
<IconButton
name={icons.SUBTRACT}
isDisabled={filterCount === 1}
onPress={this.onRemovePress}
/>
<IconButton
name={icons.ADD}
onPress={this.onAddPress}
/>
</div>
</div>
);
}
}
FilterBuilderRow.propTypes = {
index: PropTypes.number.isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]),
filterType: PropTypes.string,
filterCount: PropTypes.number.isRequired,
filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
onFilterChange: PropTypes.func.isRequired,
onAddPress: PropTypes.func.isRequired,
onRemovePress: PropTypes.func.isRequired
};
export default FilterBuilderRow;

@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds, filterBuilderTypes } from 'Helpers/Props';
import TagInput, { tagShape } from 'Components/Form/TagInput';
import FilterBuilderRowValueTag from './FilterBuilderRowValueTag';
const NAME = 'value';
class FilterBuilderRowValue extends Component {
//
// Listeners
onTagAdd = (tag) => {
const {
filterValue,
selectedFilterBuilderProp,
onChange
} = this.props;
let id = tag.id;
if (id == null) {
id = selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ?
parseInt(tag.name) :
tag.name;
}
onChange({
name: NAME,
value: [...filterValue, id]
});
}
onTagDelete = ({ index }) => {
const {
filterValue,
onChange
} = this.props;
const value = filterValue.filter((v, i) => i !== index);
onChange({
name: NAME,
value
});
}
//
// Render
render() {
const {
filterValue,
tagList
} = this.props;
const hasItems = !!tagList.length;
const tags = filterValue.map((id) => {
if (hasItems) {
const tag = tagList.find((t) => t.id === id);
return {
id,
name: tag && tag.name
};
}
return {
id,
name: id
};
});
return (
<TagInput
name={NAME}
tags={tags}
tagList={tagList}
allowNew={!tagList.length}
kind={kinds.DEFAULT}
delimiters={[9, 13]}
maxSuggestionsLength={100}
minQueryLength={0}
tagComponent={FilterBuilderRowValueTag}
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
/>
);
}
}
FilterBuilderRowValue.propTypes = {
filterValue: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])).isRequired,
selectedFilterBuilderProp: PropTypes.object.isRequired,
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
onChange: PropTypes.func.isRequired
};
export default FilterBuilderRowValue;

@ -0,0 +1,50 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { filterBuilderTypes } from 'Helpers/Props';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createTagListSelector() {
return createSelector(
(state, { sectionItems }) => _.get(state, sectionItems),
(state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp,
(sectionItems, selectedFilterBuilderProp) => {
if (
selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ||
selectedFilterBuilderProp.type === filterBuilderTypes.STRING
) {
return [];
}
let items = [];
if (selectedFilterBuilderProp.optionsSelector) {
items = sectionItems.map(selectedFilterBuilderProp.optionsSelector);
} else {
items = sectionItems.map((item) => {
const name = item[selectedFilterBuilderProp.name];
return {
id: name,
name
};
});
}
return _.uniqBy(items, 'id');
}
);
}
function createMapStateToProps() {
return createSelector(
createTagListSelector(),
(tagList) => {
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

@ -0,0 +1,19 @@
.tag {
&.isLastTag {
.or {
display: none;
}
}
}
.label {
composes: label from 'Components/Label.css';
border-style: none;
font-size: 13px;
}
.or {
margin: 0 3px;
color: $themeDarkColor;
}

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import TagInputTag from 'Components/Form/TagInputTag';
import styles from './FilterBuilderRowValueTag.css';
function FilterBuilderRowValueTag(props) {
return (
<span
className={styles.tag}
>
<TagInputTag
kind={kinds.DEFAULT}
{...props}
/>
{
!props.isLastTag &&
<span className={styles.or}>
or
</span>
}
</span>
);
}
FilterBuilderRowValueTag.propTypes = {
isLastTag: PropTypes.bool.isRequired
};
export default FilterBuilderRowValueTag;

@ -0,0 +1,79 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchIndexers } from 'Store/Actions/settingsActions';
import { tagShape } from 'Components/Form/TagInput';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.indexers,
(qualityProfiles) => {
const {
isFetching,
isPopulated,
error,
items
} = qualityProfiles;
const tagList = items.map((item) => {
return {
id: item.id,
name: item.name
};
});
return {
isFetching,
isPopulated,
error,
tagList
};
}
);
}
const mapDispatchToProps = {
dispatchFetchIndexers: fetchIndexers
};
class IndexerFilterBuilderRowValueConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
if (!this.props.isPopulated) {
this.props.dispatchFetchIndexers();
}
}
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
...otherProps
} = this.props;
return (
<FilterBuilderRowValue
{...otherProps}
/>
);
}
}
IndexerFilterBuilderRowValueConnector.propTypes = {
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
dispatchFetchIndexers: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(IndexerFilterBuilderRowValueConnector);

@ -0,0 +1,18 @@
import React from 'react';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const protocols = [
{ id: 'torrent', name: 'Torrent' },
{ id: 'usenet', name: 'Usenet' }
];
function ProtocolFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={protocols}
{...props}
/>
);
}
export default ProtocolFilterBuilderRowValue;

@ -0,0 +1,75 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import getQualities from 'Utilities/Quality/getQualities';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import { tagShape } from 'Components/Form/TagInput';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const {
isFetchingSchema: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
schema
} = qualityProfiles;
const tagList = getQualities(schema.items);
return {
isFetching,
isPopulated,
error,
tagList
};
}
);
}
const mapDispatchToProps = {
dispatchFetchQualityProfileSchema: fetchQualityProfileSchema
};
class QualityFilterBuilderRowValueConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
if (!this.props.isPopulated) {
this.props.dispatchFetchQualityProfileSchema();
}
}
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
...otherProps
} = this.props;
return (
<FilterBuilderRowValue
{...otherProps}
/>
);
}
}
QualityFilterBuilderRowValueConnector.propTypes = {
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
dispatchFetchQualityProfileSchema: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QualityFilterBuilderRowValueConnector);

@ -0,0 +1,17 @@
.customFilter {
display: flex;
margin-bottom: 5px;
padding: 5px;
&:hover {
background-color: $tableRowHoverBackgroundColor;
}
}
.label {
flex: 0 1 300px;
}
.actions {
flex: 0 0 60px;
}

@ -0,0 +1,67 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import styles from './CustomFilter.css';
class CustomFilter extends Component {
//
// Listeners
onEditPress = () => {
const {
customFilterKey,
onEditPress
} = this.props;
onEditPress(customFilterKey);
}
onRemovePress = () => {
const {
customFilterKey,
onRemovePress
} = this.props;
onRemovePress({ key: customFilterKey });
}
//
// Render
render() {
const {
label
} = this.props;
return (
<div className={styles.customFilter}>
<div className={styles.label}>
{label}
</div>
<div className={styles.actions}>
<IconButton
name={icons.EDIT}
onPress={this.onEditPress}
/>
<IconButton
name={icons.REMOVE}
onPress={this.onRemovePress}
/>
</div>
</div>
);
}
}
CustomFilter.propTypes = {
customFilterKey: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
onEditPress: PropTypes.func.isRequired,
onRemovePress: PropTypes.func.isRequired
};
export default CustomFilter;

@ -0,0 +1,68 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import CustomFilter from './CustomFilter';
import styles from './CustomFiltersModalContent.css';
function CustomFiltersModalContent(props) {
const {
customFilters,
onAddCustomFilter,
onRemoveCustomFilterPress,
onEditCustomFilter,
onModalClose
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Custom Filters
</ModalHeader>
<ModalBody>
{
customFilters.map((customFilter, index) => {
return (
<CustomFilter
key={index}
customFilterKey={customFilter.key}
label={customFilter.label}
filters={customFilter.filters}
onRemovePress={onRemoveCustomFilterPress}
onEditPress={onEditCustomFilter}
/>
);
})
}
<div className={styles.addButtonContainer}>
<Button onPress={onAddCustomFilter}>
Add Custom Filter
</Button>
</div>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
CustomFiltersModalContent.propTypes = {
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
onAddCustomFilter: PropTypes.func.isRequired,
onRemoveCustomFilterPress: PropTypes.func.isRequired,
onEditCustomFilter: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CustomFiltersModalContent;

@ -0,0 +1,90 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector';
import CustomFiltersModalContent from './CustomFilters/CustomFiltersModalContent';
class FilterModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
filterBuilder: !props.customFilters.length,
customFilterKey: null
};
}
//
// Listeners
onAddCustomFilter = () => {
this.setState({
filterBuilder: true
});
}
onEditCustomFilter = (customFilterKey) => {
this.setState({
filterBuilder: true,
customFilterKey
});
}
onModalClose = () => {
this.setState({
filterBuilder: false,
customFilterKey: null
}, () => {
this.props.onModalClose();
});
}
//
// Render
render() {
const {
isOpen,
...otherProps
} = this.props;
const {
filterBuilder,
customFilterKey
} = this.state;
return (
<Modal
isOpen={isOpen}
onModalClose={this.onModalClose}
>
{
filterBuilder ?
<FilterBuilderModalContentConnector
{...otherProps}
customFilterKey={customFilterKey}
onModalClose={this.onModalClose}
/> :
<CustomFiltersModalContent
{...otherProps}
onAddCustomFilter={this.onAddCustomFilter}
onEditCustomFilter={this.onEditCustomFilter}
onModalClose={this.onModalClose}
/>
}
</Modal>
);
}
}
FilterModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired
};
export default FilterModal;

@ -31,7 +31,6 @@
.isDisabled {
opacity: 0.7;
cursor: not-allowed;
pointer-events: all !important;
}
.dropdownArrowContainer {

@ -58,7 +58,7 @@
}
.pathHighlighted {
background-color: $menuItemHoverColor;
background-color: $menuItemHoverBackgroundColor;
}
.fileBrowserButton {

@ -1,97 +1,77 @@
.container {
.inputContainer {
composes: input from 'Components/Form/Input.css';
display: flex;
flex-wrap: wrap;
position: relative;
padding: 0;
min-height: 35px;
height: auto;
}
.containerFocused {
outline: 0;
border-color: $inputFocusBorderColor;
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
}
.selectedTagContainer {
flex: 0 0 auto;
}
.selectedTag {
composes: label from 'Components/Label.css';
border-style: none;
font-size: 13px;
&.isFocused {
outline: 0;
border-color: $inputFocusBorderColor;
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
}
}
/* Selected Tag Kinds */
.info {
composes: info from 'Components/Label.css';
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.success {
composes: success from 'Components/Label.css';
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.warning {
composes: warning from 'Components/Label.css';
.tags {
flex: 0 0 auto;
max-width: 100%;
}
.danger {
composes: danger from 'Components/Label.css';
.input {
flex: 1 1 0%;
margin-left: 3px;
min-width: 20%;
max-width: 100%;
width: 0%;
border: none;
}
.searchInputContainer {
position: relative;
flex: 1 0 100px;
margin-top: 1px;
padding-left: 5px;
.suggestionsContainer {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.searchInput {
max-width: 100%;
font-size: 13px;
input {
margin: 0;
padding: 0;
max-width: 100%;
outline: none;
border: 0;
.containerOpen {
.suggestionsContainer {
position: absolute;
right: -1px;
left: -1px;
z-index: 1;
overflow-y: auto;
margin-top: 1px;
max-height: 110px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
}
.suggestions {
position: absolute;
z-index: 1;
overflow-y: auto;
max-height: 200px;
width: 100%;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
ul {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
li {
padding: 0 16px;
}
.suggestionsList {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
li mark {
font-weight: bold;
}
.suggestion {
padding: 0 16px;
cursor: default;
li:hover {
background-color: $menuItemHoverColor;
&:hover {
background-color: $menuItemHoverBackgroundColor;
}
}
.suggestionActive {
background-color: $menuItemHoverColor;
.suggestionHighlighted {
background-color: $menuItemHoverBackgroundColor;
}

@ -1,11 +1,27 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactTags from 'react-tag-autocomplete';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import { kinds } from 'Helpers/Props';
import TagInputInput from './TagInputInput';
import TagInputTag from './TagInputTag';
import styles from './TagInput.css';
function getTag(value, selectedIndex, suggestions, allowNew) {
if (selectedIndex == null && value) {
const existingTag = _.find(suggestions, { name: value });
if (existingTag) {
return existingTag;
} else if (allowNew) {
return { name: value };
}
} else if (selectedIndex != null) {
return suggestions[selectedIndex];
}
}
class TagInput extends Component {
//
@ -14,97 +30,240 @@ class TagInput extends Component {
constructor(props, context) {
super(props, context);
this._tagsRef = null;
this._inputRef = null;
this.state = {
value: '',
suggestions: [],
isFocused: false
};
this._autosuggestRef = null;
}
//
// Control
_setTagsRef = (ref) => {
this._tagsRef = ref;
_setAutosuggestRef = (ref) => {
this._autosuggestRef = ref;
}
if (ref) {
this._inputRef = this._tagsRef.input.input;
getSuggestionValue({ name }) {
return name;
}
this._inputRef.addEventListener('blur', this.onInputBlur);
} else if (this._inputRef) {
this._inputRef.removeEventListener('blur', this.onInputBlur);
}
shouldRenderSuggestions = (value) => {
return value.length >= this.props.minQueryLength;
}
renderSuggestion({ name }, { query }) {
return name;
}
//
// Listeners
onInputContainerPress = () => {
this._autosuggestRef.input.focus();
}
onTagAdd(tag) {
this.props.onTagAdd(tag);
this.setState({
value: '',
suggestions: []
});
}
onInputChange = (event, { newValue, method }) => {
const value = _.isObject(newValue) ? newValue.name : newValue;
if (method === 'type') {
this.setState({ value });
}
}
onInputKeyDown = (event) => {
const {
tags,
allowNew,
delimiters,
onTagDelete
} = this.props;
const {
value,
suggestions
} = this.state;
const keyCode = event.keyCode;
if (keyCode === 8 && !value.length) {
const index = tags.length - 1;
if (index >= 0) {
onTagDelete({ index, id: tags[index].id });
}
setTimeout(() => {
this.onSuggestionsFetchRequested({ value: '' });
});
event.preventDefault();
}
if (delimiters.includes(keyCode)) {
const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
const tag = getTag(value, selectedIndex, suggestions, allowNew);
if (tag) {
this.onTagAdd(tag);
}
event.preventDefault();
}
}
onInputFocus = () => {
this.setState({ isFocused: true });
}
onInputBlur = () => {
if (!this._tagsRef) {
this.setState({ isFocused: false });
if (!this._autosuggestRef) {
return;
}
const {
tagList,
allowNew
} = this.props;
const query = this._tagsRef.state.query.trim();
const {
value,
suggestions
} = this.state;
if (query) {
const existingTag = _.find(tagList, { name: query });
const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
const tag = getTag(value, selectedIndex, suggestions, allowNew);
if (existingTag) {
this._tagsRef.addTag(existingTag);
} else if (allowNew) {
this._tagsRef.addTag({ name: query });
}
if (tag) {
this.onTagAdd(tag);
}
}
onSuggestionsFetchRequested = ({ value }) => {
const lowerCaseValue = value.toLowerCase();
const {
tags,
tagList
} = this.props;
const suggestions = tagList.filter((tag) => {
return (
tag.name.toLowerCase().includes(lowerCaseValue) &&
!tags.some((t) => t.id === tag.id));
});
this.setState({ suggestions });
}
onSuggestionsClearRequested = () => {
// Required because props aren't always rendered, but no-op
// because we don't want to reset the paths after a path is selected.
}
onSuggestionSelected = (event, { suggestion }) => {
this.onTagAdd(suggestion);
}
//
// Render
render() {
renderInputComponent = (inputProps) => {
const {
tags,
tagList,
allowNew,
kind,
placeholder,
onTagAdd,
tagComponent,
onTagDelete
} = this.props;
const tagInputClassNames = {
root: styles.container,
rootFocused: styles.containerFocused,
selected: styles.selectedTagContainer,
selectedTag: classNames(styles.selectedTag, styles[kind]),
search: styles.searchInputContainer,
searchInput: styles.searchInput,
suggestions: styles.suggestions,
suggestionActive: styles.suggestionActive,
suggestionDisabled: styles.suggestionDisabled
return (
<TagInputInput
tags={tags}
kind={kind}
inputProps={inputProps}
isFocused={this.state.isFocused}
tagComponent={tagComponent}
onTagDelete={onTagDelete}
onInputContainerPress={this.onInputContainerPress}
/>
);
}
render() {
const {
placeholder,
hasError,
hasWarning
} = this.props;
const {
value,
suggestions,
isFocused
} = this.state;
const inputProps = {
className: styles.input,
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onFocus: this.onInputFocus,
onBlur: this.onInputBlur
};
const theme = {
container: classNames(
styles.inputContainer,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
containerOpen: styles.containerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted
};
return (
<ReactTags
ref={this._setTagsRef}
classNames={tagInputClassNames}
tags={tags}
suggestions={tagList}
allowNew={allowNew}
minQueryLength={1}
placeholder={placeholder}
delimiters={[9, 13, 32, 188]}
handleAddition={onTagAdd}
handleDelete={onTagDelete}
<Autosuggest
ref={this._setAutosuggestRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
shouldRenderSuggestions={this.shouldRenderSuggestions}
focusInputOnSuggestionClick={false}
renderSuggestion={this.renderSuggestion}
renderInputComponent={this.renderInputComponent}
onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
);
}
}
const tagShape = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired
export const tagShape = {
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
name: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired
};
TagInput.propTypes = {
@ -113,6 +272,11 @@ TagInput.propTypes = {
allowNew: PropTypes.bool.isRequired,
kind: PropTypes.oneOf(kinds.all).isRequired,
placeholder: PropTypes.string.isRequired,
delimiters: PropTypes.arrayOf(PropTypes.number).isRequired,
minQueryLength: PropTypes.number.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
tagComponent: PropTypes.func.isRequired,
onTagAdd: PropTypes.func.isRequired,
onTagDelete: PropTypes.func.isRequired
};
@ -120,7 +284,11 @@ TagInput.propTypes = {
TagInput.defaultProps = {
allowNew: true,
kind: kinds.INFO,
placeholder: ''
placeholder: '',
// Tab, enter, space and comma
delimiters: [9, 13, 32, 188],
minQueryLength: 1,
tagComponent: TagInputTag
};
export default TagInput;

@ -103,7 +103,7 @@ class TagInputConnector extends Component {
this.props.onChange({ name, value: newValue });
}
onTagDelete = (index) => {
onTagDelete = ({ index }) => {
const {
name,
value

@ -0,0 +1,6 @@
.inputContainer {
display: flex;
flex-wrap: wrap;
padding: 6px 16px;
cursor: default;
}

@ -0,0 +1,76 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import { tagShape } from './TagInput';
import styles from './TagInputInput.css';
class TagInputInput extends Component {
onMouseDown = (event) => {
event.preventDefault();
const {
isFocused,
onInputContainerPress
} = this.props;
if (isFocused) {
return;
}
onInputContainerPress();
}
render() {
const {
className,
tags,
inputProps,
kind,
tagComponent: TagComponent,
onTagDelete
} = this.props;
return (
<div
className={className}
component="div"
onMouseDown={this.onMouseDown}
>
{
tags.map((tag, index) => {
return (
<TagComponent
key={tag.id}
index={index}
tag={tag}
kind={kind}
isLastTag={index === tags.length - 1}
onDelete={onTagDelete}
/>
);
})
}
<input {...inputProps} />
</div>
);
}
}
TagInputInput.propTypes = {
className: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
inputProps: PropTypes.object.isRequired,
kind: PropTypes.oneOf(kinds.all).isRequired,
isFocused: PropTypes.bool.isRequired,
tagComponent: PropTypes.func.isRequired,
onTagDelete: PropTypes.func.isRequired,
onInputContainerPress: PropTypes.func.isRequired
};
TagInputInput.defaultProps = {
className: styles.inputContainer
};
export default TagInputInput;

@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import { tagShape } from './TagInput';
class TagInputTag extends Component {
//
// Listeners
onDelete = () => {
const {
index,
tag,
onDelete
} = this.props;
onDelete({
index,
id: tag.id
});
}
//
// Render
render() {
const {
tag,
kind
} = this.props;
return (
<Link onPress={this.onDelete}>
<Label kind={kind}>
{tag.name}
</Label>
</Link>
);
}
}
TagInputTag.propTypes = {
index: PropTypes.number.isRequired,
tag: PropTypes.shape(tagShape),
kind: PropTypes.oneOf(kinds.all).isRequired,
onDelete: PropTypes.func.isRequired
};
export default TagInputTag;

@ -1,68 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactTags from 'react-tag-autocomplete';
import classNames from 'classnames';
import { kinds } from 'Helpers/Props';
import styles from './TagInput.css';
class TextTagInput extends Component {
//
// Render
render() {
const {
tags,
allowNew,
kind,
placeholder,
onTagAdd,
onTagDelete
} = this.props;
const tagInputClassNames = {
root: styles.container,
rootFocused: styles.containerFocused,
selected: styles.selectedTagContainer,
selectedTag: classNames(styles.selectedTag, styles[kind]),
search: styles.searchInputContainer,
searchInput: styles.searchInput,
suggestions: styles.suggestions,
suggestionActive: styles.suggestionActive,
suggestionDisabled: styles.suggestionDisabled
};
return (
<ReactTags
classNames={tagInputClassNames}
tags={tags}
allowNew={allowNew}
minQueryLength={1}
placeholder={placeholder}
handleAddition={onTagAdd}
handleDelete={onTagDelete}
/>
);
}
}
const tagShape = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
};
TextTagInput.propTypes = {
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
allowNew: PropTypes.bool.isRequired,
kind: PropTypes.string.isRequired,
placeholder: PropTypes.string,
onTagAdd: PropTypes.func.isRequired,
onTagDelete: PropTypes.func.isRequired
};
TextTagInput.defaultProps = {
allowNew: true,
kind: kinds.INFO
};
export default TextTagInput;

@ -4,7 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import split from 'Utilities/String/split';
import TextTagInput from './TextTagInput';
import TagInput from './TagInput';
function createMapStateToProps() {
return createSelector(
@ -34,25 +34,27 @@ class TextTagInputConnector extends Component {
onTagAdd = (tag) => {
const {
name,
value
value,
onChange
} = this.props;
const newValue = split(value);
newValue.push(tag.name);
this.props.onChange({ name, value: newValue.join(',') });
onChange({ name, value: newValue.join(',') });
}
onTagDelete = (index) => {
onTagDelete = ({ index }) => {
const {
name,
value
value,
onChange
} = this.props;
const newValue = split(value);
newValue.splice(index, 1);
this.props.onChange({
onChange({
name,
value: newValue.join(',')
});
@ -63,7 +65,8 @@ class TextTagInputConnector extends Component {
render() {
return (
<TextTagInput
<TagInput
tagList={[]}
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
{...this.props}

@ -13,4 +13,8 @@
background-color: inherit;
color: $iconButtonHoverColor;
}
&.isDisabled {
color: $iconButtonDisabledColor;
}
}

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import Icon from 'Components/Icon';
import Link from './Link';
import styles from './IconButton.css';
@ -12,12 +13,18 @@ function IconButton(props) {
kind,
size,
isSpinning,
isDisabled,
...otherProps
} = props;
return (
<Link
className={className}
className={classNames(
className,
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled}
{...otherProps}
>
<Icon
@ -37,7 +44,8 @@ IconButton.propTypes = {
kind: PropTypes.string,
name: PropTypes.object.isRequired,
size: PropTypes.number,
isSpinning: PropTypes.bool
isSpinning: PropTypes.bool,
isDisabled: PropTypes.bool
};
IconButton.defaultProps = {

@ -10,7 +10,7 @@
cursor: pointer;
&:global(.isDisabled) {
pointer-events: none;
cursor: default;
}
}

@ -1,42 +1,107 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import Menu from 'Components/Menu/Menu';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import FilterMenuContent from './FilterMenuContent';
import Menu from './Menu';
import ToolbarMenuButton from './ToolbarMenuButton';
import styles from './FilterMenu.css';
function FilterMenu(props) {
const {
className,
children,
isDisabled,
...otherProps
} = props;
return (
<Menu
className={className}
{...otherProps}
>
<ToolbarMenuButton
iconName={icons.FILTER}
text="Filter"
isDisabled={isDisabled}
/>
{children}
</Menu>
);
class FilterMenu extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isFilterModalOpen: false
};
}
//
// Listeners
onCustomFiltersPress = () => {
this.setState({ isFilterModalOpen: true });
}
onFiltersModalClose = () => {
this.setState({ isFilterModalOpen: false });
}
//
// Render
render(props) {
const {
className,
isDisabled,
selectedFilterKey,
filters,
customFilters,
buttonComponent: ButtonComponent,
filterModalConnectorComponent: FilterModalConnectorComponent,
onFilterSelect,
...otherProps
} = this.props;
const showCustomFilters = !!FilterModalConnectorComponent;
return (
<div>
<Menu
className={className}
{...otherProps}
>
<ButtonComponent
iconName={icons.FILTER}
text="Filter"
isDisabled={isDisabled}
/>
<FilterMenuContent
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
showCustomFilters={showCustomFilters}
onFilterSelect={onFilterSelect}
onCustomFiltersPress={this.onCustomFiltersPress}
/>
</Menu>
{
showCustomFilters &&
<FilterModalConnectorComponent
isOpen={this.state.isFilterModalOpen}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
onFilterSelect={onFilterSelect}
onModalClose={this.onFiltersModalClose}
/>
}
</div>
);
}
}
FilterMenu.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
isDisabled: PropTypes.bool.isRequired
isDisabled: PropTypes.bool.isRequired,
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
buttonComponent: PropTypes.func.isRequired,
filterModalConnectorComponent: PropTypes.func,
onFilterSelect: PropTypes.func.isRequired
};
FilterMenu.defaultProps = {
className: styles.filterMenu,
isDisabled: false
isDisabled: false,
buttonComponent: ToolbarMenuButton
};
export default FilterMenu;

@ -0,0 +1,85 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MenuContent from './MenuContent';
import FilterMenuItem from './FilterMenuItem';
import MenuItem from './MenuItem';
import MenuItemSeparator from './MenuItemSeparator';
class FilterMenuContent extends Component {
//
// Render
render() {
const {
selectedFilterKey,
filters,
customFilters,
showCustomFilters,
onFilterSelect,
onCustomFiltersPress,
...otherProps
} = this.props;
return (
<MenuContent {...otherProps}>
{
filters.map((filter) => {
return (
<FilterMenuItem
key={filter.key}
filterKey={filter.key}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})
}
{
customFilters.map((filter) => {
return (
<FilterMenuItem
key={filter.key}
filterKey={filter.key}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})
}
{
showCustomFilters &&
<MenuItemSeparator />
}
{
showCustomFilters &&
<MenuItem onPress={onCustomFiltersPress}>
Custom Filters
</MenuItem>
}
</MenuContent>
);
}
}
FilterMenuContent.propTypes = {
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
showCustomFilters: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onCustomFiltersPress: PropTypes.func.isRequired
};
FilterMenuContent.defaultProps = {
showCustomFilters: false
};
export default FilterMenuContent;

@ -9,12 +9,11 @@ class FilterMenuItem extends Component {
onPress = () => {
const {
name,
value,
filterKey,
onPress
} = this.props;
onPress(name, value);
onPress(filterKey);
}
//
@ -22,18 +21,14 @@ class FilterMenuItem extends Component {
render() {
const {
name,
value,
filterKey,
filterValue,
selectedFilterKey,
...otherProps
} = this.props;
const isSelected = name === filterKey && value === filterValue;
return (
<SelectedMenuItem
isSelected={isSelected}
isSelected={filterKey === selectedFilterKey}
{...otherProps}
onPress={this.onPress}
/>
@ -42,16 +37,9 @@ class FilterMenuItem extends Component {
}
FilterMenuItem.propTypes = {
name: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
filterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};
FilterMenuItem.defaultProps = {
name: null,
value: null
};
export default FilterMenuItem;

@ -0,0 +1,5 @@
.separator {
overflow: hidden;
height: 1px;
background-color: $themeDarkColor;
}

@ -0,0 +1,10 @@
import React from 'react';
import styles from './MenuItemSeparator.css';
function MenuItemSeparator() {
return (
<div className={styles.separator} />
);
}
export default MenuItemSeparator;

@ -0,0 +1,11 @@
.menuButton {
composes: menuButton from './MenuButton.css';
&:hover {
color: #666;
}
}
.label {
margin-left: 5px;
}

@ -0,0 +1,36 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import MenuButton from 'Components/Menu/MenuButton';
import styles from './PageMenuButton.css';
function PageMenuButton(props) {
const {
iconName,
text,
...otherProps
} = props;
return (
<MenuButton
className={styles.menuButton}
{...otherProps}
>
<Icon
name={iconName}
size={18}
/>
<div className={styles.label}>
{text}
</div>
</MenuButton>
);
}
PageMenuButton.propTypes = {
iconName: PropTypes.object.isRequired,
text: PropTypes.string
};
export default PageMenuButton;

@ -5,9 +5,7 @@
font-size: inherit;
}
.disabledButton {
composes: button from 'Components/Link/IconButton.css';
.isDisabled {
color: $disabledColor;
cursor: not-allowed;
}

@ -1,10 +1,22 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import styles from './MonitorToggleButton.css';
function getTooltip(monitored, isDisabled) {
if (isDisabled) {
return 'Cannot toogle monitored state when artist is unmonitored';
}
if (monitored) {
return 'Monitored, click to unmonitor';
}
return 'Unmonitored, click to monitor';
}
class MonitorToggleButton extends Component {
//
@ -29,27 +41,18 @@ class MonitorToggleButton extends Component {
...otherProps
} = this.props;
const monitoredMessage = 'Monitored, click to unmonitor';
const unmonitoredMessage = 'Unmonitored, click to monitor';
const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
if (isDisabled) {
return (
<Icon
className={styles.disabledButton}
size={size}
name={iconName}
title="Cannot toogle monitored state when artist is unmonitored"
/>
);
}
return (
<SpinnerIconButton
className={className}
className={classNames(
className,
isDisabled && styles.isDisabled
)}
name={iconName}
size={size}
title={monitored ? monitoredMessage : unmonitoredMessage}
title={getTooltip(monitored, isDisabled)}
isDisabled={isDisabled}
isSpinning={isSaving}
{...otherProps}
onPress={this.onPress}

@ -1,9 +1,6 @@
.wrapper {
display: flex;
}
.icon {
line-height: 24px !important;
align-items: center;
}
.input {

@ -155,7 +155,7 @@ class ArtistSearchInput extends Component {
this.reset();
}
onSuggestionSelected = (event, { suggestion, sectionIndex }) => {
onSuggestionSelected = (event, { suggestion }) => {
if (suggestion.type === ADD_NEW_TYPE) {
this.props.onGoToAddNewArtist(this.state.value);
} else {
@ -181,7 +181,7 @@ class ArtistSearchInput extends Component {
});
}
if (suggestions.length <= 3) {
if (value.length >= 3) {
suggestionGroups.push({
title: 'Add New Artist',
suggestions: [
@ -218,10 +218,7 @@ class ArtistSearchInput extends Component {
return (
<div className={styles.wrapper}>
<Icon
className={styles.icon}
name={icons.SEARCH}
/>
<Icon name={icons.SEARCH} />
<Autosuggest
ref={this.setAutosuggestRef}

@ -13,12 +13,6 @@
margin-right: 8px;
}
.separator {
overflow: hidden;
height: 1px;
background-color: $themeDarkColor;
}
@media only screen and (max-width: $breakpointSmall) {
.menuButton {
margin-right: 5px;

@ -6,6 +6,7 @@ import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import styles from './PageHeaderActionsMenu.css';
function PageHeaderActionsMenu(props) {
@ -34,7 +35,7 @@ function PageHeaderActionsMenu(props) {
Keyboard Shortcuts
</MenuItem>
<div className={styles.separator} />
<MenuItemSeparator />
<MenuItem onPress={onRestartPress}>
<Icon

@ -336,7 +336,7 @@ class PageSidebar extends Component {
if (isSidebarVisible && (touchStartX > 210 || touchStartX < 180)) {
return;
} else if (!isSidebarVisible && touchStartX > 30) {
} else if (!isSidebarVisible && touchStartX > 40) {
return;
}
@ -347,22 +347,29 @@ class PageSidebar extends Component {
onTouchMove = (event) => {
const touches = event.touches;
const currentTouchX = touches[0].pageX;
const currentTouchY = touches[0].pageY;
// const currentTouchY = touches[0].pageY;
// const isSidebarVisible = this.props.isSidebarVisible;
if (!this._touchStartX) {
return;
}
if (Math.abs(this._touchStartY - currentTouchY) > 20) {
this.setState({
transition: 'none',
transform: 0
});
// This is a bit funky when trying to close and you scroll
// vertical too much by mistake, commenting out for now.
// TODO: Evaluate if this should be nuked
return;
}
// if (Math.abs(this._touchStartY - currentTouchY) > 40) {
// const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1;
// this.setState({
// transition: 'none',
// transform
// });
// return;
// }
if (Math.abs(this._touchStartX - currentTouchX) < 20) {
if (Math.abs(this._touchStartX - currentTouchX) < 40) {
return;
}

@ -7,6 +7,10 @@
&:hover {
color: $toobarButtonHoverColor;
}
&.isDisabled {
color: $disabledColor;
}
}
.isDisabled {

@ -2,6 +2,6 @@
transition: background-color 500ms;
&:hover {
background-color: #fafbfc;
background-color: $tableRowHoverBackgroundColor;
}
}

@ -50,6 +50,16 @@ class VirtualTable extends Component {
this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
}
componentDidUpdate(prevProps, preState) {
const scrollIndex = this.props.scrollIndex;
if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) {
const scrollTop = (scrollIndex + 1) * ROW_HEIGHT + 20;
this.props.onScroll({ scrollTop });
}
}
//
// Control
@ -57,12 +67,6 @@ class VirtualTable extends Component {
return this.props.items[index];
}
scrollToRow = (rowIndex) => {
const scrollTop = (rowIndex + 1) * ROW_HEIGHT + 20;
this.props.onScroll({ scrollTop });
}
//
// Listeners
@ -144,6 +148,7 @@ VirtualTable.propTypes = {
className: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
scrollTop: PropTypes.number.isRequired,
scrollIndex: PropTypes.number,
contentBody: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
header: PropTypes.node.isRequired,

@ -0,0 +1,33 @@
import * as filterTypes from './filterTypes';
export const EXACT = 'exact';
export const NUMBER = 'number';
export const STRING = 'string';
export const all = [
EXACT,
NUMBER,
STRING
];
export const possibleFilterTypes = {
[EXACT]: [
{ key: filterTypes.EQUAL, value: 'Is' },
{ key: filterTypes.NOT_EQUAL, value: 'Is Not' }
],
[NUMBER]: [
{ key: filterTypes.EQUAL, value: 'Equal' },
{ key: filterTypes.GREATER_THAN, value: 'Greater Than' },
{ key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'Greater Than or Equal' },
{ key: filterTypes.LESS_THAN, value: 'Less Than' },
{ key: filterTypes.LESS_THAN_OR_EQUAL, value: 'Less Than or Equal' },
{ key: filterTypes.NOT_EQUAL, value: 'Not Equal' }
],
[STRING]: [
{ key: filterTypes.CONTAINS, value: 'Contains' },
{ key: filterTypes.EQUAL, value: 'Equal' },
{ key: filterTypes.NOT_EQUAL, value: 'Not Equal' }
]
};

@ -0,0 +1,4 @@
export const DEFAULT = 'default';
export const INDEXER = 'indexer';
export const PROTOCOL = 'protocol';
export const QUALITY = 'quality';

@ -1,5 +1,7 @@
import * as align from './align';
import * as inputTypes from './inputTypes';
import * as filterBuilderTypes from './filterBuilderTypes';
import * as filterBuilderValueTypes from './filterBuilderValueTypes';
import * as filterTypes from './filterTypes';
import * as icons from './icons';
import * as kinds from './kinds';
@ -12,6 +14,8 @@ import * as tooltipPositions from './tooltipPositions';
export {
align,
inputTypes,
filterBuilderTypes,
filterBuilderValueTypes,
filterTypes,
icons,
kinds,

@ -83,6 +83,22 @@ class MediaManagement extends Component {
{...settings.createEmptyArtistFolders}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>Delete empty folders</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteEmptyFolders"
helpText="Delete empty artist and album folders during disk scan and when track files are deleted"
onChange={onInputChange}
{...settings.deleteEmptyFolders}
/>
</FormGroup>
</FieldSet>
}

@ -0,0 +1,65 @@
import customFilterHandlers from 'Utilities/customFilterHandlers';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import generateUUIDv4 from 'Utilities/String/generateUUIDv4';
function createRemoveCustomFilterReducer(section) {
return (state, { payload }) => {
const newState = getSectionState(state, section);
const index = newState.customFilters.findIndex((c) => c.key === payload.key);
newState.customFilters = [...newState.customFilters];
newState.customFilters.splice(index, 1);
// Reset the selected filter to the first filter if the selected filter
// is being deleted.
// TODO: Server side collections need to have their collections refetched
if (newState.selectedFilterKey === payload.key) {
newState.selectedFilterKey = newState.filters[0].key;
}
return updateSectionState(state, section, newState);
};
}
function createSaveCustomFilterReducer(section) {
return (state, { payload }) => {
const newState = getSectionState(state, section);
const {
label,
filters
} = payload;
let key = payload.key;
newState.customFilters = [...newState.customFilters];
if (key) {
const index = newState.customFilters.findIndex((c) => c.key === key);
newState.customFilters.splice(index, 1, { key, label, filters });
} else {
key = generateUUIDv4();
newState.customFilters.push({
key,
label,
filters
});
}
// TODO: Server side collections need to have their collections refetched
newState.selectedFilterKey = key;
return updateSectionState(state, section, newState);
};
}
export default function createCustomFilterReducers(section, handlers) {
return {
[handlers[customFilterHandlers.REMOVE]]: createRemoveCustomFilterReducer(section),
[handlers[customFilterHandlers.SAVE]]: createSaveCustomFilterReducer(section)
};
}

@ -1,14 +1,11 @@
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import { filterTypes } from 'Helpers/Props';
function createSetClientSideCollectionFilterReducer(section) {
return (state, { payload }) => {
const newState = getSectionState(state, section);
newState.filterKey = payload.filterKey;
newState.filterValue = payload.filterValue;
newState.filterType = payload.filterType || filterTypes.EQUAL;
newState.selectedFilterKey = payload.selectedFilterKey;
return updateSectionState(state, section, newState);
};

@ -2,40 +2,40 @@ import $ from 'jquery';
import updateAlbums from 'Utilities/Album/updateAlbums';
import getSectionState from 'Utilities/State/getSectionState';
function createBatchToggleAlbumMonitoredHandler(section) {
return function(payload) {
return function(dispatch, getState) {
const {
albumIds,
function createBatchToggleAlbumMonitoredHandler(section, fetchHandler) {
return function(getState, payload, dispatch) {
const {
albumIds,
monitored
} = payload;
const state = getSectionState(getState(), section, true);
dispatch(updateAlbums(section, state.items, albumIds, {
isSaving: true
}));
const promise = $.ajax({
url: '/album/monitor',
method: 'PUT',
data: JSON.stringify({ albumIds, monitored }),
dataType: 'json'
});
promise.done(() => {
dispatch(updateAlbums(section, state.items, albumIds, {
isSaving: false,
monitored
} = payload;
const state = getSectionState(getState(), section, true);
updateAlbums(dispatch, section, state.items, albumIds, {
isSaving: true
});
const promise = $.ajax({
url: '/album/monitor',
method: 'PUT',
data: JSON.stringify({ albumIds, monitored }),
dataType: 'json'
});
promise.done(() => {
updateAlbums(dispatch, section, state.items, albumIds, {
isSaving: false,
monitored
});
});
promise.fail(() => {
updateAlbums(dispatch, section, state.items, albumIds, {
isSaving: false
});
});
};
}));
dispatch(fetchHandler());
});
promise.fail(() => {
dispatch(updateAlbums(section, state.items, albumIds, {
isSaving: false
}));
});
};
}

@ -1,6 +1,7 @@
import _ from 'lodash';
import $ from 'jquery';
import { batchActions } from 'redux-batched-actions';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import getSectionState from 'Utilities/State/getSectionState';
import { set, updateServerSideCollection } from '../baseActions';
@ -15,11 +16,21 @@ function createFetchServerSideCollectionHandler(section, url) {
_.pick(sectionState, [
'pageSize',
'sortDirection',
'sortKey',
'filterKey',
'filterValue'
'sortKey'
]));
const {
selectedFilterKey,
filters,
customFilters
} = sectionState;
const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters);
selectedFilters.forEach((filter) => {
data[filter.key] = filter.value;
});
const promise = $.ajax({
url,
data

@ -1,42 +0,0 @@
import $ from 'jquery';
import updateAlbums from 'Utilities/Album/updateAlbums';
import getSectionState from 'Utilities/State/getSectionState';
function createToggleAlbumMonitoredHandler(section) {
return function(payload) {
return function(dispatch, getState) {
const {
albumId,
monitored
} = payload;
const state = getSectionState(getState(), section, true);
updateAlbums(dispatch, section, state.items, [albumId], {
isSaving: true
});
const promise = $.ajax({
url: `/album/${albumId}`,
method: 'PUT',
data: JSON.stringify({ monitored }),
dataType: 'json'
});
promise.done(() => {
updateAlbums(dispatch, section, state.items, [albumId], {
isSaving: false,
monitored
});
});
promise.fail(() => {
updateAlbums(dispatch, section, state.items, [albumId], {
isSaving: false
});
});
};
};
}
export default createToggleAlbumMonitoredHandler;

@ -46,10 +46,9 @@ export const actionHandlers = handleThunks({
const queryParams = {
pageSize: 1000,
page: 1,
filterKey: 'albumId',
filterValue: payload.albumId,
sortKey: 'date',
sortDirection: sortDirections.DESCENDING
sortDirection: sortDirections.DESCENDING,
albumId: payload.albumId
};
const promise = $.ajax({

@ -2,7 +2,7 @@ import _ from 'lodash';
import $ from 'jquery';
import { createAction } from 'redux-actions';
import getMonitoringOptions from 'Utilities/Artist/getMonitoringOptions';
import { filterTypes, sortDirections } from 'Helpers/Props';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
@ -25,17 +25,17 @@ export const defaultState = {
sortDirection: sortDirections.ASCENDING,
secondarySortKey: 'sortName',
secondarySortDirection: sortDirections.ASCENDING,
filterKey: null,
filterValue: null,
filterType: filterTypes.EQUAL
selectedFilterKey: 'all',
// filters come from artistActions
customFilters: []
// filterPredicates come from artistActions
};
export const persistState = [
'albumStudio.sortKey',
'albumStudio.sortDirection',
'albumStudio.filterKey',
'albumStudio.filterValue',
'albumStudio.filterType'
'albumStudio.selectedFilterKey',
'albumStudio.customFilters'
];
//

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save