diff --git a/frontend/src/Helpers/dragTypes.js b/frontend/src/Helpers/dragTypes.js index ed6ba080d..b4d02ffb0 100644 --- a/frontend/src/Helpers/dragTypes.js +++ b/frontend/src/Helpers/dragTypes.js @@ -1,3 +1,4 @@ export const QUALITY_PROFILE_ITEM = 'qualityProfileItem'; +export const QUALITY_PROFILE_FORMAT_ITEM = 'qualityProfileFormatItem'; export const DELAY_PROFILE = 'delayProfile'; export const TABLE_COLUMN = 'tableColumn'; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js index d389ff536..510f5b555 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js @@ -1,11 +1,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import split from 'Utilities/String/split'; import { icons, kinds } from 'Helpers/Props'; import Card from 'Components/Card'; import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; -// import EditCustomFormatModalConnector from './EditCustomFormatModalConnector'; +import EditCustomFormatModalConnector from './EditCustomFormatModalConnector'; import styles from './CustomFormat.css'; class CustomFormat extends Component { @@ -62,15 +63,15 @@ class CustomFormat extends Component { render() { const { - // id, + id, name, - items, + formatTags, isDeleting } = this.props; return ( @@ -87,32 +88,31 @@ class CustomFormat extends Component { /> -
+
{ - items.map((item) => { - if (!item.allowed) { + split(formatTags).map((item) => { + if (!item) { return null; } return ( ); }) }
- {/* */} + /> -
+
{ items.sort(sortByName).map((item) => { return ( @@ -85,11 +85,10 @@ class CustomFormats extends Component {
- {/* */} + /> diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModal.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModal.js new file mode 100644 index 000000000..3011f928c --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModal.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditCustomFormatModalContentConnector from './EditCustomFormatModalContentConnector'; + +class EditCustomFormatModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 'auto' + }; + } + + // + // Listeners + + onContentHeightChange = (height) => { + if (this.state.height === 'auto' || height > this.state.height) { + this.setState({ height }); + } + } + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +EditCustomFormatModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditCustomFormatModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js new file mode 100644 index 000000000..d500cfd9f --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditCustomFormatModal from './EditCustomFormatModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditCustomFormatModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.customFormats' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditCustomFormatModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditCustomFormatModalConnector); diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css new file mode 100644 index 000000000..2f6589933 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css @@ -0,0 +1,18 @@ +.formGroupsContainer { + display: flex; + flex-wrap: wrap; +} + +.formGroupWrapper { + flex: 0 0 calc($formGroupSmallWidth - 100px); +} + +.deleteButtonContainer { + margin-right: auto; +} + +@media only screen and (max-width: $breakpointLarge) { + .formGroupsContainer { + display: block; + } +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js new file mode 100644 index 000000000..ca1389665 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +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 Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditCustomFormatModalContent.css'; + +class EditCustomFormatModalContent extends Component { + + // + // Render + + render() { + const { + isFetching, + error, + isSaving, + saveError, + item, + onInputChange, + onSavePress, + onModalClose, + ...otherProps + } = this.props; + + const { + id, + name, + formatTags + } = item; + + return ( + + + + {id ? 'Edit Custom Format' : 'Add Custom Format'} + + + +
+ { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new custom format, please try again.
+ } + + { + !isFetching && !error && +
+
+
+ + + Name + + + + + + + + Format Tags + + + + +
+
+
+ + } +
+
+ + + + + Save + + +
+ ); + } +} + +EditCustomFormatModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onContentHeightChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditCustomFormatModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContentConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContentConnector.js new file mode 100644 index 000000000..c73fcf174 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContentConnector.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setCustomFormatValue, saveCustomFormat } from 'Store/Actions/settingsActions'; +import EditCustomFormatModalContent from './EditCustomFormatModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('customFormats'), + (advancedSettings, customFormat) => { + return { + advancedSettings, + ...customFormat + }; + } + ); +} + +const mapDispatchToProps = { + setCustomFormatValue, + saveCustomFormat +}; + +class EditCustomFormatModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setCustomFormatValue({ name, value }); + } + + onSavePress = () => { + this.props.saveCustomFormat({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditCustomFormatModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setCustomFormatValue: PropTypes.func.isRequired, + saveCustomFormat: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditCustomFormatModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js index 6f6a85ae7..e72ff3665 100644 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js @@ -15,6 +15,7 @@ import FormGroup from 'Components/Form/FormGroup'; import FormLabel from 'Components/Form/FormLabel'; import FormInputGroup from 'Components/Form/FormInputGroup'; import QualityProfileItems from './QualityProfileItems'; +import QualityProfileFormatItems from './QualityProfileFormatItems'; import styles from './EditQualityProfileModalContent.css'; const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding); @@ -92,6 +93,7 @@ class EditQualityProfileModalContent extends Component { isSaving, saveError, qualities, + customFormats, languages, item, isInUse, @@ -109,11 +111,13 @@ class EditQualityProfileModalContent extends Component { name, upgradeAllowed, cutoff, + formatCutoff, language, - items + items, + formatItems } = item; - const languageId = language.value.id; + const languageId = language ? language.value.id : 0; return ( @@ -181,7 +185,7 @@ class EditQualityProfileModalContent extends Component { upgradeAllowed.value && - Upgrade Until + Upgrade Until Quality } + { + upgradeAllowed.value && + + + Upgrade Until Format + + + + + } + Language @@ -220,6 +242,15 @@ class EditQualityProfileModalContent extends Component { {...otherProps} />
+ +
+ +
@@ -282,6 +313,7 @@ EditQualityProfileModalContent.propTypes = { isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + customFormats: PropTypes.arrayOf(PropTypes.object).isRequired, languages: PropTypes.arrayOf(PropTypes.object).isRequired, item: PropTypes.object.isRequired, isInUse: PropTypes.bool.isRequired, diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js index b73f74d80..a1609338c 100644 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js @@ -61,6 +61,36 @@ function createQualitiesSelector() { ); } +function createFormatsSelector() { + return createSelector( + createProviderSettingsSelector('qualityProfiles'), + (customFormat) => { + const items = customFormat.item.formatItems; + if (!items || !items.value) { + return []; + } + + return _.reduceRight(items.value, (result, { allowed, id, name, format }) => { + if (allowed) { + if (id) { + result.push({ + key: id, + value: name + }); + } else { + result.push({ + key: format.id, + value: format.name + }); + } + } + + return result; + }, []); + } + ); +} + function createLanguagesSelector() { return createSelector( (state) => state.settings.languages, @@ -87,11 +117,13 @@ function createMapStateToProps() { return createSelector( createProviderSettingsSelector('qualityProfiles'), createQualitiesSelector(), + createFormatsSelector(), createLanguagesSelector(), createProfileInUseSelector('qualityProfileId'), - (qualityProfile, qualities, languages, isInUse) => { + (qualityProfile, qualities, customFormats, languages, isInUse) => { return { qualities, + customFormats, languages, ...qualityProfile, isInUse @@ -161,6 +193,30 @@ class EditQualityProfileModalContentConnector extends Component { } } + ensureFormatCutoff = (qualityProfile) => { + const cutoff = qualityProfile.formatCutoff.value; + + const cutoffItem = _.find(qualityProfile.formatItems.value, (i) => { + if (!cutoff) { + return false; + } + + return i.id === cutoff || (i.format && i.format.id === cutoff); + }); + + // If the cutoff isn't allowed anymore or there isn't a cutoff set one + if (!cutoff || !cutoffItem || !cutoffItem.allowed) { + const firstAllowed = _.find(qualityProfile.formatItems.value, { allowed: true }); + let cutoffId = null; + + if (firstAllowed) { + cutoffId = firstAllowed.format ? firstAllowed.format.id : firstAllowed.id; + } + + this.props.setQualityProfileValue({ name: 'formatCutoff', value: cutoffId }); + } + } + // // Listeners @@ -211,6 +267,21 @@ class EditQualityProfileModalContentConnector extends Component { this.ensureCutoff(qualityProfile); } + onQualityProfileFormatItemAllowedChange = (id, allowed) => { + const qualityProfile = _.cloneDeep(this.props.item); + const formatItems = qualityProfile.formatItems.value; + const item = _.find(qualityProfile.formatItems.value, (i) => i.format && i.format.id === id); + + item.allowed = allowed; + + this.props.setQualityProfileValue({ + name: 'formatItems', + value: formatItems + }); + + this.ensureFormatCutoff(qualityProfile); + } + onItemGroupAllowedChange = (id, allowed) => { const qualityProfile = _.cloneDeep(this.props.item); const items = qualityProfile.items.value; @@ -427,6 +498,39 @@ class EditQualityProfileModalContentConnector extends Component { }); } + onQualityProfileFormatItemDragMove = (dragIndex, dropIndex) => { + if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { + this.setState({ + dragIndex, + dropIndex + }); + } + } + + onQualityProfileFormatItemDragEnd = ({ id }, didDrop) => { + const { + dragIndex, + dropIndex + } = this.state; + + if (didDrop && dropIndex !== null) { + const qualityProfile = _.cloneDeep(this.props.item); + + const formats = qualityProfile.formatItems.value.splice(dragIndex, 1); + qualityProfile.formatItems.value.splice(dropIndex, 0, formats[0]); + + this.props.setQualityProfileValue({ + name: 'formatItems', + value: qualityProfile.formatItems.value + }); + } + + this.setState({ + dragIndex: null, + dropIndex: null + }); + } + onToggleEditGroupsMode = () => { this.setState({ editGroups: !this.state.editGroups }); } @@ -450,10 +554,13 @@ class EditQualityProfileModalContentConnector extends Component { onCreateGroupPress={this.onCreateGroupPress} onDeleteGroupPress={this.onDeleteGroupPress} onQualityProfileItemAllowedChange={this.onQualityProfileItemAllowedChange} + onQualityProfileFormatItemAllowedChange={this.onQualityProfileFormatItemAllowedChange} onItemGroupAllowedChange={this.onItemGroupAllowedChange} onItemGroupNameChange={this.onItemGroupNameChange} onQualityProfileItemDragMove={this.onQualityProfileItemDragMove} onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd} + onQualityProfileFormatItemDragMove={this.onQualityProfileFormatItemDragMove} + onQualityProfileFormatItemDragEnd={this.onQualityProfileFormatItemDragEnd} onToggleEditGroupsMode={this.onToggleEditGroupsMode} /> ); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.css new file mode 100644 index 000000000..1c139079b --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.css @@ -0,0 +1,44 @@ +.qualityProfileFormatItem { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; +} + +.checkContainer { + position: relative; + margin-right: 4px; + margin-bottom: 7px; + margin-left: 8px; +} + +.formatName { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: 36px; + cursor: pointer; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js new file mode 100644 index 000000000..ec1b75b8f --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js @@ -0,0 +1,83 @@ +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 CheckInput from 'Components/Form/CheckInput'; +import styles from './QualityProfileFormatItem.css'; + +class QualityProfileFormatItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + formatId, + onQualityProfileFormatItemAllowedChange + } = this.props; + + onQualityProfileFormatItemAllowedChange(formatId, value); + } + + // + // Render + + render() { + const { + name, + allowed, + isDragging, + connectDragSource + } = this.props; + + return ( +
+ + + { + connectDragSource( +
+ +
+ ) + } +
+ ); + } +} + +QualityProfileFormatItem.propTypes = { + formatId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + isDragging: PropTypes.bool.isRequired, + connectDragSource: PropTypes.func, + onQualityProfileFormatItemAllowedChange: PropTypes.func +}; + +QualityProfileFormatItem.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default QualityProfileFormatItem; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragPreview.css b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragPreview.css new file mode 100644 index 000000000..b927d9bce --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragPreview.css @@ -0,0 +1,4 @@ +.dragPreview { + width: 380px; + opacity: 0.75; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragPreview.js b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragPreview.js new file mode 100644 index 000000000..8b459fd4b --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragPreview.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { QUALITY_PROFILE_FORMAT_ITEM } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import QualityProfileFormatItem from './QualityProfileFormatItem'; +import styles from './QualityProfileFormatItemDragPreview.css'; + +const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth); +const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth); +const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class QualityProfileFormatItemDragPreview extends Component { + + // + // Render + + render() { + const { + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== QUALITY_PROFILE_FORMAT_ITEM) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + const { + formatId, + name, + allowed, + sortIndex + } = item; + + return ( + +
+ +
+
+ ); + } +} + +QualityProfileFormatItemDragPreview.propTypes = { + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(QualityProfileFormatItemDragPreview); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragSource.css b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragSource.css new file mode 100644 index 000000000..c5dd1ebc3 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragSource.css @@ -0,0 +1,18 @@ +.qualityProfileFormatItemDragSource { + padding: 4px 0; +} + +.qualityProfileFormatItemPlaceholder { + width: 100%; + height: 36px; + border: 1px dotted #aaa; + border-radius: 4px; +} + +.qualityProfileFormatItemPlaceholderBefore { + margin-bottom: 8px; +} + +.qualityProfileFormatItemPlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragSource.js b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragSource.js new file mode 100644 index 000000000..4a07cb684 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItemDragSource.js @@ -0,0 +1,157 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { QUALITY_PROFILE_FORMAT_ITEM } from 'Helpers/dragTypes'; +import QualityProfileFormatItem from './QualityProfileFormatItem'; +import styles from './QualityProfileFormatItemDragSource.css'; + +const qualityProfileFormatItemDragSource = { + beginDrag({ formatId, name, allowed, sortIndex }) { + return { + formatId, + name, + allowed, + sortIndex + }; + }, + + endDrag(props, monitor, component) { + props.onQualityProfileFormatItemDragEnd(monitor.getItem(), monitor.didDrop()); + } +}; + +const qualityProfileFormatItemDropTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().sortIndex; + const hoverIndex = props.sortIndex; + + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + // Moving up, only trigger if drag position is above 50% + if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + // Moving down, only trigger if drag position is below 50% + if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + props.onQualityProfileFormatItemDragMove(dragIndex, hoverIndex); + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} + +class QualityProfileFormatItemDragSource extends Component { + + // + // Render + + render() { + const { + formatId, + name, + allowed, + sortIndex, + isDragging, + isDraggingUp, + isDraggingDown, + isOver, + connectDragSource, + connectDropTarget, + onQualityProfileFormatItemAllowedChange + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + // if (isDragging && !isOver) { + // return null; + // } + + return connectDropTarget( +
+ { + isBefore && +
+ } + + + + { + isAfter && +
+ } +
+ ); + } +} + +QualityProfileFormatItemDragSource.propTypes = { + formatId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOver: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onQualityProfileFormatItemAllowedChange: PropTypes.func.isRequired, + onQualityProfileFormatItemDragMove: PropTypes.func.isRequired, + onQualityProfileFormatItemDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + QUALITY_PROFILE_FORMAT_ITEM, + qualityProfileFormatItemDropTarget, + collectDropTarget +)(DragSource( + QUALITY_PROFILE_FORMAT_ITEM, + qualityProfileFormatItemDragSource, + collectDragSource +)(QualityProfileFormatItemDragSource)); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.css new file mode 100644 index 000000000..42785b9f1 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.css @@ -0,0 +1,6 @@ +.formats { + margin-top: 10px; + /* TODO: This should consider the number of languages in the list */ + min-height: 550px; + user-select: none; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js new file mode 100644 index 000000000..e6e94abbe --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import QualityProfileFormatItemDragSource from './QualityProfileFormatItemDragSource'; +import QualityProfileFormatItemDragPreview from './QualityProfileFormatItemDragPreview'; +import styles from './QualityProfileFormatItems.css'; + +class QualityProfileFormatItems extends Component { + + // + // Render + + render() { + const { + dragIndex, + dropIndex, + profileFormatItems, + errors, + warnings, + ...otherProps + } = this.props; + + const isDragging = dropIndex !== null; + const isDraggingUp = isDragging && dropIndex > dragIndex; + const isDraggingDown = isDragging && dropIndex < dragIndex; + + return ( + + Custom Formats +
+ + + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + +
+ { + profileFormatItems.map(({ allowed, format }, index) => { + return ( + + ); + }).reverse() + } + + +
+
+
+ ); + } +} + +QualityProfileFormatItems.propTypes = { + dragIndex: PropTypes.number, + dropIndex: PropTypes.number, + profileFormatItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object) +}; + +QualityProfileFormatItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default QualityProfileFormatItems; diff --git a/package.json b/package.json index fecd1b2aa..10a41739f 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build": "gulp build", "start": "gulp watch", "watch": "gulp watch", + "clean": "git clean -fXd", "lint": "esprint check", "lint-fix": "eslint start --fix", "stylelint": "stylelint frontend/**/*.css --config frontend/.stylelintrc" @@ -53,7 +54,7 @@ "eslint": "6.0.1", "eslint-plugin-filenames": "1.3.2", "eslint-plugin-react": "7.14.2", - "esprint": "0.4.0", + "esprint": "0.5.0", "file-loader": "4.0.0", "filesize": "4.1.2", "fuse.js": "3.4.5", diff --git a/src/NzbDrone.Api/Profiles/ProfileResource.cs b/src/NzbDrone.Api/Profiles/ProfileResource.cs index 3ba652781..2f4df4f39 100644 --- a/src/NzbDrone.Api/Profiles/ProfileResource.cs +++ b/src/NzbDrone.Api/Profiles/ProfileResource.cs @@ -51,6 +51,17 @@ namespace NzbDrone.Api.Profiles ? cutoffItem.Quality : cutoffItem.Items.First().Quality; + var formatCutoffItem = model.FormatItems.First(q => + { + if (q.Id == model.FormatCutoff) return true; + + if (q.Format == null) return false; + + return q.Format.Id == model.FormatCutoff; + }); + + var formatCutoff = formatCutoffItem.Format; + return new ProfileResource { Id = model.Id, @@ -74,7 +85,7 @@ namespace NzbDrone.Api.Profiles return new List { ToResource(i) }; }).ToList(), - FormatCutoff = model.FormatCutoff.ToResource(), + FormatCutoff = formatCutoff.ToResource(), FormatItems = model.FormatItems.ConvertAll(ToResource), Language = model.Language }; @@ -112,7 +123,7 @@ namespace NzbDrone.Api.Profiles Cutoff = resource.Cutoff.Id, PreferredTags = resource.PreferredTags.Split(',').ToList(), Items = resource.Items.ConvertAll(ToModel), - FormatCutoff = resource.FormatCutoff.ToModel(), + FormatCutoff = resource.FormatCutoff.ToModel().Id, FormatItems = resource.FormatItems.ConvertAll(ToModel), Language = resource.Language }; diff --git a/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs b/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs index 433fe978d..595a3567f 100644 --- a/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs +++ b/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs @@ -45,7 +45,7 @@ namespace NzbDrone.Api.Profiles var profile = new Profile(); profile.Cutoff = Quality.Unknown.Id; profile.Items = items; - profile.FormatCutoff = CustomFormat.None; + profile.FormatCutoff = CustomFormat.None.Id; profile.FormatItems = formatItems; profile.Language = Language.English; diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs index 3b3a15e70..2efd7de36 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs @@ -79,7 +79,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { Cutoff = Quality.HDTV720p.Id, Items = Qualities.QualityFixture.GetDefaultQualities(), - FormatCutoff = CustomFormats.CustomFormat.None, + FormatCutoff = CustomFormats.CustomFormat.None.Id, FormatItems = CustomFormatsFixture.GetSampleFormatItems("None", "My Format") }, old, newQ).Should().BeFalse(); } diff --git a/src/NzbDrone.Core.Test/MovieTests/MovieRepositoryTests/MovieRepositoryFixture.cs b/src/NzbDrone.Core.Test/MovieTests/MovieRepositoryTests/MovieRepositoryFixture.cs index c3e838bca..b93735053 100644 --- a/src/NzbDrone.Core.Test/MovieTests/MovieRepositoryTests/MovieRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/MovieTests/MovieRepositoryTests/MovieRepositoryFixture.cs @@ -24,7 +24,7 @@ namespace NzbDrone.Core.Test.MovieTests.MovieRepositoryTests { Items = Qualities.QualityFixture.GetDefaultQualities(Quality.Bluray1080p, Quality.DVD, Quality.HDTV720p), FormatItems = CustomFormat.CustomFormatsFixture.GetDefaultFormatItems(), - FormatCutoff = CustomFormats.CustomFormat.None, + FormatCutoff = CustomFormats.CustomFormat.None.Id, Cutoff = Quality.Bluray1080p.Id, Name = "TestProfile" }; diff --git a/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs b/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs index d75b29f88..2f29fe20b 100644 --- a/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Core.Test.Profiles var profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities(Quality.Bluray1080p, Quality.DVD, Quality.HDTV720p), - FormatCutoff = CustomFormats.CustomFormat.None, + FormatCutoff = CustomFormats.CustomFormat.None.Id, FormatItems = CustomFormat.CustomFormatsFixture.GetDefaultFormatItems(), Cutoff = Quality.Bluray1080p.Id, Name = "TestProfile" diff --git a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs index 4fceebe4a..96d65f781 100644 --- a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs @@ -7,6 +7,8 @@ using NzbDrone.Core.Profiles; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Movies; using NzbDrone.Core.NetImport; +using NzbDrone.Core.CustomFormats; +using System.Collections.Generic; namespace NzbDrone.Core.Test.Profiles { @@ -17,6 +19,10 @@ namespace NzbDrone.Core.Test.Profiles [Test] public void init_should_add_default_profiles() { + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(new List()); + Subject.Handle(new ApplicationStartedEvent()); Mocker.GetMock() diff --git a/src/NzbDrone.Core/Profiles/Profile.cs b/src/NzbDrone.Core/Profiles/Profile.cs index 2e51f3dcd..4bdf4cc86 100644 --- a/src/NzbDrone.Core/Profiles/Profile.cs +++ b/src/NzbDrone.Core/Profiles/Profile.cs @@ -17,7 +17,7 @@ namespace NzbDrone.Core.Profiles public string Name { get; set; } public int Cutoff { get; set; } public List Items { get; set; } - public CustomFormat FormatCutoff { get; set; } + public int FormatCutoff { get; set; } public List FormatItems { get; set; } public List PreferredTags { get; set; } public Language Language { get; set; } diff --git a/src/NzbDrone.Core/Profiles/ProfileFormatItem.cs b/src/NzbDrone.Core/Profiles/ProfileFormatItem.cs index 2d031674c..73b37b45b 100644 --- a/src/NzbDrone.Core/Profiles/ProfileFormatItem.cs +++ b/src/NzbDrone.Core/Profiles/ProfileFormatItem.cs @@ -1,10 +1,13 @@ -using NzbDrone.Core.CustomFormats; +using Newtonsoft.Json; +using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Profiles { public class ProfileFormatItem : IEmbeddedDocument { + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public int Id { get; set; } public CustomFormat Format { get; set; } public bool Allowed { get; set; } } diff --git a/src/NzbDrone.Core/Profiles/ProfileService.cs b/src/NzbDrone.Core/Profiles/ProfileService.cs index 4453302b0..080cbb379 100644 --- a/src/NzbDrone.Core/Profiles/ProfileService.cs +++ b/src/NzbDrone.Core/Profiles/ProfileService.cs @@ -74,9 +74,9 @@ namespace NzbDrone.Core.Profiles foreach (var profile in all) { profile.FormatItems = profile.FormatItems.Where(c => c.Format.Id != formatId).ToList(); - if (profile.FormatCutoff.Id == formatId) + if (profile.FormatCutoff == formatId) { - profile.FormatCutoff = CustomFormat.None; + profile.FormatCutoff = CustomFormat.None.Id; } Update(profile); @@ -187,7 +187,9 @@ namespace NzbDrone.Core.Profiles public Profile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed) { var groupedQualites = Quality.DefaultQualityDefinitions.GroupBy(q => q.Weight); + var formats = _formatService.All(); var items = new List(); + var formatItems = new List(); var groupId = 1000; var profileCutoff = cutoff == null ? Quality.Unknown.Id : cutoff.Id; @@ -223,23 +225,36 @@ namespace NzbDrone.Core.Profiles groupId++; } + foreach (var format in formats) + { + formatItems.Add(new ProfileFormatItem + { + Id = format.Id, + Format = format, + Allowed = false + }); + } + var qualityProfile = new Profile { Name = name, Cutoff = profileCutoff, Items = items, Language = Language.English, - FormatCutoff = CustomFormat.None, + FormatCutoff = CustomFormat.None.Id, FormatItems = new List { new ProfileFormatItem { + Id = 0, Allowed = true, Format = CustomFormat.None } } }; + qualityProfile.FormatItems.AddRange(formatItems); + return qualityProfile; } diff --git a/src/NzbDrone.Core/Qualities/QualityModelComparer.cs b/src/NzbDrone.Core/Qualities/QualityModelComparer.cs index 324e334c0..d11972b6d 100644 --- a/src/NzbDrone.Core/Qualities/QualityModelComparer.cs +++ b/src/NzbDrone.Core/Qualities/QualityModelComparer.cs @@ -94,5 +94,15 @@ namespace NzbDrone.Core.Qualities return leftIndicies.Select(i => i.CompareTo(rightIndex)).Sum(); } + + public int Compare(List left, int right) + { + left = left.WithNone(); + + var leftIndicies = GetIndicies(left, _profile); + var rightIndex = _profile.FormatItems.FindIndex(v => Equals(v.Format, right)); + + return leftIndicies.Select(i => i.CompareTo(rightIndex)).Sum(); + } } } diff --git a/src/Radarr.Api.V2/Profiles/Quality/QualityProfileModule.cs b/src/Radarr.Api.V2/Profiles/Quality/QualityProfileModule.cs index 40c2d9e44..f2698804d 100644 --- a/src/Radarr.Api.V2/Profiles/Quality/QualityProfileModule.cs +++ b/src/Radarr.Api.V2/Profiles/Quality/QualityProfileModule.cs @@ -33,7 +33,7 @@ namespace Radarr.Api.V2.Profiles.Quality return all.Except(ids).Empty(); }).WithMessage("All Custom Formats and no extra ones need to be present inside your Profile! Try refreshing your browser."); SharedValidator.RuleFor(c => c.FormatCutoff) - .Must(c => _formatService.All().Select(f => f.Id).Contains(c.Id) || c.Id == CustomFormat.None.Id).WithMessage("The Custom Format Cutoff must be a valid Custom Format! Try refreshing your browser."); + .Must(c => _formatService.All().Select(f => f.Id).Contains(c) || c == CustomFormat.None.Id).WithMessage("The Custom Format Cutoff must be a valid Custom Format! Try refreshing your browser."); GetResourceAll = GetAll; GetResourceById = GetById; diff --git a/src/Radarr.Api.V2/Profiles/Quality/QualityProfileResource.cs b/src/Radarr.Api.V2/Profiles/Quality/QualityProfileResource.cs index 122a0095c..eed5e4421 100644 --- a/src/Radarr.Api.V2/Profiles/Quality/QualityProfileResource.cs +++ b/src/Radarr.Api.V2/Profiles/Quality/QualityProfileResource.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.Parser; using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Languages; +using NzbDrone.Core.CustomFormats; namespace Radarr.Api.V2.Profiles.Quality { @@ -16,7 +17,7 @@ namespace Radarr.Api.V2.Profiles.Quality public int Cutoff { get; set; } public string PreferredTags { get; set; } public List Items { get; set; } - public CustomFormatResource FormatCutoff { get; set; } + public int FormatCutoff { get; set; } public List FormatItems { get; set; } public Language Language { get; set; } } @@ -36,7 +37,7 @@ namespace Radarr.Api.V2.Profiles.Quality public class ProfileFormatItemResource : RestResource { - public CustomFormatResource Format { get; set; } + public CustomFormat Format { get; set; } public bool Allowed { get; set; } } @@ -54,7 +55,7 @@ namespace Radarr.Api.V2.Profiles.Quality Cutoff = model.Cutoff, PreferredTags = model.PreferredTags != null ? string.Join(",", model.PreferredTags) : "", Items = model.Items.ConvertAll(ToResource), - FormatCutoff = model.FormatCutoff.ToResource(), + FormatCutoff = model.FormatCutoff, FormatItems = model.FormatItems.ConvertAll(ToResource), Language = model.Language }; @@ -78,7 +79,7 @@ namespace Radarr.Api.V2.Profiles.Quality { return new ProfileFormatItemResource { - Format = model.Format.ToResource(), + Format = model.Format, Allowed = model.Allowed }; } @@ -95,7 +96,7 @@ namespace Radarr.Api.V2.Profiles.Quality Cutoff = resource.Cutoff, PreferredTags = resource.PreferredTags.Split(',').ToList(), Items = resource.Items.ConvertAll(ToModel), - FormatCutoff = resource.FormatCutoff.ToModel(), + FormatCutoff = resource.FormatCutoff, FormatItems = resource.FormatItems.ConvertAll(ToModel), Language = resource.Language }; @@ -119,7 +120,7 @@ namespace Radarr.Api.V2.Profiles.Quality { return new ProfileFormatItem { - Format = resource.Format.ToModel(), + Format = resource.Format, Allowed = resource.Allowed }; } diff --git a/src/Radarr.Api.V2/Qualities/CustomFormatModule.cs b/src/Radarr.Api.V2/Qualities/CustomFormatModule.cs index 10bfd582e..ed2862fae 100644 --- a/src/Radarr.Api.V2/Qualities/CustomFormatModule.cs +++ b/src/Radarr.Api.V2/Qualities/CustomFormatModule.cs @@ -30,7 +30,7 @@ namespace Radarr.Api.V2.Qualities return !allFormats.Any(f => { var allTags = f.FormatTags.Select(t => t.Raw.ToLower()); - var allNewTags = c.Select(t => t.ToLower()); + var allNewTags = c.Split(',').Select(t => t.ToLower()); var enumerable = allTags.ToList(); var newTags = allNewTags.ToList(); return (enumerable.All(newTags.Contains) && f.Id != v.Id && enumerable.Count() == newTags.Count()); diff --git a/src/Radarr.Api.V2/Qualities/CustomFormatResource.cs b/src/Radarr.Api.V2/Qualities/CustomFormatResource.cs index 4718cfbfc..ce225803f 100644 --- a/src/Radarr.Api.V2/Qualities/CustomFormatResource.cs +++ b/src/Radarr.Api.V2/Qualities/CustomFormatResource.cs @@ -8,7 +8,7 @@ namespace Radarr.Api.V2.Qualities public class CustomFormatResource : RestResource { public string Name { get; set; } - public List FormatTags { get; set; } + public string FormatTags { get; set; } public string Simplicity { get; set; } } @@ -20,7 +20,7 @@ namespace Radarr.Api.V2.Qualities { Id = model.Id, Name = model.Name, - FormatTags = model.FormatTags.Select(t => t.Raw.ToUpper()).ToList(), + FormatTags = string.Join(",", model.FormatTags.Select(t => t.Raw.ToUpper()).ToList()), }; } @@ -30,7 +30,7 @@ namespace Radarr.Api.V2.Qualities { Id = resource.Id, Name = resource.Name, - FormatTags = resource.FormatTags.Select(s => new FormatTag(s)).ToList(), + FormatTags = resource.FormatTags.Split(',').Select(s => new FormatTag(s)).ToList() }; } diff --git a/src/Radarr.Api.V2/Qualities/FormatTagValidator.cs b/src/Radarr.Api.V2/Qualities/FormatTagValidator.cs index 3cf6c94c0..a726324b5 100644 --- a/src/Radarr.Api.V2/Qualities/FormatTagValidator.cs +++ b/src/Radarr.Api.V2/Qualities/FormatTagValidator.cs @@ -19,7 +19,7 @@ namespace Radarr.Api.V2.Qualities return false; } - var tags = (IEnumerable) context.PropertyValue; + var tags = (IEnumerable) context.PropertyValue.ToString().Split(','); var invalidTags = tags.Where(t => !FormatTag.QualityTagRegex.IsMatch(t)); diff --git a/yarn.lock b/yarn.lock index d339b4658..8589787ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3295,10 +3295,10 @@ esprima@^4.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esprint@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/esprint/-/esprint-0.4.0.tgz#f89c9bace36d90407968a8f9ceb0800ff786aab0" - integrity sha1-+JybrONtkEB5aKj5zrCAD/eGqrA= +esprint@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/esprint/-/esprint-0.5.0.tgz#25975b855b9df625ce2e32655db6dff1a84bbe36" + integrity sha512-TpaXKPy6g1saDqMYwqppZC6C0wQpYQAnhms6829oVvP6XieUbGjQdcNgatGQMihin2bMgE90tmX+1OOPc5tuiw== dependencies: dnode "^1.2.2" fb-watchman "^2.0.0"