1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-23 22:25:14 -04:00

New: Project Aphrodite

This commit is contained in:
Qstick
2018-11-23 02:04:42 -05:00
parent 65efa15551
commit 8430cb40ab
1080 changed files with 73015 additions and 0 deletions
@@ -0,0 +1,40 @@
.delayProfile {
display: flex;
align-items: stretch;
margin-bottom: 10px;
height: 30px;
border-bottom: 1px solid $borderColor;
line-height: 30px;
}
.column {
flex: 0 0 200px;
}
.actions {
display: flex;
}
.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;
}
.editButton {
width: $dragHandleWidth;
text-align: center;
}
@@ -0,0 +1,172 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import titleCase from 'Utilities/String/titleCase';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import TagList from 'Components/TagList';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
import styles from './DelayProfile.css';
function getDelay(enabled, delay) {
if (!enabled) {
return '-';
}
if (!delay) {
return 'No Delay';
}
if (delay === 1) {
return '1 Minute';
}
// TODO: use better units of time than just minutes
return `${delay} Minutes`;
}
class DelayProfile extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditDelayProfileModalOpen: false,
isDeleteDelayProfileModalOpen: false
};
}
//
// Listeners
onEditDelayProfilePress = () => {
this.setState({ isEditDelayProfileModalOpen: true });
}
onEditDelayProfileModalClose = () => {
this.setState({ isEditDelayProfileModalOpen: false });
}
onDeleteDelayProfilePress = () => {
this.setState({
isEditDelayProfileModalOpen: false,
isDeleteDelayProfileModalOpen: true
});
}
onDeleteDelayProfileModalClose = () => {
this.setState({ isDeleteDelayProfileModalOpen: false });
}
onConfirmDeleteDelayProfile = () => {
this.props.onConfirmDeleteDelayProfile(this.props.id);
}
//
// Render
render() {
const {
id,
enableUsenet,
enableTorrent,
preferredProtocol,
usenetDelay,
torrentDelay,
tags,
tagList,
isDragging,
connectDragSource
} = this.props;
let preferred = titleCase(preferredProtocol);
if (!enableUsenet) {
preferred = 'Only Torrent';
} else if (!enableTorrent) {
preferred = 'Only Usenet';
}
return (
<div
className={classNames(
styles.delayProfile,
isDragging && styles.isDragging,
)}
>
<div className={styles.column}>{preferred}</div>
<div className={styles.column}>{getDelay(enableUsenet, usenetDelay)}</div>
<div className={styles.column}>{getDelay(enableTorrent, torrentDelay)}</div>
<TagList
tags={tags}
tagList={tagList}
/>
<div className={styles.actions}>
<Link
className={id === 1 ? styles.editButton : undefined}
onPress={this.onEditDelayProfilePress}
>
<Icon name={icons.EDIT} />
</Link>
{
id !== 1 &&
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
/>
</div>
)
}
</div>
<EditDelayProfileModalConnector
id={id}
isOpen={this.state.isEditDelayProfileModalOpen}
onModalClose={this.onEditDelayProfileModalClose}
onDeleteDelayProfilePress={this.onDeleteDelayProfilePress}
/>
<ConfirmModal
isOpen={this.state.isDeleteDelayProfileModalOpen}
kind={kinds.DANGER}
title="Delete Delay Profile"
message="Are you sure you want to delete this delay profile?"
confirmLabel="Delete"
onConfirm={this.onConfirmDeleteDelayProfile}
onCancel={this.onDeleteDelayProfileModalClose}
/>
</div>
);
}
}
DelayProfile.propTypes = {
id: PropTypes.number.isRequired,
enableUsenet: PropTypes.bool.isRequired,
enableTorrent: PropTypes.bool.isRequired,
preferredProtocol: PropTypes.string.isRequired,
usenetDelay: PropTypes.number.isRequired,
torrentDelay: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
isDragging: PropTypes.bool.isRequired,
connectDragSource: PropTypes.func,
onConfirmDeleteDelayProfile: PropTypes.func.isRequired
};
DelayProfile.defaultProps = {
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default DelayProfile;
@@ -0,0 +1,3 @@
.dragPreview {
opacity: 0.75;
}
@@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragLayer } from 'react-dnd';
import dimensions from 'Styles/Variables/dimensions.js';
import { DELAY_PROFILE } from 'Helpers/dragTypes';
import DragPreviewLayer from 'Components/DragPreviewLayer';
import DelayProfile from './DelayProfile';
import styles from './DelayProfileDragPreview.css';
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
function collectDragLayer(monitor) {
return {
item: monitor.getItem(),
itemType: monitor.getItemType(),
currentOffset: monitor.getSourceClientOffset()
};
}
class DelayProfileDragPreview extends Component {
//
// Render
render() {
const {
width,
item,
itemType,
currentOffset
} = this.props;
if (!currentOffset || itemType !== DELAY_PROFILE) {
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 = width - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = {
width,
position: 'absolute',
WebkitTransform: transform,
msTransform: transform,
transform
};
return (
<DragPreviewLayer>
<div
className={styles.dragPreview}
style={style}
>
<DelayProfile
isDragging={false}
{...item}
/>
</div>
</DragPreviewLayer>
);
}
}
DelayProfileDragPreview.propTypes = {
width: PropTypes.number.isRequired,
item: PropTypes.object,
itemType: PropTypes.string,
currentOffset: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
})
};
export default DragLayer(collectDragLayer)(DelayProfileDragPreview);
@@ -0,0 +1,17 @@
.delayProfileDragSource {
padding: 4px 0;
}
.delayProfilePlaceholder {
width: 100%;
height: 30px;
border-bottom: 1px dotted #aaa;
}
.delayProfilePlaceholderBefore {
margin-bottom: 8px;
}
.delayProfilePlaceholderAfter {
margin-top: 8px;
}
@@ -0,0 +1,148 @@
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 { DELAY_PROFILE } from 'Helpers/dragTypes';
import DelayProfile from './DelayProfile';
import styles from './DelayProfileDragSource.css';
const delayProfileDragSource = {
beginDrag(item) {
return item;
},
endDrag(props, monitor, component) {
props.onDelayProfileDragEnd(monitor.getItem(), monitor.didDrop());
}
};
const delayProfileDropTarget = {
hover(props, monitor, component) {
const dragIndex = monitor.getItem().order;
const hoverIndex = props.order;
const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
if (dragIndex === hoverIndex) {
return;
}
// When moving up, only trigger if drag position is above 50% and
// when moving down, only trigger if drag position is below 50%.
// If we're moving down the hoverIndex needs to be increased
// by one so it's ordered properly. Otherwise the hoverIndex will work.
if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
props.onDelayProfileDragMove(dragIndex, hoverIndex + 1);
} else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
props.onDelayProfileDragMove(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 DelayProfileDragSource extends Component {
//
// Render
render() {
const {
id,
order,
isDragging,
isDraggingUp,
isDraggingDown,
isOver,
connectDragSource,
connectDropTarget,
...otherProps
} = this.props;
const isBefore = !isDragging && isDraggingUp && isOver;
const isAfter = !isDragging && isDraggingDown && isOver;
// if (isDragging && !isOver) {
// return null;
// }
return connectDropTarget(
<div
className={classNames(
styles.delayProfileDragSource,
isBefore && styles.isDraggingUp,
isAfter && styles.isDraggingDown
)}
>
{
isBefore &&
<div
className={classNames(
styles.delayProfilePlaceholder,
styles.delayProfilePlaceholderBefore
)}
/>
}
<DelayProfile
id={id}
order={order}
isDragging={isDragging}
isOver={isOver}
{...otherProps}
connectDragSource={connectDragSource}
/>
{
isAfter &&
<div
className={classNames(
styles.delayProfilePlaceholder,
styles.delayProfilePlaceholderAfter
)}
/>
}
</div>
);
}
}
DelayProfileDragSource.propTypes = {
id: PropTypes.number.isRequired,
order: PropTypes.number.isRequired,
isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool,
isDraggingDown: PropTypes.bool,
isOver: PropTypes.bool,
connectDragSource: PropTypes.func,
connectDropTarget: PropTypes.func,
onDelayProfileDragMove: PropTypes.func.isRequired,
onDelayProfileDragEnd: PropTypes.func.isRequired
};
export default DropTarget(
DELAY_PROFILE,
delayProfileDropTarget,
collectDropTarget
)(DragSource(
DELAY_PROFILE,
delayProfileDragSource,
collectDragSource
)(DelayProfileDragSource));
@@ -0,0 +1,27 @@
.delayProfiles {
user-select: none;
}
.delayProfilesHeader {
display: flex;
margin-bottom: 10px;
font-weight: bold;
}
.column {
flex: 0 0 200px;
}
.tags {
flex: 1 0 auto;
}
.addDelayProfile {
display: flex;
justify-content: flex-end;
}
.addButton {
width: $dragHandleWidth;
text-align: center;
}
@@ -0,0 +1,148 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import Measure from 'Components/Measure';
import PageSectionContent from 'Components/Page/PageSectionContent';
import DelayProfileDragSource from './DelayProfileDragSource';
import DelayProfileDragPreview from './DelayProfileDragPreview';
import DelayProfile from './DelayProfile';
import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
import styles from './DelayProfiles.css';
class DelayProfiles extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddDelayProfileModalOpen: false,
width: 0
};
}
//
// Listeners
onAddDelayProfilePress = () => {
this.setState({ isAddDelayProfileModalOpen: true });
}
onModalClose = () => {
this.setState({ isAddDelayProfileModalOpen: false });
}
onMeasure = ({ width }) => {
this.setState({ width });
}
//
// Render
render() {
const {
defaultProfile,
items,
tagList,
dragIndex,
dropIndex,
onConfirmDeleteDelayProfile,
...otherProps
} = this.props;
const {
isAddDelayProfileModalOpen,
width
} = this.state;
const isDragging = dropIndex !== null;
const isDraggingUp = isDragging && dropIndex < dragIndex;
const isDraggingDown = isDragging && dropIndex > dragIndex;
return (
<Measure onMeasure={this.onMeasure}>
<FieldSet legend="Delay Profiles">
<PageSectionContent
errorMessage="Unable to load Delay Profiles"
{...otherProps}
>
<div className={styles.delayProfilesHeader}>
<div className={styles.column}>Protocol</div>
<div className={styles.column}>Usenet Delay</div>
<div className={styles.column}>Torrent Delay</div>
<div className={styles.tags}>Tags</div>
</div>
<div className={styles.delayProfiles}>
{
items.map((item, index) => {
return (
<DelayProfileDragSource
key={item.id}
tagList={tagList}
{...item}
{...otherProps}
index={index}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
onConfirmDeleteDelayProfile={onConfirmDeleteDelayProfile}
/>
);
})
}
<DelayProfileDragPreview
width={width}
/>
</div>
{
defaultProfile &&
<div>
<DelayProfile
tagList={tagList}
isDragging={false}
onConfirmDeleteDelayProfile={onConfirmDeleteDelayProfile}
{...defaultProfile}
/>
</div>
}
<div className={styles.addDelayProfile}>
<Link
className={styles.addButton}
onPress={this.onAddDelayProfilePress}
>
<Icon name={icons.ADD} />
</Link>
</div>
<EditDelayProfileModalConnector
isOpen={isAddDelayProfileModalOpen}
onModalClose={this.onModalClose}
/>
</PageSectionContent>
</FieldSet>
</Measure>
);
}
}
DelayProfiles.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
defaultProfile: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
dragIndex: PropTypes.number,
dropIndex: PropTypes.number,
onConfirmDeleteDelayProfile: PropTypes.func.isRequired
};
export default DelayProfiles;
@@ -0,0 +1,105 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchDelayProfiles, deleteDelayProfile, reorderDelayProfile } from 'Store/Actions/settingsActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import DelayProfiles from './DelayProfiles';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.delayProfiles,
createTagsSelector(),
(delayProfiles, tagList) => {
const defaultProfile = _.find(delayProfiles.items, { id: 1 });
const items = _.sortBy(_.reject(delayProfiles.items, { id: 1 }), ['order']);
return {
defaultProfile,
...delayProfiles,
items,
tagList
};
}
);
}
const mapDispatchToProps = {
fetchDelayProfiles,
deleteDelayProfile,
reorderDelayProfile
};
class DelayProfilesConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
dragIndex: null,
dropIndex: null
};
}
componentDidMount() {
this.props.fetchDelayProfiles();
}
//
// Listeners
onConfirmDeleteDelayProfile = (id) => {
this.props.deleteDelayProfile({ id });
}
onDelayProfileDragMove = (dragIndex, dropIndex) => {
if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
this.setState({
dragIndex,
dropIndex
});
}
}
onDelayProfileDragEnd = ({ id }, didDrop) => {
const {
dropIndex
} = this.state;
if (didDrop && dropIndex !== null) {
this.props.reorderDelayProfile({ id, moveIndex: dropIndex - 1 });
}
this.setState({
dragIndex: null,
dropIndex: null
});
}
//
// Render
render() {
return (
<DelayProfiles
{...this.state}
{...this.props}
onConfirmDeleteDelayProfile={this.onConfirmDeleteDelayProfile}
onDelayProfileDragMove={this.onDelayProfileDragMove}
onDelayProfileDragEnd={this.onDelayProfileDragEnd}
/>
);
}
}
DelayProfilesConnector.propTypes = {
fetchDelayProfiles: PropTypes.func.isRequired,
deleteDelayProfile: PropTypes.func.isRequired,
reorderDelayProfile: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector);
@@ -0,0 +1,27 @@
import PropTypes from 'prop-types';
import React from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import EditDelayProfileModalContentConnector from './EditDelayProfileModalContentConnector';
function EditDelayProfileModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditDelayProfileModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditDelayProfileModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditDelayProfileModal;
@@ -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 EditDelayProfileModal from './EditDelayProfileModal';
function mapStateToProps() {
return {};
}
const mapDispatchToProps = {
clearPendingChanges
};
class EditDelayProfileModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'settings.delayProfiles' });
this.props.onModalClose();
}
//
// Render
render() {
return (
<EditDelayProfileModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditDelayProfileModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector);
@@ -0,0 +1,5 @@
.deleteButton {
composes: button from 'Components/Link/Button.css';
margin-right: auto;
}
@@ -0,0 +1,186 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Alert from 'Components/Alert';
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 './EditDelayProfileModalContent.css';
function EditDelayProfileModalContent(props) {
const {
id,
isFetching,
error,
isSaving,
saveError,
item,
protocol,
protocolOptions,
onInputChange,
onProtocolChange,
onSavePress,
onModalClose,
onDeleteDelayProfilePress,
...otherProps
} = props;
const {
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay,
tags
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Delay Profile' : 'Add Delay Profile'}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to add a new quality profile, please try again.</div>
}
{
!isFetching && !error &&
<Form {...otherProps}>
<FormGroup>
<FormLabel>Protocol</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="protocol"
value={protocol}
values={protocolOptions}
helpText="Choose which protocol(s) to use and which one is preferred when choosing between otherwise equal releases"
onChange={onProtocolChange}
/>
</FormGroup>
{
enableUsenet.value &&
<FormGroup>
<FormLabel>Usenet Delay</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="usenetDelay"
unit="minutes"
{...usenetDelay}
helpText="Delay in minutes to wait before grabbing a release from Usenet"
onChange={onInputChange}
/>
</FormGroup>
}
{
enableTorrent.value &&
<FormGroup>
<FormLabel>Torrent Delay</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="torrentDelay"
unit="minutes"
{...torrentDelay}
helpText="Delay in minutes to wait before grabbing a torrent"
onChange={onInputChange}
/>
</FormGroup>
}
{
id === 1 ?
<Alert>
This is the default profile. It applies to all series that don't have an explicit profile.
</Alert> :
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
{...tags}
helpText="Applies to series with at least one matching tag"
onChange={onInputChange}
/>
</FormGroup>
}
</Form>
}
</ModalBody>
<ModalFooter>
{
id && id > 1 &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteDelayProfilePress}
>
Delete
</Button>
}
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
const delayProfileShape = {
enableUsenet: PropTypes.shape(boolSettingShape).isRequired,
enableTorrent: PropTypes.shape(boolSettingShape).isRequired,
usenetDelay: PropTypes.shape(numberSettingShape).isRequired,
torrentDelay: PropTypes.shape(numberSettingShape).isRequired,
order: PropTypes.shape(numberSettingShape),
tags: PropTypes.shape(tagSettingShape).isRequired
};
EditDelayProfileModalContent.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.shape(delayProfileShape).isRequired,
protocol: PropTypes.string.isRequired,
protocolOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
onInputChange: PropTypes.func.isRequired,
onProtocolChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteDelayProfilePress: PropTypes.func
};
export default EditDelayProfileModalContent;
@@ -0,0 +1,178 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import selectSettings from 'Store/Selectors/selectSettings';
import { setDelayProfileValue, saveDelayProfile } from 'Store/Actions/settingsActions';
import EditDelayProfileModalContent from './EditDelayProfileModalContent';
const newDelayProfile = {
enableUsenet: true,
enableTorrent: true,
preferredProtocol: 'usenet',
usenetDelay: 0,
torrentDelay: 0,
tags: []
};
const protocolOptions = [
{ key: 'preferUsenet', value: 'Prefer Usenet' },
{ key: 'preferTorrent', value: 'Prefer Torrent' },
{ key: 'onlyUsenet', value: 'Only Usenet' },
{ key: 'onlyTorrent', value: 'Only Torrent' }
];
function createDelayProfileSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.delayProfiles,
(id, delayProfiles) => {
const {
isFetching,
error,
isSaving,
saveError,
pendingChanges,
items
} = delayProfiles;
const profile = id ? _.find(items, { id }) : newDelayProfile;
const settings = selectSettings(profile, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings
};
}
);
}
function createMapStateToProps() {
return createSelector(
createDelayProfileSelector(),
(delayProfile) => {
const enableUsenet = delayProfile.item.enableUsenet.value;
const enableTorrent = delayProfile.item.enableTorrent.value;
const preferredProtocol = delayProfile.item.preferredProtocol.value;
let protocol = 'preferUsenet';
if (preferredProtocol === 'usenet') {
protocol = 'preferUsenet';
} else {
protocol = 'preferTorrent';
}
if (!enableUsenet) {
protocol = 'onlyTorrent';
}
if (!enableTorrent) {
protocol = 'onlyUsenet';
}
return {
protocol,
protocolOptions,
...delayProfile
};
}
);
}
const mapDispatchToProps = {
setDelayProfileValue,
saveDelayProfile
};
class EditDelayProfileModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.id) {
Object.keys(newDelayProfile).forEach((name) => {
this.props.setDelayProfileValue({
name,
value: newDelayProfile[name]
});
});
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setDelayProfileValue({ name, value });
}
onProtocolChange = ({ value }) => {
switch (value) {
case 'preferUsenet':
this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
break;
case 'preferTorrent':
this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
break;
case 'onlyUsenet':
this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
this.props.setDelayProfileValue({ name: 'enableTorrent', value: false });
this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
break;
case 'onlyTorrent':
this.props.setDelayProfileValue({ name: 'enableUsenet', value: false });
this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
break;
default:
throw Error(`Unknown protocol option: ${value}`);
}
}
onSavePress = () => {
this.props.saveDelayProfile({ id: this.props.id });
}
//
// Render
render() {
return (
<EditDelayProfileModalContent
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onProtocolChange={this.onProtocolChange}
/>
);
}
}
EditDelayProfileModalContentConnector.propTypes = {
id: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setDelayProfileValue: PropTypes.func.isRequired,
saveDelayProfile: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditDelayProfileModalContentConnector);
@@ -0,0 +1,34 @@
import React, { Component } from 'react';
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import QualityProfilesConnector from './Quality/QualityProfilesConnector';
import DelayProfilesConnector from './Delay/DelayProfilesConnector';
class Profiles extends Component {
//
// Render
render() {
return (
<PageContent title="Profiles">
<SettingsToolbarConnector
showSave={false}
/>
<PageContentBodyConnector>
<QualityProfilesConnector />
<DelayProfilesConnector />
</PageContentBodyConnector>
</PageContent>
);
}
}
// Only a single DragDropContext can exist so it's done here to allow editing
// quality profiles and reordering delay profiles to work.
export default DragDropContext(HTML5Backend)(Profiles);
@@ -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 EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
class EditQualityProfileModal 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 (
<Modal
style={{ height: `${this.state.height}px` }}
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
onModalClose={onModalClose}
>
<EditQualityProfileModalContentConnector
{...otherProps}
onContentHeightChange={this.onContentHeightChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
EditQualityProfileModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditQualityProfileModal;
@@ -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 EditQualityProfileModal from './EditQualityProfileModal';
function mapStateToProps() {
return {};
}
const mapDispatchToProps = {
clearPendingChanges
};
class EditQualityProfileModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'settings.qualityProfiles' });
this.props.onModalClose();
}
//
// Render
render() {
return (
<EditQualityProfileModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditQualityProfileModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector);
@@ -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;
}
}
@@ -0,0 +1,252 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Measure from 'Components/Measure';
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 QualityProfileItems from './QualityProfileItems';
import styles from './EditQualityProfileModalContent.css';
const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
class EditQualityProfileModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
headerHeight: 0,
bodyHeight: 0,
footerHeight: 0
};
}
componentDidUpdate(prevProps, prevState) {
const {
headerHeight,
bodyHeight,
footerHeight
} = this.state;
if (
headerHeight > 0 &&
bodyHeight > 0 &&
footerHeight > 0 &&
(
headerHeight !== prevState.headerHeight ||
bodyHeight !== prevState.bodyHeight ||
footerHeight !== prevState.footerHeight
)
) {
const padding = MODAL_BODY_PADDING * 2;
this.props.onContentHeightChange(
headerHeight + bodyHeight + footerHeight + padding
);
}
}
//
// Listeners
onHeaderMeasure = ({ height }) => {
if (height > this.state.headerHeight) {
this.setState({ headerHeight: height });
}
}
onBodyMeasure = ({ height }) => {
if (height > this.state.bodyHeight) {
this.setState({ bodyHeight: height });
}
}
onFooterMeasure = ({ height }) => {
if (height > this.state.footerHeight) {
this.setState({ footerHeight: height });
}
}
//
// Render
render() {
const {
editGroups,
isFetching,
error,
isSaving,
saveError,
qualities,
item,
isInUse,
onInputChange,
onCutoffChange,
onSavePress,
onModalClose,
onDeleteQualityProfilePress,
...otherProps
} = this.props;
const {
id,
name,
cutoff,
items
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onHeaderMeasure}
>
<ModalHeader>
{id ? 'Edit Quality Profile' : 'Add Quality Profile'}
</ModalHeader>
</Measure>
<ModalBody>
<Measure
whitelist={['height']}
onMeasure={this.onBodyMeasure}
>
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to add a new quality profile, please try again.</div>
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<div className={styles.formGroupsContainer}>
<div className={styles.formGroupWrapper}>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Name
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Cutoff
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
values={qualities}
helpText="Once this quality is reached Radarr will no longer download movies"
onChange={onCutoffChange}
/>
</FormGroup>
</div>
<div className={styles.formGroupWrapper}>
<QualityProfileItems
editGroups={editGroups}
qualityProfileItems={items.value}
errors={items.errors}
warnings={items.warnings}
{...otherProps}
/>
</div>
</div>
</Form>
}
</div>
</Measure>
</ModalBody>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onFooterMeasure}
>
<ModalFooter>
{
id &&
<div
className={styles.deleteButtonContainer}
title={isInUse && 'Can\'t delete a quality profile that is attached to a series'}
>
<Button
kind={kinds.DANGER}
isDisabled={isInUse}
onPress={onDeleteQualityProfilePress}
>
Delete
</Button>
</div>
}
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</Measure>
</ModalContent>
);
}
}
EditQualityProfileModalContent.propTypes = {
editGroups: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
item: PropTypes.object.isRequired,
isInUse: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onCutoffChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onContentHeightChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteQualityProfilePress: PropTypes.func
};
export default EditQualityProfileModalContent;
@@ -0,0 +1,442 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile } from 'Store/Actions/settingsActions';
import EditQualityProfileModalContent from './EditQualityProfileModalContent';
function getQualityItemGroupId(qualityProfile) {
// Get items with an `id` and filter out null/undefined values
const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null);
return Math.max(1000, ...ids) + 1;
}
function parseIndex(index) {
const split = index.split('.');
if (split.length === 1) {
return [
null,
parseInt(split[0]) - 1
];
}
return [
parseInt(split[0]) - 1,
parseInt(split[1]) - 1
];
}
function createQualitiesSelector() {
return createSelector(
createProviderSettingsSelector('qualityProfiles'),
(qualityProfile) => {
const items = qualityProfile.item.items;
if (!items || !items.value) {
return [];
}
return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => {
if (allowed) {
if (id) {
result.push({
key: id,
value: name
});
} else {
result.push({
key: quality.id,
value: quality.name
});
}
}
return result;
}, []);
}
);
}
function createMapStateToProps() {
return createSelector(
createProviderSettingsSelector('qualityProfiles'),
createQualitiesSelector(),
createProfileInUseSelector('qualityProfileId'),
(qualityProfile, qualities, isInUse) => {
return {
qualities,
...qualityProfile,
isInUse
};
}
);
}
const mapDispatchToProps = {
fetchQualityProfileSchema,
setQualityProfileValue,
saveQualityProfile
};
class EditQualityProfileModalContentConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null,
editGroups: false
};
}
componentDidMount() {
if (!this.props.id && !this.props.isPopulated) {
this.props.fetchQualityProfileSchema();
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Control
ensureCutoff = (qualityProfile) => {
const cutoff = qualityProfile.cutoff.value;
const cutoffItem = _.find(qualityProfile.items.value, (i) => {
if (!cutoff) {
return false;
}
return i.id === cutoff.id || (i.quality && i.quality.id === cutoff.id);
});
// If the cutoff isn't allowed anymore or there isn't a cutoff set one
if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
let cutoffId = null;
if (firstAllowed) {
cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id;
}
this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId });
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setQualityProfileValue({ name, value });
}
onCutoffChange = ({ name, value }) => {
const id = parseInt(value);
const item = _.find(this.props.item.items.value, (i) => {
if (i.quality) {
return i.quality.id === id;
}
return i.id === id;
});
const cutoffId = item.quality ? item.quality.id : item.id;
this.props.setQualityProfileValue({ name, value: cutoffId });
}
onSavePress = () => {
this.props.saveQualityProfile({ id: this.props.id });
}
onQualityProfileItemAllowedChange = (id, allowed) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id);
item.allowed = allowed;
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
onItemGroupAllowedChange = (id, allowed) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const item = _.find(qualityProfile.items.value, (i) => i.id === id);
item.allowed = allowed;
// Update each item in the group (for consistency only)
item.items.forEach((i) => {
i.allowed = allowed;
});
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
onItemGroupNameChange = (id, name) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const group = _.find(items, (i) => i.id === id);
group.name = name;
this.props.setQualityProfileValue({
name: 'items',
value: items
});
}
onCreateGroupPress = (id) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const item = _.find(items, (i) => i.quality && i.quality.id === id);
const index = items.indexOf(item);
const groupId = getQualityItemGroupId(qualityProfile);
const group = {
id: groupId,
name: item.quality.name,
allowed: item.allowed,
items: [
item
]
};
// Add the group in the same location the quality item was in.
items.splice(index, 1, group);
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
onDeleteGroupPress = (id) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const group = _.find(items, (i) => i.id === id);
const index = items.indexOf(group);
// Add the items in the same location the group was in
items.splice(index, 1, ...group.items);
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
onQualityProfileItemDragMove = (options) => {
const {
dragQualityIndex,
dropQualityIndex,
dropPosition
} = options;
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
if (
(dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
(dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
) {
if (
this.state.dragQualityIndex != null &&
this.state.dropQualityIndex != null &&
this.state.dropPosition != null
) {
this.setState({
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null
});
}
return;
}
let adjustedDropQualityIndex = dropQualityIndex;
// Correct dragging out of a group to the position above
if (
dropPosition === 'above' &&
dragGroupIndex !== dropGroupIndex &&
dropGroupIndex != null
) {
// Add 1 to the group index and 2 to the item index so it's inserted above in the correct group
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`;
}
// Correct inserting above outside a group
if (
dropPosition === 'above' &&
dragGroupIndex !== dropGroupIndex &&
dropGroupIndex == null
) {
// Add 2 to the item index so it's entered in the correct place
adjustedDropQualityIndex = `${dropItemIndex + 2}`;
}
// Correct inserting below a quality within the same group (when moving a lower item)
if (
dropPosition === 'below' &&
dragGroupIndex === dropGroupIndex &&
dropGroupIndex != null &&
dragItemIndex < dropItemIndex
) {
// Add 1 to the group index leave the item index
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`;
}
// Correct inserting below a quality outside a group (when moving a lower item)
if (
dropPosition === 'below' &&
dragGroupIndex === dropGroupIndex &&
dropGroupIndex == null &&
dragItemIndex < dropItemIndex
) {
// Leave the item index so it's inserted below the item
adjustedDropQualityIndex = `${dropItemIndex}`;
}
if (
dragQualityIndex !== this.state.dragQualityIndex ||
adjustedDropQualityIndex !== this.state.dropQualityIndex ||
dropPosition !== this.state.dropPosition
) {
this.setState({
dragQualityIndex,
dropQualityIndex: adjustedDropQualityIndex,
dropPosition
});
}
}
onQualityProfileItemDragEnd = (didDrop) => {
const {
dragQualityIndex,
dropQualityIndex
} = this.state;
if (didDrop && dropQualityIndex != null) {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
let item = null;
let dropGroup = null;
// Get the group before moving anything so we know the correct place to drop it.
if (dropGroupIndex != null) {
dropGroup = items[dropGroupIndex];
}
if (dragGroupIndex == null) {
item = items.splice(dragItemIndex, 1)[0];
} else {
const group = items[dragGroupIndex];
item = group.items.splice(dragItemIndex, 1)[0];
// If the group is now empty, destroy it.
if (!group.items.length) {
items.splice(dragGroupIndex, 1);
}
}
if (dropGroupIndex == null) {
items.splice(dropItemIndex, 0, item);
} else {
dropGroup.items.splice(dropItemIndex, 0, item);
}
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
this.setState({
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null
});
}
onToggleEditGroupsMode = () => {
this.setState({ editGroups: !this.state.editGroups });
}
//
// Render
render() {
if (_.isEmpty(this.props.item.items) && !this.props.isFetching) {
return null;
}
return (
<EditQualityProfileModalContent
{...this.state}
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onCutoffChange={this.onCutoffChange}
onCreateGroupPress={this.onCreateGroupPress}
onDeleteGroupPress={this.onDeleteGroupPress}
onQualityProfileItemAllowedChange={this.onQualityProfileItemAllowedChange}
onItemGroupAllowedChange={this.onItemGroupAllowedChange}
onItemGroupNameChange={this.onItemGroupNameChange}
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
onToggleEditGroupsMode={this.onToggleEditGroupsMode}
/>
);
}
}
EditQualityProfileModalContentConnector.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setQualityProfileValue: PropTypes.func.isRequired,
fetchQualityProfileSchema: PropTypes.func.isRequired,
saveQualityProfile: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector);
@@ -0,0 +1,38 @@
.qualityProfile {
composes: card from 'Components/Card.css';
width: 300px;
}
.nameContainer {
display: flex;
justify-content: space-between;
}
.name {
@add-mixin truncate;
margin-bottom: 20px;
font-weight: 300;
font-size: 24px;
}
.cloneButton {
composes: button from 'Components/Link/IconButton.css';
height: 36px;
}
.qualities {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
pointer-events: all;
}
.tooltipLabel {
composes: label from 'Components/Label.css';
margin: 0;
border: none;
}
@@ -0,0 +1,184 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, tooltipPositions } 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 Tooltip from 'Components/Tooltip/Tooltip';
import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
import styles from './QualityProfile.css';
class QualityProfile extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditQualityProfileModalOpen: false,
isDeleteQualityProfileModalOpen: false
};
}
//
// Listeners
onEditQualityProfilePress = () => {
this.setState({ isEditQualityProfileModalOpen: true });
}
onEditQualityProfileModalClose = () => {
this.setState({ isEditQualityProfileModalOpen: false });
}
onDeleteQualityProfilePress = () => {
this.setState({
isEditQualityProfileModalOpen: false,
isDeleteQualityProfileModalOpen: true
});
}
onDeleteQualityProfileModalClose = () => {
this.setState({ isDeleteQualityProfileModalOpen: false });
}
onConfirmDeleteQualityProfile = () => {
this.props.onConfirmDeleteQualityProfile(this.props.id);
}
onCloneQualityProfilePress = () => {
const {
id,
onCloneQualityProfilePress
} = this.props;
onCloneQualityProfilePress(id);
}
//
// Render
render() {
const {
id,
name,
cutoff,
items,
isDeleting
} = this.props;
return (
<Card
className={styles.qualityProfile}
overlayContent={true}
onPress={this.onEditQualityProfilePress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<IconButton
className={styles.cloneButton}
title="Clone Profile"
name={icons.CLONE}
onPress={this.onCloneQualityProfilePress}
/>
</div>
<div className={styles.qualities}>
{
items.map((item) => {
if (!item.allowed) {
return null;
}
if (item.quality) {
const isCutoff = item.quality.id === cutoff.id;
return (
<Label
key={item.quality.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
>
{item.quality.name}
</Label>
);
}
const isCutoff = item.id === cutoff.id;
return (
<Tooltip
key={item.id}
className={styles.tooltipLabel}
anchor={
<Label
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
>
{item.name}
</Label>
}
tooltip={
<div>
{
item.items.map((groupItem) => {
return (
<Label
key={groupItem.quality.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
>
{groupItem.quality.name}
</Label>
);
})
}
</div>
}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
);
})
}
</div>
<EditQualityProfileModalConnector
id={id}
isOpen={this.state.isEditQualityProfileModalOpen}
onModalClose={this.onEditQualityProfileModalClose}
onDeleteQualityProfilePress={this.onDeleteQualityProfilePress}
/>
<ConfirmModal
isOpen={this.state.isDeleteQualityProfileModalOpen}
kind={kinds.DANGER}
title="Delete Quality Profile"
message={`Are you sure you want to delete the quality profile '${name}'?`}
confirmLabel="Delete"
isSpinning={isDeleting}
onConfirm={this.onConfirmDeleteQualityProfile}
onCancel={this.onDeleteQualityProfileModalClose}
/>
</Card>
);
}
}
QualityProfile.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
cutoff: PropTypes.object.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
onCloneQualityProfilePress: PropTypes.func.isRequired
};
export default QualityProfile;
@@ -0,0 +1,85 @@
.qualityProfileItem {
display: flex;
align-items: stretch;
width: 100%;
border: 1px solid #aaa;
border-radius: 4px;
background: #fafafa;
&.isInGroup {
border-style: dashed;
}
}
.checkInputContainer {
position: relative;
margin-right: 4px;
margin-bottom: 5px;
margin-left: 8px;
}
.checkInput {
composes: input from 'Components/Form/CheckInput.css';
margin-top: 5px;
}
.qualityNameContainer {
display: flex;
flex-grow: 1;
margin-bottom: 0;
margin-left: 2px;
font-weight: normal;
line-height: $qualityProfileItemHeight;
cursor: pointer;
}
.qualityName {
&.isInGroup {
margin-left: 14px;
}
&.notAllowed {
color: #c6c6c6;
}
}
.createGroupButton {
composes: buton from 'Components/Link/IconButton.css';
display: flex;
justify-content: center;
flex-shrink: 0;
margin-right: 5px;
margin-left: 8px;
width: 20px;
}
.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;
}
.isPreview {
.qualityName {
margin-left: 14px;
&.isInGroup {
margin-left: 28px;
}
}
}
@@ -0,0 +1,131 @@
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 IconButton from 'Components/Link/IconButton';
import CheckInput from 'Components/Form/CheckInput';
import styles from './QualityProfileItem.css';
class QualityProfileItem extends Component {
//
// Listeners
onAllowedChange = ({ value }) => {
const {
qualityId,
onQualityProfileItemAllowedChange
} = this.props;
onQualityProfileItemAllowedChange(qualityId, value);
}
onCreateGroupPress = () => {
const {
qualityId,
onCreateGroupPress
} = this.props;
onCreateGroupPress(qualityId);
}
//
// Render
render() {
const {
editGroups,
isPreview,
groupId,
name,
allowed,
isDragging,
isOverCurrent,
connectDragSource
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileItem,
isDragging && styles.isDragging,
isPreview && styles.isPreview,
isOverCurrent && styles.isOverCurrent,
groupId && styles.isInGroup
)}
>
<label
className={styles.qualityNameContainer}
>
{
editGroups && !groupId && !isPreview &&
<IconButton
className={styles.createGroupButton}
name={icons.GROUP}
title="Group"
onPress={this.onCreateGroupPress}
/>
}
{
!editGroups &&
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name={name}
value={allowed}
isDisabled={!!groupId}
onChange={this.onAllowedChange}
/>
}
<div className={classNames(
styles.qualityName,
groupId && styles.isInGroup,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</label>
{
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
title="Create group"
name={icons.REORDER}
/>
</div>
)
}
</div>
);
}
}
QualityProfileItem.propTypes = {
editGroups: PropTypes.bool,
isPreview: PropTypes.bool,
groupId: PropTypes.number,
qualityId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
isDragging: PropTypes.bool.isRequired,
isOverCurrent: PropTypes.bool.isRequired,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func
};
QualityProfileItem.defaultProps = {
isPreview: false,
isOverCurrent: false,
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default QualityProfileItem;
@@ -0,0 +1,4 @@
.dragPreview {
width: 380px;
opacity: 0.75;
}
@@ -0,0 +1,92 @@
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_ITEM } from 'Helpers/dragTypes';
import DragPreviewLayer from 'Components/DragPreviewLayer';
import QualityProfileItem from './QualityProfileItem';
import styles from './QualityProfileItemDragPreview.css';
const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth);
const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
function collectDragLayer(monitor) {
return {
item: monitor.getItem(),
itemType: monitor.getItemType(),
currentOffset: monitor.getSourceClientOffset()
};
}
class QualityProfileItemDragPreview extends Component {
//
// Render
render() {
const {
item,
itemType,
currentOffset
} = this.props;
if (!currentOffset || itemType !== QUALITY_PROFILE_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 = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = {
position: 'absolute',
WebkitTransform: transform,
msTransform: transform,
transform
};
const {
editGroups,
groupId,
qualityId,
name,
allowed
} = item;
// TODO: Show a different preview for groups
return (
<DragPreviewLayer>
<div
className={styles.dragPreview}
style={style}
>
<QualityProfileItem
editGroups={editGroups}
isPreview={true}
qualityId={groupId || qualityId}
name={name}
allowed={allowed}
isDragging={false}
/>
</div>
</DragPreviewLayer>
);
}
}
QualityProfileItemDragPreview.propTypes = {
item: PropTypes.object,
itemType: PropTypes.string,
currentOffset: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
})
};
export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview);
@@ -0,0 +1,18 @@
.qualityProfileItemDragSource {
padding: $qualityProfileItemDragSourcePadding 0;
}
.qualityProfileItemPlaceholder {
width: 100%;
height: $qualityProfileItemHeight;
border: 1px dotted #aaa;
border-radius: 4px;
}
.qualityProfileItemPlaceholderBefore {
margin-bottom: 8px;
}
.qualityProfileItemPlaceholderAfter {
margin-top: 8px;
}
@@ -0,0 +1,241 @@
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_ITEM } from 'Helpers/dragTypes';
import QualityProfileItem from './QualityProfileItem';
import QualityProfileItemGroup from './QualityProfileItemGroup';
import styles from './QualityProfileItemDragSource.css';
const qualityProfileItemDragSource = {
beginDrag(props) {
const {
editGroups,
qualityIndex,
groupId,
qualityId,
name,
allowed
} = props;
return {
editGroups,
qualityIndex,
groupId,
qualityId,
isGroup: !qualityId,
name,
allowed
};
},
endDrag(props, monitor, component) {
props.onQualityProfileItemDragEnd(monitor.didDrop());
}
};
const qualityProfileItemDropTarget = {
hover(props, monitor, component) {
const {
qualityIndex: dragQualityIndex,
isGroup: isDragGroup
} = monitor.getItem();
const dropQualityIndex = props.qualityIndex;
const isDropGroupItem = !!(props.qualityId && props.groupId);
// Use childNodeIndex to select the correct node to get the middle of so
// we don't bounce between above and below causing rapid setState calls.
const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0;
const componentDOMNode = findDOMNode(component).children[childNodeIndex];
const hoverBoundingRect = componentDOMNode.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// If we're hovering over a child don't trigger on the parent
if (!monitor.isOver({ shallow: true })) {
return;
}
// Don't show targets for dropping on self
if (dragQualityIndex === dropQualityIndex) {
return;
}
// Don't allow a group to be dropped inside a group
if (isDragGroup && isDropGroupItem) {
return;
}
let dropPosition = null;
// Determine drop position based on position over target
if (hoverClientY > hoverMiddleY) {
dropPosition = 'below';
} else if (hoverClientY < hoverMiddleY) {
dropPosition = 'above';
} else {
return;
}
props.onQualityProfileItemDragMove({
dragQualityIndex,
dropQualityIndex,
dropPosition
});
}
};
function collectDragSource(connect, monitor) {
return {
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging()
};
}
function collectDropTarget(connect, monitor) {
return {
connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver(),
isOverCurrent: monitor.isOver({ shallow: true })
};
}
class QualityProfileItemDragSource extends Component {
//
// Render
render() {
const {
editGroups,
groupId,
qualityId,
name,
allowed,
items,
qualityIndex,
isDragging,
isDraggingUp,
isDraggingDown,
isOverCurrent,
connectDragSource,
connectDropTarget,
onCreateGroupPress,
onDeleteGroupPress,
onQualityProfileItemAllowedChange,
onItemGroupAllowedChange,
onItemGroupNameChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd
} = this.props;
const isBefore = !isDragging && isDraggingUp && isOverCurrent;
const isAfter = !isDragging && isDraggingDown && isOverCurrent;
return connectDropTarget(
<div
className={classNames(
styles.qualityProfileItemDragSource,
isBefore && styles.isDraggingUp,
isAfter && styles.isDraggingDown
)}
>
{
isBefore &&
<div
className={classNames(
styles.qualityProfileItemPlaceholder,
styles.qualityProfileItemPlaceholderBefore
)}
/>
}
{
!!groupId && qualityId == null &&
<QualityProfileItemGroup
editGroups={editGroups}
groupId={groupId}
name={name}
allowed={allowed}
items={items}
qualityIndex={qualityIndex}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
connectDragSource={connectDragSource}
onDeleteGroupPress={onDeleteGroupPress}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onItemGroupAllowedChange={onItemGroupAllowedChange}
onItemGroupNameChange={onItemGroupNameChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
/>
}
{
qualityId != null &&
<QualityProfileItem
editGroups={editGroups}
groupId={groupId}
qualityId={qualityId}
name={name}
allowed={allowed}
qualityIndex={qualityIndex}
isDragging={isDragging}
isOverCurrent={isOverCurrent}
connectDragSource={connectDragSource}
onCreateGroupPress={onCreateGroupPress}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
/>
}
{
isAfter &&
<div
className={classNames(
styles.qualityProfileItemPlaceholder,
styles.qualityProfileItemPlaceholderAfter
)}
/>
}
</div>
);
}
}
QualityProfileItemDragSource.propTypes = {
editGroups: PropTypes.bool.isRequired,
groupId: PropTypes.number,
qualityId: PropTypes.number,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
qualityIndex: PropTypes.string.isRequired,
isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool,
isDraggingDown: PropTypes.bool,
isOverCurrent: PropTypes.bool,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func,
connectDropTarget: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onDeleteGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
onItemGroupAllowedChange: PropTypes.func,
onItemGroupNameChange: PropTypes.func,
onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired
};
export default DropTarget(
QUALITY_PROFILE_ITEM,
qualityProfileItemDropTarget,
collectDropTarget
)(DragSource(
QUALITY_PROFILE_ITEM,
qualityProfileItemDragSource,
collectDragSource
)(QualityProfileItemDragSource));
@@ -0,0 +1,105 @@
.qualityProfileItemGroup {
width: 100%;
border: 1px solid #aaa;
border-radius: 4px;
background: #fafafa;
&.editGroups {
background: #fcfcfc;
}
}
.qualityProfileItemGroupInfo {
display: flex;
align-items: stretch;
width: 100%;
}
.checkInputContainer {
composes: checkInputContainer from './QualityProfileItem.css';
display: flex;
align-items: center;
}
.checkInput {
composes: checkInput from './QualityProfileItem.css';
}
.nameInput {
composes: input from 'Components/Form/TextInput.css';
margin-top: 4px;
margin-right: 10px;
}
.nameContainer {
display: flex;
align-items: center;
flex-grow: 1;
}
.name {
flex-shrink: 0;
&.notAllowed {
color: #c6c6c6;
}
}
.groupQualities {
display: flex;
justify-content: flex-end;
flex-grow: 1;
flex-wrap: wrap;
margin: 2px 0 2px 10px;
}
.qualityNameContainer {
display: flex;
align-items: stretch;
flex-grow: 1;
margin-bottom: 0;
margin-left: 2px;
font-weight: normal;
}
.qualityNameLabel {
composes: qualityNameContainer;
cursor: pointer;
}
.deleteGroupButton {
composes: buton from 'Components/Link/IconButton.css';
display: flex;
justify-content: center;
flex-shrink: 0;
margin-right: 5px;
margin-left: 8px;
width: 20px;
}
.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;
}
.items {
margin: 0 50px 0 35px;
}
@@ -0,0 +1,200 @@
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 Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import CheckInput from 'Components/Form/CheckInput';
import TextInput from 'Components/Form/TextInput';
import QualityProfileItemDragSource from './QualityProfileItemDragSource';
import styles from './QualityProfileItemGroup.css';
class QualityProfileItemGroup extends Component {
//
// Listeners
onAllowedChange = ({ value }) => {
const {
groupId,
onItemGroupAllowedChange
} = this.props;
onItemGroupAllowedChange(groupId, value);
}
onNameChange = ({ value }) => {
const {
groupId,
onItemGroupNameChange
} = this.props;
onItemGroupNameChange(groupId, value);
}
onDeleteGroupPress = ({ value }) => {
const {
groupId,
onDeleteGroupPress
} = this.props;
onDeleteGroupPress(groupId, value);
}
//
// Render
render() {
const {
editGroups,
groupId,
name,
allowed,
items,
qualityIndex,
isDragging,
isDraggingUp,
isDraggingDown,
connectDragSource,
onQualityProfileItemAllowedChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileItemGroup,
editGroups && styles.editGroups,
isDragging && styles.isDragging,
)}
>
<div className={styles.qualityProfileItemGroupInfo}>
{
editGroups &&
<div className={styles.qualityNameContainer}>
<IconButton
className={styles.deleteGroupButton}
name={icons.UNGROUP}
title="Ungroup"
onPress={this.onDeleteGroupPress}
/>
<TextInput
className={styles.nameInput}
name="name"
value={name}
onChange={this.onNameChange}
/>
</div>
}
{
!editGroups &&
<label
className={styles.qualityNameLabel}
>
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name="allowed"
value={allowed}
onChange={this.onAllowedChange}
/>
<div className={styles.nameContainer}>
<div className={classNames(
styles.name,
!allowed && styles.notAllowed
)}
>
{name}
</div>
<div className={styles.groupQualities}>
{
items.map(({ quality }) => {
return (
<Label key={quality.id}>
{quality.name}
</Label>
);
}).reverse()
}
</div>
</div>
</label>
}
{
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
title="Reorder"
/>
</div>
)
}
</div>
{
editGroups &&
<div className={styles.items}>
{
items.map(({ quality }, index) => {
return (
<QualityProfileItemDragSource
key={quality.id}
editGroups={editGroups}
groupId={groupId}
qualityId={quality.id}
name={quality.name}
allowed={allowed}
items={items}
qualityIndex={`${qualityIndex}.${index + 1}`}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
isInGroup={true}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
/>
);
}).reverse()
}
</div>
}
</div>
);
}
}
QualityProfileItemGroup.propTypes = {
editGroups: PropTypes.bool,
groupId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
qualityIndex: PropTypes.string.isRequired,
isDragging: PropTypes.bool.isRequired,
isDraggingUp: PropTypes.bool.isRequired,
isDraggingDown: PropTypes.bool.isRequired,
connectDragSource: PropTypes.func,
onItemGroupAllowedChange: PropTypes.func.isRequired,
onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
onItemGroupNameChange: PropTypes.func.isRequired,
onDeleteGroupPress: PropTypes.func.isRequired,
onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired
};
QualityProfileItemGroup.defaultProps = {
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default QualityProfileItemGroup;
@@ -0,0 +1,15 @@
.editGroupsButton {
composes: button from 'Components/Link/Button.css';
margin-top: 10px;
}
.editGroupsButtonIcon {
margin-right: 8px;
}
.qualities {
margin-top: 10px;
transition: min-height 200ms;
user-select: none;
}
@@ -0,0 +1,181 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, sizes } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import Measure from 'Components/Measure';
import QualityProfileItemDragSource from './QualityProfileItemDragSource';
import QualityProfileItemDragPreview from './QualityProfileItemDragPreview';
import styles from './QualityProfileItems.css';
class QualityProfileItems extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
qualitiesHeight: 0,
qualitiesHeightEditGroups: 0
};
}
//
// Listeners
onMeasure = ({ height }) => {
if (this.props.editGroups) {
this.setState({
qualitiesHeightEditGroups: height
});
} else {
this.setState({ qualitiesHeight: height });
}
}
onToggleEditGroupsMode = () => {
this.props.onToggleEditGroupsMode();
}
//
// Render
render() {
const {
editGroups,
dropQualityIndex,
dropPosition,
qualityProfileItems,
errors,
warnings,
...otherProps
} = this.props;
const {
qualitiesHeight,
qualitiesHeightEditGroups
} = this.state;
const isDragging = dropQualityIndex !== null;
const isDraggingUp = isDragging && dropPosition === 'above';
const isDraggingDown = isDragging && dropPosition === 'below';
const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight;
return (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Qualities
</FormLabel>
<div>
<FormInputHelpText
text="Qualities higher in the list are more preferred. Only checked qualities are wanted"
/>
{
errors.map((error, index) => {
return (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={false}
/>
);
})
}
{
warnings.map((warning, index) => {
return (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={false}
/>
);
})
}
<Button
className={styles.editGroupsButton}
kind={kinds.PRIMARY}
onPress={this.onToggleEditGroupsMode}
>
<div>
<Icon
className={styles.editGroupsButtonIcon}
name={editGroups ? icons.REORDER : icons.GROUP}
/>
{
editGroups ? 'Done Editing Groups' : 'Edit Groups'
}
</div>
</Button>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onMeasure}
>
<div
className={styles.qualities}
style={{ minHeight: `${minHeight}px` }}
>
{
qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => {
const identifier = quality ? quality.id : id;
return (
<QualityProfileItemDragSource
key={identifier}
editGroups={editGroups}
groupId={id}
qualityId={quality && quality.id}
name={quality ? quality.name : name}
allowed={allowed}
items={items}
qualityIndex={`${index + 1}`}
isInGroup={false}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
{...otherProps}
/>
);
}).reverse()
}
<QualityProfileItemDragPreview />
</div>
</Measure>
</div>
</FormGroup>
);
}
}
QualityProfileItems.propTypes = {
editGroups: PropTypes.bool.isRequired,
dragQualityIndex: PropTypes.string,
dropQualityIndex: PropTypes.string,
dropPosition: PropTypes.string,
qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object),
onToggleEditGroupsMode: PropTypes.func.isRequired
};
QualityProfileItems.defaultProps = {
errors: [],
warnings: []
};
export default QualityProfileItems;
@@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
function createMapStateToProps() {
return createSelector(
createQualityProfileSelector(),
(qualityProfile) => {
return {
name: qualityProfile.name
};
}
);
}
function QualityProfileNameConnector({ name, ...otherProps }) {
return (
<span>
{name}
</span>
);
}
QualityProfileNameConnector.propTypes = {
qualityProfileId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired
};
export default connect(createMapStateToProps)(QualityProfileNameConnector);
@@ -0,0 +1,21 @@
.qualityProfiles {
display: flex;
flex-wrap: wrap;
}
.addQualityProfile {
composes: qualityProfile from './QualityProfile.css';
background-color: $cardAlternateBackgroundColor;
color: $gray;
text-align: center;
font-size: 45px;
}
.center {
display: inline-block;
padding: 5px 20px 0;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
}
@@ -0,0 +1,107 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import sortByName from 'Utilities/Array/sortByName';
import { icons } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Card from 'Components/Card';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import QualityProfile from './QualityProfile';
import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
import styles from './QualityProfiles.css';
class QualityProfiles extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isQualityProfileModalOpen: false
};
}
//
// Listeners
onCloneQualityProfilePress = (id) => {
this.props.onCloneQualityProfilePress(id);
this.setState({ isQualityProfileModalOpen: true });
}
onEditQualityProfilePress = () => {
this.setState({ isQualityProfileModalOpen: true });
}
onModalClose = () => {
this.setState({ isQualityProfileModalOpen: false });
}
//
// Render
render() {
const {
items,
isDeleting,
onConfirmDeleteQualityProfile,
onCloneQualityProfilePress,
...otherProps
} = this.props;
return (
<FieldSet legend="Quality Profiles">
<PageSectionContent
errorMessage="Unable to load Quality Profiles"
{...otherProps}c={true}
>
<div className={styles.qualityProfiles}>
{
items.sort(sortByName).map((item) => {
return (
<QualityProfile
key={item.id}
{...item}
isDeleting={isDeleting}
onConfirmDeleteQualityProfile={onConfirmDeleteQualityProfile}
onCloneQualityProfilePress={this.onCloneQualityProfilePress}
/>
);
})
}
<Card
className={styles.addQualityProfile}
onPress={this.onEditQualityProfilePress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
<EditQualityProfileModalConnector
isOpen={this.state.isQualityProfileModalOpen}
onModalClose={this.onModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
QualityProfiles.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
onCloneQualityProfilePress: PropTypes.func.isRequired
};
export default QualityProfiles;
@@ -0,0 +1,65 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchQualityProfiles, deleteQualityProfile, cloneQualityProfile } from 'Store/Actions/settingsActions';
import QualityProfiles from './QualityProfiles';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
return {
...qualityProfiles
};
}
);
}
const mapDispatchToProps = {
dispatchFetchQualityProfiles: fetchQualityProfiles,
dispatchDeleteQualityProfile: deleteQualityProfile,
dispatchCloneQualityProfile: cloneQualityProfile
};
class QualityProfilesConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchQualityProfiles();
}
//
// Listeners
onConfirmDeleteQualityProfile = (id) => {
this.props.dispatchDeleteQualityProfile({ id });
}
onCloneQualityProfilePress = (id) => {
this.props.dispatchCloneQualityProfile({ id });
}
//
// Render
render() {
return (
<QualityProfiles
onConfirmDeleteQualityProfile={this.onConfirmDeleteQualityProfile}
onCloneQualityProfilePress={this.onCloneQualityProfilePress}
{...this.props}
/>
);
}
}
QualityProfilesConnector.propTypes = {
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
dispatchDeleteQualityProfile: PropTypes.func.isRequired,
dispatchCloneQualityProfile: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector);