mirror of
https://github.com/lidarr/lidarr.git
synced 2025-07-08 05:51:47 -07:00
New: Custom Filtering for UI (#234)
This commit is contained in:
parent
c6873014c7
commit
7354e02bff
154 changed files with 3498 additions and 1370 deletions
|
@ -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);
|
32
frontend/src/Components/Filter/Builder/FilterBuilderRow.css
Normal file
32
frontend/src/Components/Filter/Builder/FilterBuilderRow.css
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
248
frontend/src/Components/Filter/Builder/FilterBuilderRow.js
Normal file
248
frontend/src/Components/Filter/Builder/FilterBuilderRow.js
Normal file
|
@ -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;
|
100
frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
Normal file
100
frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
Normal file
|
@ -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;
|
||||
}
|
67
frontend/src/Components/Filter/CustomFilters/CustomFilter.js
Normal file
67
frontend/src/Components/Filter/CustomFilters/CustomFilter.js
Normal file
|
@ -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,3 @@
|
|||
.addButtonContainer {
|
||||
margin-top: 15px;
|
||||
}
|
|
@ -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;
|
90
frontend/src/Components/Filter/FilterModal.js
Normal file
90
frontend/src/Components/Filter/FilterModal.js
Normal file
|
@ -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;
|
||||
|
||||
&.isFocused {
|
||||
outline: 0;
|
||||
border-color: $inputFocusBorderColor;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
|
||||
}
|
||||
}
|
||||
|
||||
.containerFocused {
|
||||
outline: 0;
|
||||
border-color: $inputFocusBorderColor;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
|
||||
.hasError {
|
||||
composes: hasError from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.selectedTagContainer {
|
||||
.hasWarning {
|
||||
composes: hasWarning from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.tags {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.selectedTag {
|
||||
composes: label from 'Components/Label.css';
|
||||
|
||||
border-style: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Selected Tag Kinds */
|
||||
|
||||
.info {
|
||||
composes: info from 'Components/Label.css';
|
||||
}
|
||||
|
||||
.success {
|
||||
composes: success from 'Components/Label.css';
|
||||
}
|
||||
|
||||
.warning {
|
||||
composes: warning from 'Components/Label.css';
|
||||
}
|
||||
|
||||
.danger {
|
||||
composes: danger from 'Components/Label.css';
|
||||
}
|
||||
|
||||
.searchInputContainer {
|
||||
position: relative;
|
||||
flex: 1 0 100px;
|
||||
margin-top: 1px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
border: 0;
|
||||
.input {
|
||||
flex: 1 1 0%;
|
||||
margin-left: 3px;
|
||||
min-width: 20%;
|
||||
max-width: 100%;
|
||||
width: 0%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.suggestionsContainer {
|
||||
@add-mixin scrollbar;
|
||||
@add-mixin scrollbarTrack;
|
||||
@add-mixin scrollbarThumb;
|
||||
}
|
||||
|
||||
.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;
|
||||
.suggestionsList {
|
||||
margin: 5px 0;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 5px 0;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
.suggestion {
|
||||
padding: 0 16px;
|
||||
cursor: default;
|
||||
|
||||
li {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
li mark {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
6
frontend/src/Components/Form/TagInputInput.css
Normal file
6
frontend/src/Components/Form/TagInputInput.css
Normal file
|
@ -0,0 +1,6 @@
|
|||
.inputContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 16px;
|
||||
cursor: default;
|
||||
}
|
76
frontend/src/Components/Form/TagInputInput.js
Normal file
76
frontend/src/Components/Form/TagInputInput.js
Normal file
|
@ -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;
|
52
frontend/src/Components/Form/TagInputTag.js
Normal file
52
frontend/src/Components/Form/TagInputTag.js
Normal file
|
@ -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;
|
||||
class FilterMenu extends Component {
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={className}
|
||||
{...otherProps}
|
||||
>
|
||||
<ToolbarMenuButton
|
||||
iconName={icons.FILTER}
|
||||
text="Filter"
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
//
|
||||
// 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;
|
||||
|
|
85
frontend/src/Components/Menu/FilterMenuContent.js
Normal file
85
frontend/src/Components/Menu/FilterMenuContent.js
Normal file
|
@ -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;
|
||||
|
|
5
frontend/src/Components/Menu/MenuItemSeparator.css
Normal file
5
frontend/src/Components/Menu/MenuItemSeparator.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.separator {
|
||||
overflow: hidden;
|
||||
height: 1px;
|
||||
background-color: $themeDarkColor;
|
||||
}
|
10
frontend/src/Components/Menu/MenuItemSeparator.js
Normal file
10
frontend/src/Components/Menu/MenuItemSeparator.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import styles from './MenuItemSeparator.css';
|
||||
|
||||
function MenuItemSeparator() {
|
||||
return (
|
||||
<div className={styles.separator} />
|
||||
);
|
||||
}
|
||||
|
||||
export default MenuItemSeparator;
|
11
frontend/src/Components/Menu/PageMenuButton.css
Normal file
11
frontend/src/Components/Menu/PageMenuButton.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
.menuButton {
|
||||
composes: menuButton from './MenuButton.css';
|
||||
|
||||
&:hover {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-left: 5px;
|
||||
}
|
36
frontend/src/Components/Menu/PageMenuButton.js
Normal file
36
frontend/src/Components/Menu/PageMenuButton.js
Normal file
|
@ -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;
|
||||
|
||||
if (Math.abs(this._touchStartX - currentTouchX) < 20) {
|
||||
// this.setState({
|
||||
// transition: 'none',
|
||||
// transform
|
||||
// });
|
||||
|
||||
// return;
|
||||
// }
|
||||
|
||||
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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue