Upgrade react-dnd and DnD Components to TypeScript

This commit is contained in:
Mark McDowall 2024-12-29 18:21:01 -08:00
parent 572bdc979c
commit 1bc1b080d1
No known key found for this signature in database
85 changed files with 3525 additions and 4767 deletions

View file

@ -39,7 +39,7 @@ export interface AutoTaggingSpecificationAppState
AppSectionSchemaState<AutoTaggingSpecification> {}
export interface DelayProfileAppState
extends AppSectionState<DelayProfile>,
extends AppSectionListState<DelayProfile>,
AppSectionDeleteState,
AppSectionSaveState {}
@ -77,7 +77,7 @@ export interface NotificationAppState
AppSectionDeleteState {}
export interface QualityDefinitionsAppState
extends AppSectionState<QualityProfile>,
extends AppSectionState<QualityDefinition>,
AppSectionSaveState {
pendingChanges: {
[key: number]: Partial<QualityProfile>;
@ -86,7 +86,9 @@ export interface QualityDefinitionsAppState
export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionItemSchemaState<QualityProfile> {}
AppSectionItemSchemaState<QualityProfile>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface ReleaseProfilesAppState
extends AppSectionState<ReleaseProfile>,

View file

@ -1,9 +0,0 @@
.dragLayer {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
width: 100%;
height: 100%;
pointer-events: none;
}

View file

@ -1,7 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'dragLayer': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,21 +0,0 @@
import React from 'react';
import styles from './DragPreviewLayer.css';
interface DragPreviewLayerProps {
className?: string;
children?: React.ReactNode;
}
function DragPreviewLayer({
className = styles.dragLayer,
children,
...otherProps
}: DragPreviewLayerProps) {
return (
<div className={className} {...otherProps}>
{children}
</div>
);
}
export default DragPreviewLayer;

View file

@ -1,16 +1,21 @@
import React from 'react';
import React, { ForwardedRef, forwardRef, ReactNode } from 'react';
import styles from './ModalFooter.css';
interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
children: ReactNode;
}
function ModalFooter({ children, ...otherProps }: ModalFooterProps) {
return (
<div className={styles.modalFooter} {...otherProps}>
{children}
</div>
);
}
const ModalFooter = forwardRef(
(
{ children, ...otherProps }: ModalFooterProps,
ref: ForwardedRef<HTMLDivElement>
) => {
return (
<div ref={ref} className={styles.modalFooter} {...otherProps}>
{children}
</div>
);
}
);
export default ModalFooter;

View file

@ -1,16 +1,21 @@
import React from 'react';
import React, { ForwardedRef, forwardRef, ReactNode } from 'react';
import styles from './ModalHeader.css';
interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
children: ReactNode;
}
function ModalHeader({ children, ...otherProps }: ModalHeaderProps) {
return (
<div className={styles.modalHeader} {...otherProps}>
{children}
</div>
);
}
const ModalHeader = forwardRef(
(
{ children, ...otherProps }: ModalHeaderProps,
ref: ForwardedRef<HTMLDivElement>
) => {
return (
<div ref={ref} className={styles.modalHeader} {...otherProps}>
{children}
</div>
);
}
);
export default ModalHeader;

View file

@ -1,3 +1,7 @@
.columnContainer {
margin: 4px 0;
}
.column {
display: flex;
align-items: stretch;
@ -43,6 +47,17 @@
opacity: 0.25;
}
.notDragable {
padding: 4px 0;
.placeholder {
width: 100%;
height: 36px;
border: 1px dotted #aaa;
border-radius: 4px;
}
.placeholderBefore {
margin-bottom: 8px;
}
.placeholderAfter {
margin-top: 8px;
}

View file

@ -3,11 +3,14 @@
interface CssExports {
'checkContainer': string;
'column': string;
'columnContainer': string;
'dragHandle': string;
'dragIcon': string;
'isDragging': string;
'label': string;
'notDragable': string;
'placeholder': string;
'placeholderAfter': string;
'placeholderBefore': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,68 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import CheckInput from 'Components/Form/CheckInput';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import styles from './TableOptionsColumn.css';
function TableOptionsColumn(props) {
const {
name,
label,
isVisible,
isModifiable,
isDragging,
connectDragSource,
onVisibleChange
} = props;
return (
<div className={isModifiable ? undefined : styles.notDragable}>
<div
className={classNames(
styles.column,
isDragging && styles.isDragging
)}
>
<label
className={styles.label}
>
<CheckInput
containerClassName={styles.checkContainer}
name={name}
value={isVisible}
isDisabled={isModifiable === false}
onChange={onVisibleChange}
/>
{typeof label === 'function' ? label() : label}
</label>
{
!!connectDragSource &&
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
/>
</div>
)
}
</div>
</div>
);
}
TableOptionsColumn.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
isVisible: PropTypes.bool.isRequired,
isModifiable: PropTypes.bool.isRequired,
index: PropTypes.number.isRequired,
isDragging: PropTypes.bool,
connectDragSource: PropTypes.func,
onVisibleChange: PropTypes.func.isRequired
};
export default TableOptionsColumn;

View file

@ -0,0 +1,163 @@
import classNames from 'classnames';
import React, { useRef } from 'react';
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
import CheckInput from 'Components/Form/CheckInput';
import Icon from 'Components/Icon';
import DragType from 'Helpers/DragType';
import { icons } from 'Helpers/Props';
import { CheckInputChanged } from 'typings/inputs';
import Column from '../Column';
import styles from './TableOptionsColumn.css';
interface DragItem {
name: string;
index: number;
}
interface TableOptionsColumnProps {
name: string;
label: Column['label'];
isDraggingDown: boolean;
isDraggingUp: boolean;
isVisible: boolean;
isModifiable: boolean;
index: number;
onVisibleChange: (change: CheckInputChanged) => void;
onColumnDragEnd: (didDrop: boolean) => void;
onColumnDragMove: (dragIndex: number, hoverIndex: number) => void;
}
function TableOptionsColumn({
name,
label,
index,
isDraggingDown,
isDraggingUp,
isVisible,
isModifiable,
onVisibleChange,
onColumnDragEnd,
onColumnDragMove,
}: TableOptionsColumnProps) {
const ref = useRef<HTMLDivElement>(null);
const [{ isOver }, dropRef] = useDrop<DragItem, void, { isOver: boolean }>({
accept: DragType.TableColumn,
collect(monitor) {
return {
isOver: monitor.isOver(),
};
},
hover(item: DragItem, monitor) {
if (!ref.current) {
return;
}
if (!isModifiable) {
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
// 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.
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
onColumnDragMove(dragIndex, hoverIndex);
},
});
const [{ isDragging }, dragRef, previewRef] = useDrag<
DragItem,
unknown,
{ isDragging: boolean }
>({
type: DragType.TableColumn,
item: () => {
return {
name,
index,
};
},
collect: (monitor: DragSourceMonitor<unknown, unknown>) => ({
isDragging: monitor.isDragging(),
}),
end: (_item: DragItem, monitor) => {
onColumnDragEnd(monitor.didDrop());
},
});
dropRef(previewRef(ref));
const isBefore = !isDragging && isDraggingUp && isOver;
const isAfter = !isDragging && isDraggingDown && isOver;
return (
<div ref={ref} className={styles.columnContainer}>
{isBefore ? (
<div
className={classNames(styles.placeholder, styles.placeholderBefore)}
/>
) : null}
<div
className={classNames(styles.column, isDragging && styles.isDragging)}
>
<label className={styles.label}>
<CheckInput
containerClassName={styles.checkContainer}
name={name}
value={isVisible}
isDisabled={isModifiable === false}
onChange={onVisibleChange}
/>
{typeof label === 'function' ? label() : label}
</label>
{isModifiable ? (
<div ref={dragRef} className={styles.dragHandle}>
<Icon className={styles.dragIcon} name={icons.REORDER} />
</div>
) : null}
</div>
{isAfter ? (
<div
className={classNames(styles.placeholder, styles.placeholderAfter)}
/>
) : null}
</div>
);
}
export default TableOptionsColumn;

View file

@ -1,4 +0,0 @@
.dragPreview {
width: 380px;
opacity: 0.75;
}

View file

@ -1,7 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'dragPreview': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,78 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragLayer } from 'react-dnd';
import DragPreviewLayer from 'Components/DragPreviewLayer';
import { TABLE_COLUMN } from 'Helpers/dragTypes';
import dimensions from 'Styles/Variables/dimensions.js';
import TableOptionsColumn from './TableOptionsColumn';
import styles from './TableOptionsColumnDragPreview.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 TableOptionsColumnDragPreview extends Component {
//
// Render
render() {
const {
item,
itemType,
currentOffset
} = this.props;
if (!currentOffset || itemType !== TABLE_COLUMN) {
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
};
return (
<DragPreviewLayer>
<div
className={styles.dragPreview}
style={style}
>
<TableOptionsColumn
isDragging={false}
{...item}
/>
</div>
</DragPreviewLayer>
);
}
}
TableOptionsColumnDragPreview.propTypes = {
item: PropTypes.object,
itemType: PropTypes.string,
currentOffset: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
})
};
export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview);

View file

@ -1,18 +0,0 @@
.columnDragSource {
padding: 4px 0;
}
.columnPlaceholder {
width: 100%;
height: 36px;
border: 1px dotted #aaa;
border-radius: 4px;
}
.columnPlaceholderBefore {
margin-bottom: 8px;
}
.columnPlaceholderAfter {
margin-top: 8px;
}

View file

@ -1,10 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'columnDragSource': string;
'columnPlaceholder': string;
'columnPlaceholderAfter': string;
'columnPlaceholderBefore': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,164 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragSource, DropTarget } from 'react-dnd';
import { findDOMNode } from 'react-dom';
import { TABLE_COLUMN } from 'Helpers/dragTypes';
import TableOptionsColumn from './TableOptionsColumn';
import styles from './TableOptionsColumnDragSource.css';
const columnDragSource = {
beginDrag(column) {
return column;
},
endDrag(props, monitor, component) {
props.onColumnDragEnd(monitor.getItem(), monitor.didDrop());
}
};
const columnDropTarget = {
hover(props, monitor, component) {
const dragIndex = monitor.getItem().index;
const hoverIndex = props.index;
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.
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
props.onColumnDragMove(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 TableOptionsColumnDragSource extends Component {
//
// Render
render() {
const {
name,
label,
isVisible,
isModifiable,
index,
isDragging,
isDraggingUp,
isDraggingDown,
isOver,
connectDragSource,
connectDropTarget,
onVisibleChange
} = this.props;
const isBefore = !isDragging && isDraggingUp && isOver;
const isAfter = !isDragging && isDraggingDown && isOver;
// if (isDragging && !isOver) {
// return null;
// }
return connectDropTarget(
<div
className={classNames(
styles.columnDragSource,
isBefore && styles.isDraggingUp,
isAfter && styles.isDraggingDown
)}
>
{
isBefore &&
<div
className={classNames(
styles.columnPlaceholder,
styles.columnPlaceholderBefore
)}
/>
}
<TableOptionsColumn
name={name}
label={typeof label === 'function' ? label() : label}
isVisible={isVisible}
isModifiable={isModifiable}
index={index}
isDragging={isDragging}
isOver={isOver}
connectDragSource={connectDragSource}
onVisibleChange={onVisibleChange}
/>
{
isAfter &&
<div
className={classNames(
styles.columnPlaceholder,
styles.columnPlaceholderAfter
)}
/>
}
</div>
);
}
}
TableOptionsColumnDragSource.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
isVisible: PropTypes.bool.isRequired,
isModifiable: PropTypes.bool.isRequired,
index: PropTypes.number.isRequired,
isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool,
isDraggingDown: PropTypes.bool,
isOver: PropTypes.bool,
connectDragSource: PropTypes.func,
connectDropTarget: PropTypes.func,
onVisibleChange: PropTypes.func.isRequired,
onColumnDragMove: PropTypes.func.isRequired,
onColumnDragEnd: PropTypes.func.isRequired
};
export default DropTarget(
TABLE_COLUMN,
columnDropTarget,
collectDropTarget
)(DragSource(
TABLE_COLUMN,
columnDragSource,
collectDragSource
)(TableOptionsColumnDragSource));

View file

@ -1,263 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DndProvider } from 'react-dnd-multi-backend';
import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import TableOptionsColumn from './TableOptionsColumn';
import TableOptionsColumnDragPreview from './TableOptionsColumnDragPreview';
import TableOptionsColumnDragSource from './TableOptionsColumnDragSource';
import styles from './TableOptionsModal.css';
class TableOptionsModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
hasPageSize: !!props.pageSize,
pageSize: props.pageSize,
pageSizeError: null,
dragIndex: null,
dropIndex: null
};
}
componentDidUpdate(prevProps) {
if (prevProps.pageSize !== this.state.pageSize) {
this.setState({ pageSize: this.props.pageSize });
}
}
//
// Listeners
onPageSizeChange = ({ value }) => {
let pageSizeError = null;
const maxPageSize = this.props.maxPageSize ?? 250;
if (value < 5) {
pageSizeError = translate('TablePageSizeMinimum', { minimumValue: '5' });
} else if (value > maxPageSize) {
pageSizeError = translate('TablePageSizeMaximum', { maximumValue: `${maxPageSize}` });
} else {
this.props.onTableOptionChange({ pageSize: value });
}
this.setState({
pageSize: value,
pageSizeError
});
};
onVisibleChange = ({ name, value }) => {
const columns = _.cloneDeep(this.props.columns);
const column = _.find(columns, { name });
column.isVisible = value;
this.props.onTableOptionChange({ columns });
};
onColumnDragMove = (dragIndex, dropIndex) => {
if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
this.setState({
dragIndex,
dropIndex
});
}
};
onColumnDragEnd = ({ id }, didDrop) => {
const {
dragIndex,
dropIndex
} = this.state;
if (didDrop && dropIndex !== null) {
const columns = _.cloneDeep(this.props.columns);
const items = columns.splice(dragIndex, 1);
columns.splice(dropIndex, 0, items[0]);
this.props.onTableOptionChange({ columns });
}
this.setState({
dragIndex: null,
dropIndex: null
});
};
//
// Render
render() {
const {
isOpen,
columns,
canModifyColumns,
optionsComponent: OptionsComponent,
onTableOptionChange,
onModalClose
} = this.props;
const {
hasPageSize,
pageSize,
pageSizeError,
dragIndex,
dropIndex
} = this.state;
const isDragging = dropIndex !== null;
const isDraggingUp = isDragging && dropIndex < dragIndex;
const isDraggingDown = isDragging && dropIndex > dragIndex;
return (
<DndProvider options={HTML5toTouch}>
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
{
isOpen ?
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('TableOptions')}
</ModalHeader>
<ModalBody>
<Form>
{
hasPageSize ?
<FormGroup>
<FormLabel>{translate('TablePageSize')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="pageSize"
value={pageSize || 0}
helpText={translate('TablePageSizeHelpText')}
errors={pageSizeError ? [{ message: pageSizeError }] : undefined}
onChange={this.onPageSizeChange}
/>
</FormGroup> :
null
}
{
OptionsComponent ?
<OptionsComponent
onTableOptionChange={onTableOptionChange}
/> : null
}
{
canModifyColumns ?
<FormGroup>
<FormLabel>{translate('TableColumns')}</FormLabel>
<div>
<FormInputHelpText
text={translate('TableColumnsHelpText')}
/>
<div className={styles.columns}>
{
columns.map((column, index) => {
const {
name,
label,
columnLabel,
isVisible,
isModifiable
} = column;
if (isModifiable !== false) {
return (
<TableOptionsColumnDragSource
key={name}
name={name}
label={columnLabel || label}
isVisible={isVisible}
isModifiable={true}
index={index}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
onVisibleChange={this.onVisibleChange}
onColumnDragMove={this.onColumnDragMove}
onColumnDragEnd={this.onColumnDragEnd}
/>
);
}
return (
<TableOptionsColumn
key={name}
name={name}
label={columnLabel || label}
isVisible={isVisible}
index={index}
isModifiable={false}
onVisibleChange={this.onVisibleChange}
/>
);
})
}
<TableOptionsColumnDragPreview />
</div>
</div>
</FormGroup> :
null
}
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent> :
null
}
</Modal>
</DndProvider>
);
}
}
TableOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
pageSize: PropTypes.number,
maxPageSize: PropTypes.number,
canModifyColumns: PropTypes.bool.isRequired,
optionsComponent: PropTypes.elementType,
onTableOptionChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
TableOptionsModal.defaultProps = {
canModifyColumns: true
};
export default TableOptionsModal;

View file

@ -0,0 +1,216 @@
import _ from 'lodash';
import { HTML5toTouch } from 'rdndmb-html5-to-touch';
import React, { useCallback, useEffect, useState } from 'react';
import { DndProvider } from 'react-dnd-multi-backend';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import { CheckInputChanged, InputChanged } from 'typings/inputs';
import { TableOptionsChangePayload } from 'typings/Table';
import translate from 'Utilities/String/translate';
import Column from '../Column';
import TableOptionsColumn from './TableOptionsColumn';
import styles from './TableOptionsModal.css';
interface TableOptionsModalProps {
isOpen: boolean;
columns: Column[];
pageSize?: number;
maxPageSize?: number;
canModifyColumns: boolean;
optionsComponent?: React.ElementType;
onTableOptionChange: (payload: TableOptionsChangePayload) => void;
onModalClose: () => void;
}
function TableOptionsModal({
isOpen,
columns,
canModifyColumns = true,
optionsComponent: OptionsComponent,
pageSize: propsPageSize,
maxPageSize = 250,
onTableOptionChange,
onModalClose,
}: TableOptionsModalProps) {
const [pageSize, setPageSize] = useState(propsPageSize);
const [pageSizeError, setPageSizeError] = useState<string | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const hasPageSize = !!propsPageSize;
const isDragging = dropIndex !== null;
const isDraggingUp =
isDragging &&
dropIndex != null &&
dragIndex != null &&
dropIndex < dragIndex;
const isDraggingDown =
isDragging &&
dropIndex != null &&
dragIndex != null &&
dropIndex > dragIndex;
const handlePageSizeChange = useCallback(
({ value }: InputChanged<number>) => {
let error: string | null = null;
if (value < 5) {
error = translate('TablePageSizeMinimum', {
minimumValue: '5',
});
} else if (value > maxPageSize) {
error = translate('TablePageSizeMaximum', {
maximumValue: `${maxPageSize}`,
});
} else {
onTableOptionChange({ pageSize: value });
}
setPageSize(value);
setPageSizeError(error);
},
[maxPageSize, onTableOptionChange]
);
const handleVisibleChange = useCallback(
({ name, value }: CheckInputChanged) => {
const newColumns = columns.map((column) => {
if (column.name === name) {
return {
...column,
isVisible: value,
};
}
return column;
});
onTableOptionChange({ columns: newColumns });
},
[columns, onTableOptionChange]
);
const handleColumnDragMove = useCallback(
(newDragIndex: number, newDropIndex: number) => {
setDropIndex(newDropIndex);
setDragIndex(newDragIndex);
},
[]
);
const handleColumnDragEnd = useCallback(
(didDrop: boolean) => {
if (didDrop && dragIndex && dropIndex !== null) {
const newColumns = [...columns];
const items = newColumns.splice(dragIndex, 1);
newColumns.splice(dropIndex, 0, items[0]);
onTableOptionChange({ columns: newColumns });
}
setDragIndex(null);
setDropIndex(null);
},
[dragIndex, dropIndex, columns, onTableOptionChange]
);
useEffect(() => {
setPageSize(propsPageSize);
}, [propsPageSize]);
return (
<DndProvider options={HTML5toTouch}>
<Modal isOpen={isOpen} onModalClose={onModalClose}>
{isOpen ? (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('TableOptions')}</ModalHeader>
<ModalBody>
<Form>
{hasPageSize ? (
<FormGroup>
<FormLabel>{translate('TablePageSize')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="pageSize"
value={pageSize || 0}
helpText={translate('TablePageSizeHelpText')}
errors={
pageSizeError ? [{ message: pageSizeError }] : undefined
}
onChange={handlePageSizeChange}
/>
</FormGroup>
) : null}
{OptionsComponent ? (
<OptionsComponent onTableOptionChange={onTableOptionChange} />
) : null}
{canModifyColumns ? (
<FormGroup>
<FormLabel>{translate('TableColumns')}</FormLabel>
<div>
<FormInputHelpText
text={translate('TableColumnsHelpText')}
/>
<div className={styles.columns}>
{columns.map((column, index) => {
const {
name,
label,
columnLabel,
isVisible,
isModifiable = true,
} = column;
return (
<TableOptionsColumn
key={name}
name={name}
label={columnLabel ?? label}
isVisible={isVisible}
isModifiable={isModifiable}
index={index}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
onVisibleChange={handleVisibleChange}
onColumnDragMove={handleColumnDragMove}
onColumnDragEnd={handleColumnDragEnd}
/>
);
})}
</div>
</div>
</FormGroup>
) : null}
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
) : null}
</Modal>
</DndProvider>
);
}
TableOptionsModal.defaultProps = {
canModifyColumns: true,
};
export default TableOptionsModal;

View file

@ -11,7 +11,7 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileName';
import {
deleteEpisodeFile,
fetchEpisodeFile,

View file

@ -0,0 +1,7 @@
enum DragType {
DelayProfile = 'delayProfile',
QualityProfileItem = 'qualityProfileItem',
TableColumn = 'tableColumn',
}
export default DragType;

View file

@ -1,3 +0,0 @@
export const QUALITY_PROFILE_ITEM = 'qualityProfileItem';
export const DELAY_PROFILE = 'delayProfile';
export const TABLE_COLUMN = 'tableColumn';

View file

@ -20,6 +20,9 @@ interface Quality {
name: string;
resolution: number;
source: QualitySource;
minSize: number | null;
maxSize: number | null;
preferredSize: number | null;
}
export interface QualityModel {

View file

@ -30,7 +30,7 @@ import MonitoringOptionsModal from 'Series/MonitoringOptions/MonitoringOptionsMo
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileName';
import fonts from 'Styles/Variables/fonts';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';

View file

@ -38,3 +38,17 @@
width: $dragHandleWidth;
text-align: center;
}
.placeholder {
width: 100%;
height: 30px;
border-bottom: 1px dotted #aaa;
}
.placeholderBefore {
margin-bottom: 8px;
}
.placeholderAfter {
margin-top: 8px;
}

View file

@ -8,6 +8,9 @@ interface CssExports {
'dragIcon': string;
'editButton': string;
'isDragging': string;
'placeholder': string;
'placeholderAfter': string;
'placeholderBefore': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,173 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { icons, kinds } from 'Helpers/Props';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
import styles from './DelayProfile.css';
function getDelay(enabled, delay) {
if (!enabled) {
return '-';
}
if (!delay) {
return translate('NoDelay');
}
if (delay === 1) {
return translate('OneMinute');
}
// TODO: use better units of time than just minutes
return translate('DelayMinutes', { delay });
}
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(translate('PreferProtocol', { preferredProtocol }));
if (!enableUsenet) {
preferred = translate('OnlyTorrent');
} else if (!enableTorrent) {
preferred = translate('OnlyUsenet');
}
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={translate('DeleteDelayProfile')}
message={translate('DeleteDelayProfileMessageText')}
confirmLabel={translate('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;

View file

@ -0,0 +1,240 @@
import classNames from 'classnames';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
import { useDispatch } from 'react-redux';
import { Tag } from 'App/State/TagsAppState';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import DragType from 'Helpers/DragType';
import { icons, kinds } from 'Helpers/Props';
import { deleteDelayProfile } from 'Store/Actions/settingsActions';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import EditDelayProfileModal from './EditDelayProfileModal';
import styles from './DelayProfile.css';
function getDelay(enabled: boolean, delay: number) {
if (!enabled) {
return '-';
}
if (!delay) {
return translate('NoDelay');
}
if (delay === 1) {
return translate('OneMinute');
}
// TODO: use better units of time than just minutes
return translate('DelayMinutes', { delay });
}
interface DragItem {
id: number;
order: number;
}
interface DelayProfileProps {
id: number;
enableUsenet: boolean;
enableTorrent: boolean;
preferredProtocol: string;
usenetDelay: number;
torrentDelay: number;
order: number;
tags: number[];
tagList: Tag[];
isDraggingDown: boolean;
isDraggingUp: boolean;
onDelayProfileDragEnd: (id: number, didDrop: boolean) => void;
onDelayProfileDragMove: (dragIndex: number, hoverIndex: number) => void;
}
function DelayProfile({
id,
enableUsenet,
enableTorrent,
preferredProtocol,
usenetDelay,
torrentDelay,
order,
tags,
tagList,
isDraggingDown,
isDraggingUp,
onDelayProfileDragEnd,
onDelayProfileDragMove,
}: DelayProfileProps) {
const dispatch = useDispatch();
const ref = useRef<HTMLDivElement>(null);
const [isEditDelayProfileModalOpen, setIsEditDelayProfileModalOpen] =
useState(false);
const [isDeleteDelayProfileModalOpen, setIsDeleteDelayProfileModalOpen] =
useState(false);
const preferred = useMemo(() => {
if (!enableUsenet) {
return translate('OnlyTorrent');
} else if (!enableTorrent) {
return translate('OnlyUsenet');
}
return titleCase(translate('PreferProtocol', { preferredProtocol }));
}, [preferredProtocol, enableUsenet, enableTorrent]);
const handleEditDelayProfilePress = useCallback(() => {
setIsEditDelayProfileModalOpen(true);
}, []);
const handleEditDelayProfileModalClose = useCallback(() => {
setIsEditDelayProfileModalOpen(false);
}, []);
const handleDeleteDelayProfilePress = useCallback(() => {
setIsEditDelayProfileModalOpen(false);
setIsDeleteDelayProfileModalOpen(true);
}, []);
const handleDeleteDelayProfileModalClose = useCallback(() => {
setIsDeleteDelayProfileModalOpen(false);
}, []);
const handleConfirmDeleteDelayProfile = useCallback(() => {
dispatch(deleteDelayProfile(id));
}, [id, dispatch]);
const [{ isOver }, dropRef] = useDrop<DragItem, void, { isOver: boolean }>({
accept: DragType.DelayProfile,
collect(monitor) {
return {
isOver: monitor.isOver(),
};
},
hover(item: DragItem, monitor) {
if (!ref.current) {
return;
}
const dragIndex = item.order;
const hoverIndex = order;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
// 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) {
onDelayProfileDragMove(dragIndex, hoverIndex + 1);
} else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
onDelayProfileDragMove(dragIndex, hoverIndex);
}
},
});
const [{ isDragging }, dragRef, previewRef] = useDrag<
DragItem,
unknown,
{ isDragging: boolean }
>({
type: DragType.DelayProfile,
item: () => {
return {
id,
order,
};
},
collect: (monitor: DragSourceMonitor<unknown, unknown>) => ({
isDragging: monitor.isDragging(),
}),
end: (item: DragItem, monitor) => {
onDelayProfileDragEnd(item.id, monitor.didDrop());
},
});
dropRef(previewRef(ref));
const isBefore = !isDragging && isDraggingUp && isOver;
const isAfter = !isDragging && isDraggingDown && isOver;
return (
<div ref={id === 1 ? undefined : ref}>
{isBefore ? (
<div
className={classNames(styles.placeholder, styles.placeholderBefore)}
/>
) : null}
<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={handleEditDelayProfilePress}
>
<Icon name={icons.EDIT} />
</Link>
{id === 1 ? null : (
<div ref={dragRef} className={styles.dragHandle}>
<Icon className={styles.dragIcon} name={icons.REORDER} />
</div>
)}
</div>
</div>
{isAfter ? (
<div
className={classNames(styles.placeholder, styles.placeholderAfter)}
/>
) : null}
<EditDelayProfileModal
id={id}
isOpen={isEditDelayProfileModalOpen}
onModalClose={handleEditDelayProfileModalClose}
onDeleteDelayProfilePress={handleDeleteDelayProfilePress}
/>
<ConfirmModal
isOpen={isDeleteDelayProfileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteDelayProfile')}
message={translate('DeleteDelayProfileMessageText')}
confirmLabel={translate('Delete')}
onConfirm={handleConfirmDeleteDelayProfile}
onCancel={handleDeleteDelayProfileModalClose}
/>
</div>
);
}
export default DelayProfile;

View file

@ -1,3 +0,0 @@
.dragPreview {
opacity: 0.75;
}

View file

@ -1,7 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'dragPreview': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,78 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragLayer } from 'react-dnd';
import DragPreviewLayer from 'Components/DragPreviewLayer';
import { DELAY_PROFILE } from 'Helpers/dragTypes';
import dimensions from 'Styles/Variables/dimensions.js';
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);

View file

@ -1,17 +0,0 @@
.delayProfileDragSource {
padding: 4px 0;
}
.delayProfilePlaceholder {
width: 100%;
height: 30px;
border-bottom: 1px dotted #aaa;
}
.delayProfilePlaceholderBefore {
margin-bottom: 8px;
}
.delayProfilePlaceholderAfter {
margin-top: 8px;
}

View file

@ -1,10 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'delayProfileDragSource': string;
'delayProfilePlaceholder': string;
'delayProfilePlaceholderAfter': string;
'delayProfilePlaceholderBefore': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,148 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragSource, DropTarget } from 'react-dnd';
import { findDOMNode } from 'react-dom';
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));

View file

@ -1,169 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
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 Scroller from 'Components/Scroller/Scroller';
import { icons, scrollDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import DelayProfile from './DelayProfile';
import DelayProfileDragPreview from './DelayProfileDragPreview';
import DelayProfileDragSource from './DelayProfileDragSource';
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={translate('DelayProfiles')}>
<PageSectionContent
errorMessage={translate('DelayProfilesLoadError')}
{...otherProps}
>
<Scroller
className={styles.horizontalScroll}
scrollDirection={
scrollDirections.HORIZONTAL
}
autoFocus={false}
>
<div>
<div className={styles.delayProfilesHeader}>
<div className={styles.column}>
{translate('PreferredProtocol')}
</div>
<div className={styles.column}>
{translate('UsenetDelay')}
</div>
<div className={styles.column}>
{translate('TorrentDelay')}
</div>
<div className={styles.tags}>
{translate('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> :
null
}
</div>
</Scroller>
<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;

View file

@ -0,0 +1,186 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import PageSectionContent from 'Components/Page/PageSectionContent';
import Scroller from 'Components/Scroller/Scroller';
import { icons, scrollDirections } from 'Helpers/Props';
import {
fetchDelayProfiles,
reorderDelayProfile,
} from 'Store/Actions/settingsActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import DelayProfileModel from 'typings/DelayProfile';
import translate from 'Utilities/String/translate';
import DelayProfile from './DelayProfile';
import EditDelayProfileModal from './EditDelayProfileModal';
import styles from './DelayProfiles.css';
function createDisplayProfilesSelector() {
return createSelector(
(state: AppState) => state.settings.delayProfiles,
(delayProfiles) => {
const { defaultProfile, items } = delayProfiles.items.reduce<{
defaultProfile: null | DelayProfileModel;
items: DelayProfileModel[];
}>(
(acc, item) => {
if (item.id === 1) {
acc.defaultProfile = item;
} else {
acc.items.push(item);
}
return acc;
},
{
defaultProfile: null,
items: [],
}
);
items.sort((a, b) => a.order - b.order);
return {
defaultProfile,
...delayProfiles,
items,
};
}
);
}
function DelayProfiles() {
const dispatch = useDispatch();
const { error, isFetching, isPopulated, items, defaultProfile } = useSelector(
createDisplayProfilesSelector()
);
const tagList = useSelector(createTagsSelector());
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [isAddDelayProfileModalOpen, setIsAddDelayProfileModalOpen] =
useState(false);
const isDragging = dropIndex !== null;
const isDraggingUp =
isDragging &&
dropIndex != null &&
dragIndex != null &&
dropIndex < dragIndex;
const isDraggingDown =
isDragging &&
dropIndex != null &&
dragIndex != null &&
dropIndex > dragIndex;
const handleAddDelayProfilePress = useCallback(() => {
setIsAddDelayProfileModalOpen(true);
}, []);
const handleAddDelayProfileModalClose = useCallback(() => {
setIsAddDelayProfileModalOpen(false);
}, []);
const handleDelayProfileDragMove = useCallback(
(newDragIndex: number, newDropIndex: number) => {
setDragIndex(newDragIndex);
setDropIndex(newDropIndex);
},
[]
);
const handleDelayProfileDragEnd = useCallback(
(id: number, didDrop: boolean) => {
if (didDrop && dropIndex !== null) {
dispatch(reorderDelayProfile({ id, moveIndex: dropIndex - 1 }));
}
setDragIndex(null);
setDropIndex(null);
},
[dropIndex, dispatch]
);
useEffect(() => {
dispatch(fetchDelayProfiles());
}, [dispatch]);
return (
<FieldSet legend={translate('DelayProfiles')}>
<PageSectionContent
errorMessage={translate('DelayProfilesLoadError')}
error={error}
isFetching={isFetching}
isPopulated={isPopulated}
>
<Scroller
className={styles.horizontalScroll}
scrollDirection={scrollDirections.HORIZONTAL}
autoFocus={false}
>
<div>
<div className={styles.delayProfilesHeader}>
<div className={styles.column}>
{translate('PreferredProtocol')}
</div>
<div className={styles.column}>{translate('UsenetDelay')}</div>
<div className={styles.column}>{translate('TorrentDelay')}</div>
<div className={styles.tags}>{translate('Tags')}</div>
</div>
<div className={styles.delayProfiles}>
{items.map((item) => {
return (
<DelayProfile
key={item.id}
{...item}
tagList={tagList}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
onDelayProfileDragEnd={handleDelayProfileDragEnd}
onDelayProfileDragMove={handleDelayProfileDragMove}
/>
);
})}
</div>
{defaultProfile ? (
<div>
<DelayProfile
{...defaultProfile}
tagList={tagList}
isDraggingDown={false}
isDraggingUp={false}
onDelayProfileDragEnd={handleDelayProfileDragEnd}
onDelayProfileDragMove={handleDelayProfileDragMove}
/>
</div>
) : null}
</div>
</Scroller>
<div className={styles.addDelayProfile}>
<Link
className={styles.addButton}
onPress={handleAddDelayProfilePress}
>
<Icon name={icons.ADD} />
</Link>
</div>
<EditDelayProfileModal
isOpen={isAddDelayProfileModalOpen}
onModalClose={handleAddDelayProfileModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
export default DelayProfiles;

View file

@ -1,105 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteDelayProfile, fetchDelayProfiles, 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);

View file

@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
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;

View file

@ -0,0 +1,37 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditDelayProfileModalContent, {
EditDelayProfileModalContentProps,
} from './EditDelayProfileModalContent';
interface EditDelayProfileModalProps extends EditDelayProfileModalContentProps {
isOpen: boolean;
onModalClose: () => void;
}
function EditDelayProfileModal({
isOpen,
onModalClose,
...otherProps
}: EditDelayProfileModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'settings.delayProfiles' }));
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
<EditDelayProfileModalContent
{...otherProps}
onModalClose={handleModalClose}
/>
</Modal>
);
}
export default EditDelayProfileModal;

View file

@ -1,43 +0,0 @@
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);

View file

@ -1,266 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape';
import translate from 'Utilities/String/translate';
import styles from './EditDelayProfileModalContent.css';
const protocolOptions = [
{
key: 'preferUsenet',
get value() {
return translate('PreferUsenet');
}
},
{
key: 'preferTorrent',
get value() {
return translate('PreferTorrent');
}
},
{
key: 'onlyUsenet',
get value() {
return translate('OnlyUsenet');
}
},
{
key: 'onlyTorrent',
get value() {
return translate('OnlyTorrent');
}
}
];
function EditDelayProfileModalContent(props) {
const {
id,
isFetching,
error,
isSaving,
saveError,
item,
protocol,
onInputChange,
onProtocolChange,
onSavePress,
onModalClose,
onDeleteDelayProfilePress,
...otherProps
} = props;
const {
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay,
bypassIfHighestQuality,
bypassIfAboveCustomFormatScore,
minimumCustomFormatScore,
tags
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? translate('EditDelayProfile') : translate('AddDelayProfile')}
</ModalHeader>
<ModalBody>
{
isFetching ?
<LoadingIndicator /> :
null
}
{
!isFetching && !!error ?
<Alert kind={kinds.DANGER}>
{translate('AddDelayProfileError')}
</Alert> :
null
}
{
!isFetching && !error ?
<Form {...otherProps}>
<FormGroup>
<FormLabel>{translate('PreferredProtocol')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="protocol"
value={protocol}
values={protocolOptions}
helpText={translate('ProtocolHelpText')}
onChange={onProtocolChange}
/>
</FormGroup>
{
enableUsenet.value ?
<FormGroup>
<FormLabel>{translate('UsenetDelay')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="usenetDelay"
unit="minutes"
{...usenetDelay}
helpText={translate('UsenetDelayHelpText')}
onChange={onInputChange}
/>
</FormGroup> :
null
}
{
enableTorrent.value ?
<FormGroup>
<FormLabel>{translate('TorrentDelay')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="torrentDelay"
unit="minutes"
{...torrentDelay}
helpText={translate('TorrentDelayHelpText')}
onChange={onInputChange}
/>
</FormGroup> :
null
}
<FormGroup>
<FormLabel>{translate('BypassDelayIfHighestQuality')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="bypassIfHighestQuality"
{...bypassIfHighestQuality}
helpText={translate('BypassDelayIfHighestQualityHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('BypassDelayIfAboveCustomFormatScore')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="bypassIfAboveCustomFormatScore"
{...bypassIfAboveCustomFormatScore}
helpText={translate('BypassDelayIfAboveCustomFormatScoreHelpText')}
onChange={onInputChange}
/>
</FormGroup>
{
bypassIfAboveCustomFormatScore.value ?
<FormGroup>
<FormLabel>{translate('BypassDelayIfAboveCustomFormatScoreMinimumScore')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minimumCustomFormatScore"
{...minimumCustomFormatScore}
helpText={translate('BypassDelayIfAboveCustomFormatScoreMinimumScoreHelpText')}
onChange={onInputChange}
/>
</FormGroup> :
null
}
{
id === 1 ?
<Alert>
{translate('DefaultDelayProfileSeries')}
</Alert> :
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
{...tags}
helpText={translate('DelayProfileSeriesTagsHelpText')}
onChange={onInputChange}
/>
</FormGroup>
}
</Form> :
null
}
</ModalBody>
<ModalFooter>
{
id && id > 1 ?
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteDelayProfilePress}
>
{translate('Delete')}
</Button> :
null
}
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('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,
bypassIfHighestQuality: PropTypes.shape(boolSettingShape).isRequired,
bypassIfAboveCustomFormatScore: PropTypes.shape(boolSettingShape).isRequired,
minimumCustomFormatScore: 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,
onInputChange: PropTypes.func.isRequired,
onProtocolChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteDelayProfilePress: PropTypes.func
};
export default EditDelayProfileModalContent;

View file

@ -0,0 +1,370 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds } from 'Helpers/Props';
import {
saveDelayProfile,
setDelayProfileValue,
} from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './EditDelayProfileModalContent.css';
const newDelayProfile: Record<string, boolean | number | number[] | string> = {
enableUsenet: true,
enableTorrent: true,
preferredProtocol: 'usenet',
usenetDelay: 0,
torrentDelay: 0,
bypassIfHighestQuality: false,
bypassIfAboveCustomFormatScore: false,
minimumCustomFormatScore: 0,
tags: [],
};
const protocolOptions = [
{
key: 'preferUsenet',
get value() {
return translate('PreferUsenet');
},
},
{
key: 'preferTorrent',
get value() {
return translate('PreferTorrent');
},
},
{
key: 'onlyUsenet',
get value() {
return translate('OnlyUsenet');
},
},
{
key: 'onlyTorrent',
get value() {
return translate('OnlyTorrent');
},
},
];
function createDelayProfileSelector(id: number | undefined) {
return createSelector(
(state: AppState) => state.settings.delayProfiles,
(delayProfiles) => {
const { isFetching, error, isSaving, saveError, pendingChanges, items } =
delayProfiles;
const profile = id ? items.find((i) => i.id === id) : newDelayProfile;
const settings = selectSettings(profile!, pendingChanges, saveError);
return {
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings,
};
}
);
}
export interface EditDelayProfileModalContentProps {
id?: number;
onDeleteDelayProfilePress?: () => void;
onModalClose: () => void;
}
function EditDelayProfileModalContent({
id,
onModalClose,
onDeleteDelayProfilePress,
...otherProps
}: EditDelayProfileModalContentProps) {
const dispatch = useDispatch();
const { item, isFetching, error, isSaving, saveError } = useSelector(
createDelayProfileSelector(id)
);
const {
enableUsenet,
enableTorrent,
preferredProtocol,
usenetDelay,
torrentDelay,
bypassIfHighestQuality,
bypassIfAboveCustomFormatScore,
minimumCustomFormatScore,
tags,
} = item;
const protocol = useMemo(() => {
if (!enableUsenet.value) {
return 'onlyTorrent';
} else if (!enableTorrent.value) {
return 'onlyUsenet';
}
return preferredProtocol.value === 'usenet'
? 'preferUsenet'
: 'preferTorrent';
}, [enableUsenet, enableTorrent, preferredProtocol]);
const wasSaving = usePrevious(isSaving);
const onInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setDelayProfileValue({ name, value }));
},
[dispatch]
);
const onProtocolChange = useCallback(
({ value }: InputChanged) => {
let enableUsenet = false;
let enableTorrent = false;
let preferredProtocol: 'usenet' | 'torrent' = 'usenet';
switch (value) {
case 'preferUsenet':
enableUsenet = true;
enableTorrent = true;
preferredProtocol = 'usenet';
break;
case 'preferTorrent':
enableUsenet = true;
enableTorrent = true;
preferredProtocol = 'torrent';
break;
case 'onlyUsenet':
enableUsenet = true;
enableTorrent = false;
preferredProtocol = 'usenet';
break;
case 'onlyTorrent':
enableUsenet = false;
enableTorrent = true;
preferredProtocol = 'torrent';
break;
default:
throw Error(`Unknown protocol option: ${value}`);
}
dispatch(
// @ts-expect-error - actions are not typed
setDelayProfileValue({ name: 'enableUsenet', value: enableUsenet })
);
dispatch(
// @ts-expect-error - actions are not typed
setDelayProfileValue({
name: 'enableTorrent',
value: enableTorrent,
})
);
dispatch(
// @ts-expect-error - actions are not typed
setDelayProfileValue({
name: 'preferredProtocol',
value: preferredProtocol,
})
);
},
[dispatch]
);
const handleSavePress = useCallback(() => {
dispatch(saveDelayProfile({ id }));
}, [id, dispatch]);
useEffect(() => {
if (!id) {
Object.keys(newDelayProfile).forEach((name) => {
dispatch(
// @ts-expect-error - actions are not typed
setDelayProfileValue({
name,
value: newDelayProfile[name],
})
);
});
}
}, [id, dispatch]);
useEffect(() => {
if (wasSaving && !isSaving && !saveError) {
onModalClose();
}
}, [isSaving, wasSaving, saveError, onModalClose]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? translate('EditDelayProfile') : translate('AddDelayProfile')}
</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('AddDelayProfileError')}</Alert>
) : null}
{!isFetching && !error ? (
<Form {...otherProps}>
<FormGroup>
<FormLabel>{translate('PreferredProtocol')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="protocol"
value={protocol}
values={protocolOptions}
helpText={translate('ProtocolHelpText')}
onChange={onProtocolChange}
/>
</FormGroup>
{enableUsenet.value ? (
<FormGroup>
<FormLabel>{translate('UsenetDelay')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="usenetDelay"
unit="minutes"
{...usenetDelay}
helpText={translate('UsenetDelayHelpText')}
onChange={onInputChange}
/>
</FormGroup>
) : null}
{enableTorrent.value ? (
<FormGroup>
<FormLabel>{translate('TorrentDelay')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="torrentDelay"
unit="minutes"
{...torrentDelay}
helpText={translate('TorrentDelayHelpText')}
onChange={onInputChange}
/>
</FormGroup>
) : null}
<FormGroup>
<FormLabel>{translate('BypassDelayIfHighestQuality')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="bypassIfHighestQuality"
{...bypassIfHighestQuality}
helpText={translate('BypassDelayIfHighestQualityHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('BypassDelayIfAboveCustomFormatScore')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="bypassIfAboveCustomFormatScore"
{...bypassIfAboveCustomFormatScore}
helpText={translate(
'BypassDelayIfAboveCustomFormatScoreHelpText'
)}
onChange={onInputChange}
/>
</FormGroup>
{bypassIfAboveCustomFormatScore.value ? (
<FormGroup>
<FormLabel>
{translate('BypassDelayIfAboveCustomFormatScoreMinimumScore')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minimumCustomFormatScore"
{...minimumCustomFormatScore}
helpText={translate(
'BypassDelayIfAboveCustomFormatScoreMinimumScoreHelpText'
)}
onChange={onInputChange}
/>
</FormGroup>
) : null}
{id === 1 ? (
<Alert>{translate('DefaultDelayProfileSeries')}</Alert>
) : (
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
{...tags}
helpText={translate('DelayProfileSeriesTagsHelpText')}
onChange={onInputChange}
/>
</FormGroup>
)}
</Form>
) : null}
</ModalBody>
<ModalFooter>
{id && id > 1 ? (
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteDelayProfilePress}
>
{translate('Delete')}
</Button>
) : null}
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
export default EditDelayProfileModalContent;

View file

@ -1,172 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveDelayProfile, setDelayProfileValue } from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import EditDelayProfileModalContent from './EditDelayProfileModalContent';
const newDelayProfile = {
enableUsenet: true,
enableTorrent: true,
preferredProtocol: 'usenet',
usenetDelay: 0,
torrentDelay: 0,
bypassIfHighestQuality: false,
bypassIfAboveCustomFormatScore: false,
minimumCustomFormatScore: 0,
tags: []
};
function createDelayProfileSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.delayProfiles,
(id, delayProfiles) => {
const {
isFetching,
error,
isSaving,
saveError,
pendingChanges,
items
} = delayProfiles;
const profile = id ? items.find((i) => i.id === 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,
...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);

View file

@ -1,37 +0,0 @@
import React, { Component } from 'react';
import { DndProvider } from 'react-dnd-multi-backend';
import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbar from 'Settings/SettingsToolbar';
import translate from 'Utilities/String/translate';
import DelayProfilesConnector from './Delay/DelayProfilesConnector';
import QualityProfilesConnector from './Quality/QualityProfilesConnector';
import ReleaseProfiles from './Release/ReleaseProfiles';
// Only a single DragDrop Context can exist so it's done here to allow editing
// quality profiles and reordering delay profiles to work.
class Profiles extends Component {
//
// Render
render() {
return (
<PageContent title={translate('Profiles')}>
<SettingsToolbar showSave={false} />
<PageContentBody>
<DndProvider options={HTML5toTouch}>
<QualityProfilesConnector />
<DelayProfilesConnector />
<ReleaseProfiles />
</DndProvider>
</PageContentBody>
</PageContent>
);
}
}
export default Profiles;

View file

@ -0,0 +1,31 @@
import { HTML5toTouch } from 'rdndmb-html5-to-touch';
import React from 'react';
import { DndProvider } from 'react-dnd-multi-backend';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbar from 'Settings/SettingsToolbar';
import translate from 'Utilities/String/translate';
import DelayProfiles from './Delay/DelayProfiles';
import QualityProfiles from './Quality/QualityProfiles';
import ReleaseProfiles from './Release/ReleaseProfiles';
// Only a single DragDrop Context can exist so it's done here to allow editing
// quality profiles and reordering delay profiles to work.
function Profiles() {
return (
<PageContent title={translate('Profiles')}>
<SettingsToolbar showSave={false} />
<PageContentBody>
<DndProvider options={HTML5toTouch}>
<QualityProfiles />
<DelayProfiles />
<ReleaseProfiles />
</DndProvider>
</PageContentBody>
</PageContent>
);
}
export default Profiles;

View file

@ -1,61 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
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 !== 0) {
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;

View file

@ -0,0 +1,55 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditQualityProfileModalContent from './EditQualityProfileModalContent';
interface EditQualityProfileModalProps {
id?: number;
isOpen: boolean;
onDeleteQualityProfilePress?: () => void;
onModalClose: () => void;
}
function EditQualityProfileModal({
id,
isOpen,
onDeleteQualityProfilePress,
onModalClose,
}: EditQualityProfileModalProps) {
const dispatch = useDispatch();
const [height, setHeight] = useState<'auto' | number>('auto');
const handleOnModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'settings.qualityProfiles' }));
onModalClose();
}, [dispatch, onModalClose]);
const handleContentHeightChange = useCallback(
(newHeight: number) => {
if (height === 'auto' || newHeight !== 0) {
setHeight(newHeight);
}
},
[height]
);
return (
<Modal
style={{ height: height === 'auto' ? 'auto' : `${height}px` }}
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
onModalClose={handleOnModalClose}
>
<EditQualityProfileModalContent
id={id}
onContentHeightChange={handleContentHeightChange}
onDeleteQualityProfilePress={onDeleteQualityProfilePress}
onModalClose={handleOnModalClose}
/>
</Modal>
);
}
export default EditQualityProfileModal;

View file

@ -1,43 +0,0 @@
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);

View file

@ -1,363 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Measure from 'Components/Measure';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import translate from 'Utilities/String/translate';
import QualityProfileFormatItems from './QualityProfileFormatItems';
import QualityProfileItems from './QualityProfileItems';
import styles from './EditQualityProfileModalContent.css';
const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
function getCustomFormatRender(formatItems, otherProps) {
return (
<QualityProfileFormatItems
profileFormatItems={formatItems.value}
errors={formatItems.errors}
warnings={formatItems.warnings}
{...otherProps}
/>
);
}
class EditQualityProfileModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
headerHeight: 0,
bodyHeight: 0,
defaultBodyHeight: 0,
editGroupsBodyHeight: 0,
editSizesBodyHeight: 0,
footerHeight: 0
};
}
componentDidUpdate(prevProps, prevState) {
const {
headerHeight,
footerHeight
} = this.state;
const bodyHeight = this.state[`${this.props.mode}BodyHeight`];
if (
headerHeight > 0 &&
bodyHeight > 0 &&
footerHeight > 0 &&
(
headerHeight !== prevState.headerHeight ||
bodyHeight !== prevState[`${prevProps.mode}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 }) => {
const heightKey = `${this.props.mode}BodyHeight`;
if (height !== this.state[heightKey]) {
this.setState({ [heightKey]: height });
}
};
onFooterMeasure = ({ height }) => {
if (height > this.state.footerHeight) {
this.setState({ footerHeight: height });
}
};
//
// Render
render() {
const {
mode,
isFetching,
error,
isSaving,
saveError,
qualities,
customFormats,
item,
isInUse,
onInputChange,
onCutoffChange,
onSavePress,
onModalClose,
onDeleteQualityProfilePress,
...otherProps
} = this.props;
const {
id,
name,
upgradeAllowed,
cutoff,
minFormatScore,
minUpgradeFormatScore,
cutoffFormatScore,
items,
formatItems
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onHeaderMeasure}
>
<ModalHeader>
{id ? translate('EditQualityProfile') : translate('AddQualityProfile')}
</ModalHeader>
</Measure>
<ModalBody>
<Measure
whitelist={['height']}
onMeasure={this.onBodyMeasure}
>
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>
{translate('AddQualityProfileError')}
</Alert>
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<div className={styles.formGroupsContainer}>
<div className={styles.formGroupWrapper}>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('Name')}
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('UpgradesAllowed')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="upgradeAllowed"
{...upgradeAllowed}
helpText={translate('UpgradesAllowedHelpText')}
onChange={onInputChange}
/>
</FormGroup>
{
upgradeAllowed.value &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('UpgradeUntil')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
values={qualities}
helpText={translate('UpgradeUntilEpisodeHelpText')}
onChange={onCutoffChange}
/>
</FormGroup>
}
{
formatItems.value.length > 0 &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('MinimumCustomFormatScore')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minFormatScore"
{...minFormatScore}
helpText={translate('MinimumCustomFormatScoreHelpText')}
onChange={onInputChange}
/>
</FormGroup>
}
{
upgradeAllowed.value && formatItems.value.length > 0 &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('UpgradeUntilCustomFormatScore')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="cutoffFormatScore"
{...cutoffFormatScore}
helpText={translate('UpgradeUntilCustomFormatScoreEpisodeHelpText')}
onChange={onInputChange}
/>
</FormGroup>
}
{
upgradeAllowed.value && formatItems.value.length > 0 ?
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('MinimumCustomFormatScoreIncrement')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minUpgradeFormatScore"
min={1}
{...minUpgradeFormatScore}
helpText={translate('MinimumCustomFormatScoreIncrementHelpText')}
onChange={onInputChange}
/>
</FormGroup> :
null
}
<div className={styles.formatItemLarge}>
{getCustomFormatRender(formatItems, otherProps)}
</div>
</div>
<div className={styles.formGroupWrapper}>
<QualityProfileItems
mode={mode}
qualityProfileItems={items.value}
errors={items.errors}
warnings={items.warnings}
{...otherProps}
/>
</div>
<div className={styles.formatItemSmall}>
{getCustomFormatRender(formatItems, otherProps)}
</div>
</div>
</Form>
}
</div>
</Measure>
</ModalBody>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onFooterMeasure}
>
<ModalFooter>
{
id ?
<div
className={styles.deleteButtonContainer}
title={
isInUse ?
translate('QualityProfileInUseSeriesListCollection') :
undefined
}
>
<Button
kind={kinds.DANGER}
isDisabled={isInUse}
onPress={onDeleteQualityProfilePress}
>
{translate('Delete')}
</Button>
</div> :
null
}
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</Measure>
</ModalContent>
);
}
}
EditQualityProfileModalContent.propTypes = {
mode: PropTypes.string.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
customFormats: 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;

View file

@ -0,0 +1,763 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { QualityProfilesAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import useMeasure from 'Helpers/Hooks/useMeasure';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import {
fetchQualityProfileSchema,
saveQualityProfile,
setQualityProfileValue,
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import createQualityProfileInUseSelector from 'Store/Selectors/createQualityProfileInUseSelector';
import dimensions from 'Styles/Variables/dimensions';
import { InputChanged } from 'typings/inputs';
import QualityProfile, {
QualityProfileGroup,
QualityProfileQualityItem,
} from 'typings/QualityProfile';
import translate from 'Utilities/String/translate';
import QualityProfileFormatItems from './QualityProfileFormatItems';
import { DragMoveState } from './QualityProfileItemDragSource';
import QualityProfileItems, {
EditQualityProfileMode,
} from './QualityProfileItems';
import { SizeChanged } from './QualityProfileItemSize';
import styles from './EditQualityProfileModalContent.css';
const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
function parseIndex(index: string): [number | null, number] {
const split = index.split('.');
if (split.length === 1) {
return [null, parseInt(split[0]) - 1];
}
return [parseInt(split[0]) - 1, parseInt(split[1]) - 1];
}
interface EditQualityProfileModalContentProps {
id?: number;
onContentHeightChange: (height: number) => void;
onDeleteQualityProfilePress?: () => void;
onModalClose: () => void;
}
function EditQualityProfileModalContent({
id,
onContentHeightChange,
onDeleteQualityProfilePress,
onModalClose,
}: EditQualityProfileModalContentProps) {
const dispatch = useDispatch();
const { error, isFetching, isPopulated, isSaving, saveError, item } =
useSelector(
createProviderSettingsSelectorHook<
QualityProfile,
QualityProfilesAppState
>('qualityProfiles', id)
);
const isInUse = useSelector(createQualityProfileInUseSelector(id));
const [measureHeaderRef, { height: headerHeight }] = useMeasure();
const [measureBodyRef, { height: bodyHeight }] = useMeasure();
const [measureFooterRef, { height: footerHeight }] = useMeasure();
const [mode, setMode] = useState<EditQualityProfileMode>('default');
const [defaultBodyHeight, setDefaultBodyHeight] = useState(0);
const [editGroupsBodyHeight, setEditGroupsBodyHeight] = useState(0);
const [editSizesBodyHeight, setEditSizesBodyHeight] = useState(0);
const [dndState, setDndState] = useState<DragMoveState>({
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null,
});
const wasSaving = usePrevious(isSaving);
const { dragQualityIndex, dropQualityIndex, dropPosition } = dndState;
const {
name,
upgradeAllowed,
cutoff,
minFormatScore,
minUpgradeFormatScore,
cutoffFormatScore,
items,
formatItems,
} = item;
const qualities = useMemo(() => {
if (!items?.value) {
return [];
}
return items.value.reduceRight<{ key: number; value: string }[]>(
(acc, item) => {
if (item.allowed) {
if ('id' in item) {
acc.push({
key: item.id,
value: item.name,
});
} else {
acc.push({
key: item.quality.id,
value: item.quality.name,
});
}
}
return acc;
},
[]
);
}, [items]);
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setQualityProfileValue({ name, value }));
},
[dispatch]
);
const handleSavePress = useCallback(() => {
dispatch(saveQualityProfile({ id }));
}, [id, dispatch]);
const handleCutoffChange = useCallback(
({ name, value }: InputChanged<number>) => {
const cutoffItem = items.value.find((item) => {
return 'id' in item ? item.id === value : item.quality.id === value;
});
if (cutoffItem) {
const cutoffId =
'id' in cutoffItem ? cutoffItem.id : cutoffItem.quality.id;
// @ts-expect-error - actions are not typed
dispatch(setQualityProfileValue({ name, value: cutoffId }));
}
},
[items, dispatch]
);
const handleItemAllowedChange = useCallback(
(qualityId: number, allowed: boolean) => {
const newItems = items.value.map((item) => {
if ('quality' in item && item.quality.id === qualityId) {
return {
...item,
allowed,
};
}
return item;
});
dispatch(
// @ts-expect-error - actions are not typed
setQualityProfileValue({
name: 'items',
value: newItems,
})
);
},
[items, dispatch]
);
const handleGroupAllowedChange = useCallback(
(groupId: number, allowed: boolean) => {
const newItems = items.value.map((item) => {
if ('id' in item && item.id === groupId) {
return {
...item,
allowed,
};
}
return item;
});
dispatch(
// @ts-expect-error - actions are not typed
setQualityProfileValue({
name: 'items',
value: newItems,
})
);
},
[items, dispatch]
);
const handleGroupNameChange = useCallback(
(groupId: number, name: string) => {
const newItems = items.value.map((item) => {
if ('id' in item && item.id === groupId) {
return {
...item,
name,
};
}
return item;
});
// @ts-expect-error - actions are not typed
dispatch(setQualityProfileValue({ name: 'items', value: newItems }));
},
[items, dispatch]
);
const handleSizeChange = useCallback(
(sizeChange: SizeChanged) => {
const { qualityId, ...sizes } = sizeChange;
const newItems = items.value.map((item) => {
if ('quality' in item && item.quality.id === qualityId) {
return {
...item,
...sizes,
};
}
return {
...item,
items: (item as QualityProfileGroup).items.map((subItem) => {
if (subItem.quality.id === qualityId) {
return {
...subItem,
...sizes,
};
}
return subItem;
}),
};
});
dispatch(
// @ts-expect-error - actions are not typed
setQualityProfileValue({
name: 'items',
value: newItems,
})
);
},
[items, dispatch]
);
const handleCreateGroupPress = useCallback(
(qualityId: number) => {
const groupId =
items.value.reduce((acc, item) => {
if ('id' in item && item.id > acc) {
acc = item.id;
}
return acc;
}, 1000) + 1;
const newItems = items.value.map((item) => {
if ('quality' in item && item.quality.id === qualityId) {
return {
id: groupId,
name: item.quality.name,
allowed: item.allowed,
items: [item],
};
}
return item;
});
// @ts-expect-error - actions are not typed
dispatch(setQualityProfileValue({ name: 'items', newItems }));
},
[items, dispatch]
);
const handleDeleteGroupPress = useCallback(
(groupId: number) => {
const newItems = items.value.reduce<QualityProfileQualityItem[]>(
(acc, item) => {
if ('id' in item && item.id === groupId) {
acc.push(...item.items);
} else {
acc.push(item as QualityProfileQualityItem);
}
return acc;
},
[]
);
// @ts-expect-error - actions are not typed
dispatch(setQualityProfileValue({ name: 'items', value: newItems }));
},
[items, dispatch]
);
const handleDragMove = useCallback((options: DragMoveState) => {
const { dragQualityIndex, dropQualityIndex, dropPosition } = options;
if (!dragQualityIndex || !dropQualityIndex || !dropPosition) {
setDndState({
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null,
});
return;
}
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
if (
(dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
(dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
) {
setDndState({
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}`;
}
setDndState({
dragQualityIndex,
dropQualityIndex: adjustedDropQualityIndex,
dropPosition,
});
}, []);
const handleDragEnd = useCallback(
(didDrop: boolean) => {
if (didDrop && dragQualityIndex != null && dropQualityIndex != null) {
const newItems = items.value.map((i) => {
if ('id' in i) {
return {
...i,
items: [...i.items],
} as QualityProfileGroup;
}
return {
...i,
} as QualityProfileQualityItem;
});
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
let item: QualityProfileQualityItem | null = null;
let dropGroup: QualityProfileGroup | null = null;
// Get the group before moving anything so we know the correct place to drop it.
if (dropGroupIndex != null) {
dropGroup = newItems[dropGroupIndex] as QualityProfileGroup;
}
if (dragGroupIndex == null) {
item = newItems.splice(
dragItemIndex,
1
)[0] as QualityProfileQualityItem;
} else {
const group = newItems[dragGroupIndex] as QualityProfileGroup;
item = group.items.splice(dragItemIndex, 1)[0];
// If the group is now empty, destroy it.
if (!group.items.length) {
newItems.splice(dragGroupIndex, 1);
}
}
if (dropGroup == null) {
newItems.splice(dropItemIndex, 0, item);
} else {
dropGroup.items.splice(dropItemIndex, 0, item);
}
dispatch(
// @ts-expect-error - actions are not typed
setQualityProfileValue({
name: 'items',
value: newItems,
})
);
}
setDndState({
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null,
});
},
[dragQualityIndex, dropQualityIndex, items, dispatch]
);
const handleChangeMode = useCallback((newMode: EditQualityProfileMode) => {
setMode(newMode);
}, []);
const handleFormatItemScoreChange = useCallback(
(formatId: number, score: number) => {
const newFormatItems = formatItems.value.map((formatItem) => {
if (formatItem.format === formatId) {
return {
...formatItem,
score,
};
}
return formatItem;
});
dispatch(
// @ts-expect-error - actions are not typed
setQualityProfileValue({
name: 'formatItems',
value: newFormatItems,
})
);
},
[formatItems, dispatch]
);
useEffect(() => {
let bodyHeight = 0;
if (mode === 'default') {
bodyHeight = defaultBodyHeight;
} else if (mode === 'editGroups') {
bodyHeight = editGroupsBodyHeight;
} else if (mode === 'editSizes') {
bodyHeight = editSizesBodyHeight;
}
const padding = MODAL_BODY_PADDING * 2;
onContentHeightChange(headerHeight + bodyHeight + footerHeight + padding);
}, [
headerHeight,
defaultBodyHeight,
editGroupsBodyHeight,
editSizesBodyHeight,
footerHeight,
mode,
onContentHeightChange,
]);
useEffect(() => {
if (mode === 'default') {
setDefaultBodyHeight(bodyHeight);
} else if (mode === 'editGroups') {
setEditGroupsBodyHeight(bodyHeight);
} else if (mode === 'editSizes') {
setEditSizesBodyHeight(bodyHeight);
}
}, [bodyHeight, mode]);
useEffect(() => {
if (!id && !isPopulated) {
dispatch(fetchQualityProfileSchema());
}
}, [id, isPopulated, dispatch]);
useEffect(() => {
if (wasSaving && !isSaving && !saveError) {
onModalClose();
}
}, [isSaving, wasSaving, saveError, onModalClose]);
useEffect(() => {
if (!items?.value) {
return;
}
const cutoffItem = items.value.find((item) =>
'id' in item ? item.id === cutoff.value : item.quality.id === cutoff.value
);
// If the cutoff isn't allowed anymore or there isn't a cutoff set one
if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
const firstAllowed = items.value.find((item) => item.allowed);
let cutoffId = null;
if (firstAllowed) {
cutoffId =
'id' in firstAllowed ? firstAllowed.id : firstAllowed.quality.id;
// @ts-expect-error - actions are not typed
dispatch(setQualityProfileValue({ name: 'cutoff', value: cutoffId }));
}
}
}, [cutoff, items, dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader ref={measureHeaderRef}>
{id ? translate('EditQualityProfile') : translate('AddQualityProfile')}
</ModalHeader>
<ModalBody>
<div ref={measureBodyRef}>
{isPopulated ? null : <LoadingIndicator />}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>
{translate('AddQualityProfileError')}
</Alert>
) : null}
{isPopulated && !error ? (
<Form>
<div className={styles.formGroupsContainer}>
<div className={styles.formGroupWrapper}>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('Name')}
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('UpgradesAllowed')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="upgradeAllowed"
{...upgradeAllowed}
helpText={translate('UpgradesAllowedHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
{upgradeAllowed.value ? (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('UpgradeUntil')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
values={qualities}
helpText={translate('UpgradeUntilEpisodeHelpText')}
onChange={handleCutoffChange}
/>
</FormGroup>
) : null}
{formatItems.value.length > 0 ? (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('MinimumCustomFormatScore')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minFormatScore"
{...minFormatScore}
helpText={translate('MinimumCustomFormatScoreHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
) : null}
{upgradeAllowed.value && formatItems.value.length > 0 ? (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('UpgradeUntilCustomFormatScore')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="cutoffFormatScore"
{...cutoffFormatScore}
helpText={translate(
'UpgradeUntilCustomFormatScoreEpisodeHelpText'
)}
onChange={handleInputChange}
/>
</FormGroup>
) : null}
{upgradeAllowed.value && formatItems.value.length > 0 ? (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('MinimumCustomFormatScoreIncrement')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minUpgradeFormatScore"
min={1}
{...minUpgradeFormatScore}
helpText={translate(
'MinimumCustomFormatScoreIncrementHelpText'
)}
onChange={handleInputChange}
/>
</FormGroup>
) : null}
<div className={styles.formatItemLarge}>
<QualityProfileFormatItems
profileFormatItems={formatItems.value}
errors={formatItems.errors}
warnings={formatItems.warnings}
onQualityProfileFormatItemScoreChange={
handleFormatItemScoreChange
}
/>
</div>
</div>
<div className={styles.formGroupWrapper}>
<QualityProfileItems
mode={mode}
qualityProfileItems={items.value}
errors={items.errors}
warnings={items.warnings}
dragQualityIndex={dragQualityIndex}
dropQualityIndex={dropQualityIndex}
dropPosition={dropPosition}
onChangeMode={handleChangeMode}
onCreateGroupPress={handleCreateGroupPress}
onDeleteGroupPress={handleDeleteGroupPress}
onItemAllowedChange={handleItemAllowedChange}
onGroupAllowedChange={handleGroupAllowedChange}
onItemGroupNameChange={handleGroupNameChange}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
onSizeChange={handleSizeChange}
/>
</div>
<div className={styles.formatItemSmall}>
<QualityProfileFormatItems
profileFormatItems={formatItems.value}
errors={formatItems.errors}
warnings={formatItems.warnings}
onQualityProfileFormatItemScoreChange={
handleFormatItemScoreChange
}
/>
</div>
</div>
</Form>
) : null}
</div>
</ModalBody>
<ModalFooter ref={measureFooterRef}>
{id ? (
<div
className={styles.deleteButtonContainer}
title={
isInUse
? translate('QualityProfileInUseSeriesListCollection')
: undefined
}
>
<Button
kind={kinds.DANGER}
isDisabled={isInUse}
onPress={onDeleteQualityProfilePress}
>
{translate('Delete')}
</Button>
</div>
) : null}
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
export default EditQualityProfileModalContent;

View file

@ -1,532 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchQualityProfileSchema, saveQualityProfile, setQualityProfileValue } from 'Store/Actions/settingsActions';
import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
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 createFormatsSelector() {
return createSelector(
createProviderSettingsSelector('qualityProfiles'),
(customFormat) => {
const items = customFormat.item.formatItems;
if (!items || !items.value) {
return [];
}
return _.reduceRight(items.value, (result, { id, name, format, score }) => {
if (id) {
result.push({
key: id,
value: name,
score
});
} else {
result.push({
key: format,
value: name,
score
});
}
return result;
}, []);
}
);
}
function createMapStateToProps() {
return createSelector(
createProviderSettingsSelector('qualityProfiles'),
createQualitiesSelector(),
createFormatsSelector(),
createProfileInUseSelector('qualityProfileId'),
(qualityProfile, qualities, customFormats, isInUse) => {
return {
qualities,
customFormats,
...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,
mode: 'default' // default, editGroups, editSizes
};
}
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 || (i.quality && i.quality.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.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);
};
onQualityProfileFormatItemScoreChange = (id, score) => {
const qualityProfile = _.cloneDeep(this.props.item);
const formatItems = qualityProfile.formatItems.value;
const item = _.find(qualityProfile.formatItems.value, (i) => i.format === id);
item.score = score;
this.props.setQualityProfileValue({
name: 'formatItems',
value: formatItems
});
};
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
});
};
onSizeChange = ({ id, minSize, maxSize, preferredSize }) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
let quality = null;
// eslint-disable-next-line guard-for-in
for (const index in items) {
const item = items[index];
if (item.quality?.id === id) {
quality = item;
break;
}
// eslint-disable-next-line guard-for-in
for (const i in item.items) {
const nestedItem = items[i];
if (nestedItem.quality?.id === id) {
quality = nestedItem;
break;
}
}
if (quality) {
break;
}
}
if (!quality) {
return;
}
quality.minSize = minSize;
quality.maxSize = maxSize;
quality.preferredSize = preferredSize;
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
});
};
onChangeMode = (mode) => {
this.setState({ mode });
};
//
// 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}
onQualityProfileFormatItemScoreChange={this.onQualityProfileFormatItemScoreChange}
onChangeMode={this.onChangeMode}
onSizeChange={this.onSizeChange}
/>
);
}
}
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);

View file

@ -1,187 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
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 { icons, kinds, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
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,
upgradeAllowed,
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={translate('CloneProfile')}
name={icons.CLONE}
onPress={this.onCloneQualityProfilePress}
/>
</div>
<div className={styles.qualities}>
{
items.map((item) => {
if (!item.allowed) {
return null;
}
if (item.quality) {
const isCutoff = upgradeAllowed && item.quality.id === cutoff;
return (
<Label
key={item.quality.id}
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
title={isCutoff ? translate('UpgradeUntilThisQualityIsMetOrExceeded') : null}
>
{item.quality.name}
</Label>
);
}
const isCutoff = upgradeAllowed && item.id === cutoff;
return (
<Tooltip
key={item.id}
className={styles.tooltipLabel}
anchor={
<Label
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
title={isCutoff ? translate('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 ? translate('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={translate('DeleteQualityProfile')}
message={translate('DeleteQualityProfileMessageText', { name })}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={this.onConfirmDeleteQualityProfile}
onCancel={this.onDeleteQualityProfileModalClose}
/>
</Card>
);
}
}
QualityProfile.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
upgradeAllowed: PropTypes.bool.isRequired,
cutoff: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
onCloneQualityProfilePress: PropTypes.func.isRequired
};
export default QualityProfile;

View file

@ -0,0 +1,165 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
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 { icons, kinds, tooltipPositions } from 'Helpers/Props';
import { deleteQualityProfile } from 'Store/Actions/settingsActions';
import { QualityProfileItems } from 'typings/QualityProfile';
import translate from 'Utilities/String/translate';
import EditQualityProfileModal from './EditQualityProfileModal';
import styles from './QualityProfile.css';
interface QualityProfileProps {
id: number;
name: string;
upgradeAllowed: boolean;
cutoff: number;
items: QualityProfileItems;
isDeleting: boolean;
onCloneQualityProfilePress: (id: number) => void;
}
function QualityProfile({
id,
name,
upgradeAllowed,
cutoff,
items,
isDeleting,
onCloneQualityProfilePress,
}: QualityProfileProps) {
const dispatch = useDispatch();
const [isEditQualityProfileModalOpen, setIsEditQualityProfileModalOpen] =
useState(false);
const [isDeleteQualityProfileModalOpen, setIsDeleteQualityProfileModalOpen] =
useState(false);
const handleEditQualityProfilePress = useCallback(() => {
setIsEditQualityProfileModalOpen(true);
}, []);
const handleEditQualityProfileModalClose = useCallback(() => {
setIsEditQualityProfileModalOpen(false);
}, []);
const handleDeleteQualityProfilePress = useCallback(() => {
setIsDeleteQualityProfileModalOpen(true);
}, []);
const handleDeleteQualityProfileModalClose = useCallback(() => {
setIsDeleteQualityProfileModalOpen(false);
}, []);
const handleConfirmDeleteQualityProfile = useCallback(() => {
dispatch(deleteQualityProfile({ id }));
}, [id, dispatch]);
const handleCloneQualityProfilePress = useCallback(() => {
onCloneQualityProfilePress(id);
}, [id, onCloneQualityProfilePress]);
return (
<Card
className={styles.qualityProfile}
overlayContent={true}
onPress={handleEditQualityProfilePress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>{name}</div>
<IconButton
className={styles.cloneButton}
title={translate('CloneProfile')}
name={icons.CLONE}
onPress={handleCloneQualityProfilePress}
/>
</div>
<div className={styles.qualities}>
{items.map((item) => {
if (!item.allowed) {
return null;
}
if ('quality' in item) {
const isCutoff = upgradeAllowed && item.quality.id === cutoff;
return (
<Label
key={item.quality.id}
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
title={
isCutoff
? translate('UpgradeUntilThisQualityIsMetOrExceeded')
: undefined
}
>
{item.quality.name}
</Label>
);
}
const isCutoff = upgradeAllowed && item.id === cutoff;
return (
<Tooltip
key={item.id}
className={styles.tooltipLabel}
anchor={
<Label
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
title={isCutoff ? translate('Cutoff') : undefined}
>
{item.name}
</Label>
}
tooltip={
<div>
{item.items.map((groupItem) => {
return (
<Label
key={groupItem.quality.id}
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
title={isCutoff ? translate('Cutoff') : undefined}
>
{groupItem.quality.name}
</Label>
);
})}
</div>
}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
);
})}
</div>
<EditQualityProfileModal
id={id}
isOpen={isEditQualityProfileModalOpen}
onModalClose={handleEditQualityProfileModalClose}
onDeleteQualityProfilePress={handleDeleteQualityProfilePress}
/>
<ConfirmModal
isOpen={isDeleteQualityProfileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteQualityProfile')}
message={translate('DeleteQualityProfileMessageText', { name })}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={handleConfirmDeleteQualityProfile}
onCancel={handleDeleteQualityProfileModalClose}
/>
</Card>
);
}
export default QualityProfile;

View file

@ -1,68 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import NumberInput from 'Components/Form/NumberInput';
import styles from './QualityProfileFormatItem.css';
class QualityProfileFormatItem extends Component {
//
// Listeners
onScoreChange = ({ value }) => {
const {
formatId
} = this.props;
this.props.onScoreChange(formatId, value);
};
//
// Render
render() {
const {
name,
score
} = this.props;
return (
<div
className={styles.qualityProfileFormatItemContainer}
>
<div
className={styles.qualityProfileFormatItem}
>
<label
className={styles.formatNameContainer}
>
<div className={styles.formatName}>
{name}
</div>
<NumberInput
containerClassName={styles.scoreContainer}
className={styles.scoreInput}
name={name}
value={score}
onChange={this.onScoreChange}
/>
</label>
</div>
</div>
);
}
}
QualityProfileFormatItem.propTypes = {
formatId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number.isRequired,
onScoreChange: PropTypes.func
};
QualityProfileFormatItem.defaultProps = {
// To handle the case score is deleted during edit
score: 0
};
export default QualityProfileFormatItem;

View file

@ -0,0 +1,45 @@
import React, { useCallback } from 'react';
import NumberInput from 'Components/Form/NumberInput';
import { InputChanged } from 'typings/inputs';
import styles from './QualityProfileFormatItem.css';
interface QualityProfileFormatItemProps {
formatId: number;
name: string;
score?: number;
onScoreChange: (formatId: number, score: number) => void;
}
function QualityProfileFormatItem({
formatId,
name,
score = 0,
onScoreChange,
}: QualityProfileFormatItemProps) {
const handleScoreChange = useCallback(
({ value }: InputChanged<number>) => {
onScoreChange(formatId, value);
},
[formatId, onScoreChange]
);
return (
<div className={styles.qualityProfileFormatItemContainer}>
<div className={styles.qualityProfileFormatItem}>
<label className={styles.formatNameContainer}>
<div className={styles.formatName}>{name}</div>
<NumberInput
containerClassName={styles.scoreContainer}
className={styles.scoreInput}
name={name}
value={score}
// @ts-expect-error - mismatched types
onChange={handleScoreChange}
/>
</label>
</div>
</div>
);
}
export default QualityProfileFormatItem;

View file

@ -1,158 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import FormLabel from 'Components/Form/FormLabel';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import { sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import QualityProfileFormatItem from './QualityProfileFormatItem';
import styles from './QualityProfileFormatItems.css';
function calcOrder(profileFormatItems) {
const items = profileFormatItems.reduce((acc, cur, index) => {
acc[cur.format] = index;
return acc;
}, {});
return [...profileFormatItems].sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return a.name.localeCompare(b.name, undefined, { numeric: true });
}).map((x) => items[x.format]);
}
class QualityProfileFormatItems extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
order: calcOrder(this.props.profileFormatItems)
};
}
//
// Listeners
onScoreChange = (formatId, value) => {
const {
onQualityProfileFormatItemScoreChange
} = this.props;
onQualityProfileFormatItemScoreChange(formatId, value);
this.reorderItems();
};
reorderItems = _.debounce(() => this.setState({ order: calcOrder(this.props.profileFormatItems) }), 1000);
//
// Render
render() {
const {
profileFormatItems,
errors,
warnings
} = this.props;
const {
order
} = this.state;
if (profileFormatItems.length < 1) {
return (
<InlineMarkdown className={styles.addCustomFormatMessage} data={translate('WantMoreControlAddACustomFormat')} />
);
}
return (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('CustomFormats')}
</FormLabel>
<div>
<FormInputHelpText
text={translate('CustomFormatHelpText')}
/>
{
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}
/>
);
})
}
<div className={styles.formats}>
<div className={styles.headerContainer}>
<div className={styles.headerTitle}>
{translate('CustomFormat')}
</div>
<div className={styles.headerScore}>
{translate('Score')}
</div>
</div>
{
order.map((index) => {
const {
format,
name,
score
} = profileFormatItems[index];
return (
<QualityProfileFormatItem
key={format}
formatId={format}
name={name}
score={score}
onScoreChange={this.onScoreChange}
/>
);
})
}
</div>
</div>
</FormGroup>
);
}
}
QualityProfileFormatItems.propTypes = {
profileFormatItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object),
onQualityProfileFormatItemScoreChange: PropTypes.func
};
QualityProfileFormatItems.defaultProps = {
errors: [],
warnings: []
};
export default QualityProfileFormatItems;

View file

@ -0,0 +1,113 @@
import React, { useMemo } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import FormLabel from 'Components/Form/FormLabel';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import { sizes } from 'Helpers/Props';
import { QualityProfileFormatItem as QualityProfileFormatItemModel } from 'typings/CustomFormat';
import { Failure } from 'typings/pending';
import translate from 'Utilities/String/translate';
import QualityProfileFormatItem from './QualityProfileFormatItem';
import styles from './QualityProfileFormatItems.css';
interface QualityProfileFormatItemsProps {
profileFormatItems: QualityProfileFormatItemModel[];
errors?: Failure[];
warnings?: Failure[];
onQualityProfileFormatItemScoreChange: (
formatId: number,
score: number
) => void;
}
function QualityProfileFormatItems({
profileFormatItems,
errors = [],
warnings = [],
onQualityProfileFormatItemScoreChange,
}: QualityProfileFormatItemsProps) {
const order = useMemo(() => {
const items = profileFormatItems.reduce<Record<number, number>>(
(acc, cur, index) => {
acc[cur.format] = index;
return acc;
},
{}
);
return [...profileFormatItems]
.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return a.name.localeCompare(b.name, undefined, { numeric: true });
})
.map((x) => items[x.format]);
}, [profileFormatItems]);
if (profileFormatItems.length < 1) {
return (
<InlineMarkdown
className={styles.addCustomFormatMessage}
data={translate('WantMoreControlAddACustomFormat')}
/>
);
}
return (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>{translate('CustomFormats')}</FormLabel>
<div>
<FormInputHelpText text={translate('CustomFormatHelpText')} />
{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}
/>
);
})}
<div className={styles.formats}>
<div className={styles.headerContainer}>
<div className={styles.headerTitle}>
{translate('CustomFormat')}
</div>
<div className={styles.headerScore}>{translate('Score')}</div>
</div>
{order.map((index) => {
const { format, name, score } = profileFormatItems[index];
return (
<QualityProfileFormatItem
key={format}
formatId={format}
name={name}
score={score}
onScoreChange={onQualityProfileFormatItemScoreChange}
/>
);
})}
</div>
</div>
</FormGroup>
);
}
export default QualityProfileFormatItems;

View file

@ -1,162 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import QualityProfileItemSize from './QualityProfileItemSize';
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 {
mode,
isPreview,
qualityId,
groupId,
name,
allowed,
minSize,
maxSize,
preferredSize,
isDragging,
isOverCurrent,
connectDragSource,
onSizeChange
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileItem,
mode === 'editSizes' && styles.editSizes,
isDragging && styles.isDragging,
isPreview && styles.isPreview,
isOverCurrent && styles.isOverCurrent,
groupId && styles.isInGroup
)}
>
<label
className={classNames(
styles.qualityNameContainer,
mode === 'editSizes' && styles.editSizes
)}
>
{
mode === 'editGroups' && !groupId && !isPreview &&
<IconButton
className={styles.createGroupButton}
name={icons.GROUP}
title={translate('Group')}
onPress={this.onCreateGroupPress}
/>
}
{
mode === 'default' &&
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name={name}
value={allowed}
isDisabled={!!groupId}
onChange={this.onAllowedChange}
/>
}
<div className={classNames(
styles.qualityName,
groupId && mode !== 'editSizes' && styles.isInGroup,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</label>
{
mode === 'editSizes' && qualityId != null ?
<div>
<QualityProfileItemSize
id={qualityId}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
onSizeChange={onSizeChange}
/>
</div> :
null
}
{
mode === 'editSizes' ? null :
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
title={translate('CreateGroup')}
name={icons.REORDER}
/>
</div>
)
}
</div>
);
}
}
QualityProfileItem.propTypes = {
mode: PropTypes.string.isRequired,
isPreview: PropTypes.bool,
groupId: PropTypes.number,
qualityId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
isDragging: PropTypes.bool.isRequired,
isOverCurrent: PropTypes.bool.isRequired,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func,
onSizeChange: PropTypes.func
};
QualityProfileItem.defaultProps = {
mode: 'default',
isPreview: false,
isOverCurrent: false,
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default QualityProfileItem;

View file

@ -0,0 +1,129 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { ConnectDragSource } from 'react-dnd';
import CheckInput from 'Components/Form/CheckInput';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import QualityProfileItemSize, { SizeChanged } from './QualityProfileItemSize';
import styles from './QualityProfileItem.css';
interface QualityProfileItemProps {
dragRef: ConnectDragSource;
mode: string;
isPreview?: boolean;
groupId?: number;
qualityId: number;
name: string;
allowed: boolean;
minSize: number | null;
maxSize: number | null;
preferredSize: number | null;
isDragging: boolean;
onCreateGroupPress?: (qualityId: number) => void;
onItemAllowedChange: (qualityId: number, allowed: boolean) => void;
onSizeChange: (change: SizeChanged) => void;
}
function QualityProfileItem({
dragRef,
mode = 'default',
isPreview = false,
qualityId,
groupId,
name,
allowed,
minSize,
maxSize,
isDragging,
preferredSize,
onCreateGroupPress,
onItemAllowedChange,
onSizeChange,
}: QualityProfileItemProps) {
const handleAllowedChange = useCallback(
({ value }: InputChanged<boolean>) => {
onItemAllowedChange?.(qualityId, value);
},
[qualityId, onItemAllowedChange]
);
const handleCreateGroupPress = useCallback(() => {
onCreateGroupPress?.(qualityId);
}, [qualityId, onCreateGroupPress]);
return (
<div
className={classNames(
styles.qualityProfileItem,
mode === 'editSizes' && styles.editSizes,
isDragging && styles.isDragging,
isPreview && styles.isPreview,
groupId && styles.isInGroup
)}
>
<label
className={classNames(
styles.qualityNameContainer,
mode === 'editSizes' && styles.editSizes
)}
>
{mode === 'editGroups' && !groupId && !isPreview && (
<IconButton
className={styles.createGroupButton}
name={icons.GROUP}
title={translate('Group')}
onPress={handleCreateGroupPress}
/>
)}
{mode === 'default' && (
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name={name}
value={allowed}
isDisabled={!!groupId}
onChange={handleAllowedChange}
/>
)}
<div
className={classNames(
styles.qualityName,
groupId && mode !== 'editSizes' && styles.isInGroup,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</label>
{mode === 'editSizes' && qualityId != null ? (
<div>
<QualityProfileItemSize
id={qualityId}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
onSizeChange={onSizeChange}
/>
</div>
) : null}
{mode === 'editSizes' ? null : (
<div ref={dragRef} className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
title={translate('CreateGroup')}
name={icons.REORDER}
/>
</div>
)}
</div>
);
}
export default QualityProfileItem;

View file

@ -1,4 +0,0 @@
.dragPreview {
width: 380px;
opacity: 0.75;
}

View file

@ -1,7 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'dragPreview': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,92 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragLayer } from 'react-dnd';
import DragPreviewLayer from 'Components/DragPreviewLayer';
import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
import dimensions from 'Styles/Variables/dimensions.js';
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);

View file

@ -1,10 +1,10 @@
.qualityProfileItemDragSource {
padding: $qualityProfileItemDragSourcePadding 0;
margin: $qualityProfileItemDragSourcePadding 0;
}
.qualityProfileItemPlaceholder {
width: 100%;
height: $qualityProfileItemHeight;
/* height: $qualityProfileItemHeight; */
border: 1px dotted #aaa;
border-radius: 4px;
}

View file

@ -1,254 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragSource, DropTarget } from 'react-dnd';
import { findDOMNode } from 'react-dom';
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 {
mode,
qualityIndex,
groupId,
qualityId,
name,
allowed
} = props;
return {
mode,
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 {
mode,
groupId,
qualityId,
name,
allowed,
items,
minSize,
maxSize,
preferredSize,
qualityIndex,
isDragging,
isDraggingUp,
isDraggingDown,
isOverCurrent,
connectDragSource,
connectDropTarget,
onCreateGroupPress,
onDeleteGroupPress,
onQualityProfileItemAllowedChange,
onItemGroupAllowedChange,
onItemGroupNameChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd,
onSizeChange
} = 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
mode={mode}
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}
onSizeChange={onSizeChange}
/>
}
{
qualityId != null &&
<QualityProfileItem
mode={mode}
groupId={groupId}
qualityId={qualityId}
name={name}
allowed={allowed}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
qualityIndex={qualityIndex}
isDragging={isDragging}
isOverCurrent={isOverCurrent}
connectDragSource={connectDragSource}
onCreateGroupPress={onCreateGroupPress}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onSizeChange={onSizeChange}
/>
}
{
isAfter &&
<div
className={classNames(
styles.qualityProfileItemPlaceholder,
styles.qualityProfileItemPlaceholderAfter
)}
/>
}
</div>
);
}
}
QualityProfileItemDragSource.propTypes = {
mode: PropTypes.string.isRequired,
groupId: PropTypes.number,
qualityId: PropTypes.number,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
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,
onSizeChange: PropTypes.func.isRequired
};
export default DropTarget(
QUALITY_PROFILE_ITEM,
qualityProfileItemDropTarget,
collectDropTarget
)(DragSource(
QUALITY_PROFILE_ITEM,
qualityProfileItemDragSource,
collectDragSource
)(QualityProfileItemDragSource));

View file

@ -0,0 +1,251 @@
import classNames from 'classnames';
import React, { useRef } from 'react';
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
import DragType from 'Helpers/DragType';
import useMeasure from 'Helpers/Hooks/useMeasure';
import { qualityProfileItemHeight } from 'Styles/Variables/dimensions';
import { QualityProfileQualityItem } from 'typings/QualityProfile';
import QualityProfileItem from './QualityProfileItem';
import QualityProfileItemGroup from './QualityProfileItemGroup';
import { SizeChanged } from './QualityProfileItemSize';
import styles from './QualityProfileItemDragSource.css';
export interface DragMoveState {
dragQualityIndex: string | null;
dropQualityIndex: string | null;
dropPosition: 'above' | 'below' | null;
}
interface DragItem {
mode: string;
qualityIndex: string;
groupId: number | undefined;
qualityId: number | undefined;
isGroup: boolean;
name: string;
allowed: boolean;
height: number;
}
interface ItemProps {
groupId: number | undefined;
qualityId: number;
name: string;
minSize: number | null;
maxSize: number | null;
preferredSize: number | null;
isInGroup?: boolean;
onCreateGroupPress?: (qualityId: number) => void;
onItemAllowedChange: (id: number, allowd: boolean) => void;
}
interface GroupProps {
groupId: number;
qualityId: undefined;
items: QualityProfileQualityItem[];
qualityIndex: string;
onDeleteGroupPress: (groupId: number) => void;
onItemAllowedChange: (id: number, allowd: boolean) => void;
onGroupAllowedChange: (id: number, allowd: boolean) => void;
onItemGroupNameChange: (groupId: number, name: string) => void;
}
interface CommonProps {
mode: string;
name: string;
allowed: boolean;
qualityIndex: string;
isDraggingUp: boolean;
isDraggingDown: boolean;
onDragMove: (move: DragMoveState) => void;
onDragEnd: (didDrop: boolean) => void;
onSizeChange: (sizeChange: SizeChanged) => void;
}
export type QualityProfileItemDragSourceProps = CommonProps &
(ItemProps | GroupProps);
export interface QualityProfileItemDragSourceActionProps {
onCreateGroupPress?: (qualityId: number) => void;
onItemAllowedChange: (id: number, allowd: boolean) => void;
onDeleteGroupPress: (groupId: number) => void;
onGroupAllowedChange: (id: number, allowd: boolean) => void;
onItemGroupNameChange: (groupId: number, name: string) => void;
onDragMove: (move: DragMoveState) => void;
onDragEnd: (didDrop: boolean) => void;
onSizeChange: (sizeChange: SizeChanged) => void;
}
function QualityProfileItemDragSource({
mode,
groupId,
qualityId,
name,
allowed,
qualityIndex,
isDraggingDown,
isDraggingUp,
onDragMove,
onDragEnd,
...otherProps
}: QualityProfileItemDragSourceProps) {
const ref = useRef<HTMLDivElement>(null);
const [measureRef, { height }] = useMeasure();
const [{ isOver, dragHeight }, dropRef] = useDrop<
DragItem,
void,
{ isOver: boolean; dragHeight: number }
>({
accept: DragType.QualityProfileItem,
collect(monitor) {
return {
isOver: monitor.isOver(),
dragHeight: monitor.getItem()?.height ?? qualityProfileItemHeight,
};
},
hover(item: DragItem, monitor) {
if (!ref.current) {
return;
}
const { qualityIndex: dragQualityIndex, isGroup: isDragGroup } = item;
const dropQualityIndex = qualityIndex;
const isDropGroupItem = !!(qualityId && groupId);
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverHeight = hoverBoundingRect.bottom - hoverBoundingRect.top;
// Smooth out updates when dragging down and the size grows to avoid flickering
const hoverMiddleY = Math.max(hoverHeight - height, height) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).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: 'above' | 'below' | null = null;
// Determine drop position based on position over target
if (hoverClientY > hoverMiddleY) {
dropPosition = 'below';
} else if (hoverClientY < hoverMiddleY) {
dropPosition = 'above';
} else {
return;
}
onDragMove({
dragQualityIndex,
dropQualityIndex,
dropPosition,
});
},
});
const [{ isDragging }, dragRef, previewRef] = useDrag<
DragItem,
unknown,
{ isDragging: boolean }
>({
type: DragType.QualityProfileItem,
item: () => {
return {
mode,
qualityIndex,
groupId,
qualityId,
isGroup: !qualityId,
name,
allowed,
height,
};
},
collect: (monitor: DragSourceMonitor<unknown, unknown>) => ({
isDragging: monitor.isDragging(),
}),
end: (_item: DragItem, monitor) => {
onDragEnd(monitor.didDrop());
},
});
dropRef(previewRef(ref));
const isBefore = !isDragging && isDraggingUp && isOver;
const isAfter = !isDragging && isDraggingDown && isOver;
return (
<div ref={ref} className={classNames(styles.qualityProfileItemDragSource)}>
{isBefore ? (
<div
className={classNames(
styles.qualityProfileItemPlaceholder,
styles.qualityProfileItemPlaceholderBefore
)}
style={{
height: dragHeight,
}}
/>
) : null}
<div ref={measureRef}>
{'items' in otherProps && groupId ? (
<QualityProfileItemGroup
{...otherProps}
dragRef={dragRef}
mode={mode}
groupId={groupId}
name={name}
allowed={allowed}
qualityIndex={qualityIndex}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
onDragEnd={onDragEnd}
onDragMove={onDragMove}
/>
) : null}
{!('items' in otherProps) && qualityId ? (
<QualityProfileItem
{...otherProps}
dragRef={dragRef}
mode={mode}
qualityId={qualityId}
name={name}
allowed={allowed}
isDragging={isDragging}
/>
) : null}
</div>
{isAfter ? (
<div
className={classNames(
styles.qualityProfileItemPlaceholder,
styles.qualityProfileItemPlaceholderAfter
)}
style={{
height: dragHeight,
}}
/>
) : null}
</div>
);
}
export default QualityProfileItemDragSource;

View file

@ -1,228 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
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 {
mode,
groupId,
name,
allowed,
items,
qualityIndex,
isDragging,
isDraggingUp,
isDraggingDown,
connectDragSource,
onQualityProfileItemAllowedChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd,
onSizeChange
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileItemGroup,
mode === 'editGroups' && styles.editGroups,
mode === 'editSizes' && styles.editSizes,
isDragging && styles.isDragging
)}
>
<div className={styles.qualityProfileItemGroupInfo}>
{
mode === 'editGroups' &&
<div className={styles.qualityNameContainer}>
<IconButton
className={styles.deleteGroupButton}
name={icons.UNGROUP}
title={translate('Ungroup')}
onPress={this.onDeleteGroupPress}
/>
<TextInput
className={styles.nameInput}
name="name"
value={name}
onChange={this.onNameChange}
/>
</div>
}
{
mode === 'default' &&
<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>
}
{
mode === 'editSizes' &&
<label
className={styles.editSizesQualityNameLabel}
>
<div className={styles.nameContainer}>
<div className={classNames(
styles.name,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</div>
</label>
}
{
mode === 'editSizes' ? null :
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
title={translate('Reorder')}
/>
</div>
)
}
</div>
{
mode === 'default' ?
null :
<div className={mode === 'editGroups' ? styles.items : undefined}>
{
items.map(({ quality }, index) => {
return (
<QualityProfileItemDragSource
key={quality.id}
mode={mode}
groupId={groupId}
qualityId={quality.id}
name={quality.name}
allowed={allowed}
minSize={quality.minSize}
maxSize={quality.maxSize}
preferredSize={quality.preferredSize}
items={items}
qualityIndex={`${qualityIndex}.${index + 1}`}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
isInGroup={true}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
onSizeChange={onSizeChange}
/>
);
}).reverse()
}
</div>
}
</div>
);
}
}
QualityProfileItemGroup.propTypes = {
mode: PropTypes.string.isRequired,
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,
onSizeChange: PropTypes.func
};
QualityProfileItemGroup.defaultProps = {
mode: 'default',
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default QualityProfileItemGroup;

View file

@ -0,0 +1,194 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { ConnectDragSource } from 'react-dnd';
import CheckInput from 'Components/Form/CheckInput';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import { InputChanged } from 'typings/inputs';
import { QualityProfileQualityItem } from 'typings/QualityProfile';
import translate from 'Utilities/String/translate';
import QualityProfileItemDragSource, {
DragMoveState,
} from './QualityProfileItemDragSource';
import { SizeChanged } from './QualityProfileItemSize';
import styles from './QualityProfileItemGroup.css';
interface QualityProfileItemGroupProps {
dragRef: ConnectDragSource;
mode?: string;
groupId: number;
name: string;
allowed: boolean;
items: QualityProfileQualityItem[];
qualityIndex: string;
isDragging: boolean;
isDraggingUp: boolean;
isDraggingDown: boolean;
onGroupAllowedChange: (groupId: number, allowed: boolean) => void;
onItemAllowedChange: (groupId: number, allowed: boolean) => void;
onItemGroupNameChange: (groupId: number, name: string) => void;
onDeleteGroupPress: (groupId: number) => void;
onDragMove: (drag: DragMoveState) => void;
onDragEnd: (didDrop: boolean) => void;
onSizeChange: (sizeChange: SizeChanged) => void;
}
function QualityProfileItemGroup({
dragRef,
mode = 'default',
groupId,
name,
allowed,
items,
qualityIndex,
isDragging,
isDraggingUp,
isDraggingDown,
onDeleteGroupPress,
onGroupAllowedChange,
onItemAllowedChange,
onItemGroupNameChange,
onDragMove,
onDragEnd,
onSizeChange,
}: QualityProfileItemGroupProps) {
const handleAllowedChange = useCallback(
({ value }: InputChanged<boolean>) => {
onGroupAllowedChange?.(groupId, value);
},
[groupId, onGroupAllowedChange]
);
const handleNameChange = useCallback(
({ value }: InputChanged<string>) => {
onItemGroupNameChange?.(groupId, value);
},
[groupId, onItemGroupNameChange]
);
const handleDeleteGroupPress = useCallback(() => {
onDeleteGroupPress?.(groupId);
}, [groupId, onDeleteGroupPress]);
return (
<div
className={classNames(
styles.qualityProfileItemGroup,
mode === 'editGroups' && styles.editGroups,
mode === 'editSizes' && styles.editSizes,
isDragging && styles.isDragging
)}
>
<div className={styles.qualityProfileItemGroupInfo}>
{mode === 'editGroups' ? (
<div className={styles.qualityNameContainer}>
<IconButton
className={styles.deleteGroupButton}
name={icons.UNGROUP}
title={translate('Ungroup')}
onPress={handleDeleteGroupPress}
/>
<TextInput
className={styles.nameInput}
name="name"
value={name}
onChange={handleNameChange}
/>
</div>
) : null}
{mode === 'default' ? (
<label className={styles.qualityNameLabel}>
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name="allowed"
value={allowed}
onChange={handleAllowedChange}
/>
<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>
) : null}
{mode === 'editSizes' ? (
<label className={styles.editSizesQualityNameLabel}>
<div className={styles.nameContainer}>
<div
className={classNames(
styles.name,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</div>
</label>
) : null}
{mode === 'editSizes' ? null : (
<div ref={dragRef} className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
title={translate('Reorder')}
/>
</div>
)}
</div>
{mode === 'default' ? null : (
<div className={mode === 'editGroups' ? styles.items : undefined}>
{items
.map(({ quality }, index) => {
return (
<QualityProfileItemDragSource
key={quality.id}
mode={mode}
groupId={groupId}
qualityId={quality.id}
name={quality.name}
allowed={allowed}
minSize={quality.minSize}
maxSize={quality.maxSize}
preferredSize={quality.preferredSize}
qualityIndex={`${qualityIndex}.${index + 1}`}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
isInGroup={true}
onItemAllowedChange={onItemAllowedChange}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onSizeChange={onSizeChange}
/>
);
})
.reverse()}
</div>
)}
</div>
);
}
export default QualityProfileItemGroup;

View file

@ -23,12 +23,16 @@ interface SizeProps {
maxSize: number | null;
}
export interface OnSizeChangeArguments extends SizeProps {
id: number;
export interface SizeChanged extends SizeProps {
qualityId: number;
}
export interface QualityProfileItemSizeProps extends OnSizeChangeArguments {
onSizeChange: (props: OnSizeChangeArguments) => void;
export interface QualityProfileItemSizeProps {
id: number;
minSize: number | null;
preferredSize: number | null;
maxSize: number | null;
onSizeChange: (props: SizeChanged) => void;
}
function trackRenderer(props: HTMLProps<HTMLDivElement>) {
@ -45,10 +49,13 @@ function getSliderValue(value: number | null, defaultValue: number): number {
return roundNumber(sliderValue);
}
export default function QualityProfileItemSize(
props: QualityProfileItemSizeProps
) {
const { id, minSize, maxSize, preferredSize, onSizeChange } = props;
export default function QualityProfileItemSize({
id,
minSize,
maxSize,
preferredSize,
onSizeChange,
}: QualityProfileItemSizeProps) {
const [sizes, setSizes] = useState<SizeProps>({
minSize: getSliderValue(minSize, MIN),
preferredSize: getSliderValue(preferredSize, SLIDER_MAX - MIN_DISTANCE),
@ -61,21 +68,14 @@ export default function QualityProfileItemSize(
number,
number
]) => {
// console.log('Sizes:', sliderMinSize, sliderPreferredSize, sliderMaxSize);
console.log(
'Min Sizes: ',
sliderMinSize,
roundNumber(Math.pow(sliderMinSize, 1.1))
);
setSizes({
minSize: sliderMinSize,
preferredSize: sliderPreferredSize,
maxSize: sliderMaxSize,
});
onSizeChange({
id,
onSizeChange?.({
qualityId: id,
minSize: roundNumber(Math.pow(sliderMinSize, 1.1)),
preferredSize:
sliderPreferredSize === MAX - MIN_DISTANCE
@ -98,8 +98,8 @@ export default function QualityProfileItemSize(
maxSize: sizes.maxSize,
});
onSizeChange({
id,
onSizeChange?.({
qualityId: id,
minSize: value,
preferredSize: sizes.preferredSize,
maxSize: sizes.maxSize,
@ -116,8 +116,8 @@ export default function QualityProfileItemSize(
maxSize: sizes.maxSize,
});
onSizeChange({
id,
onSizeChange?.({
qualityId: id,
minSize: sizes.minSize,
preferredSize: value,
maxSize: sizes.maxSize,
@ -134,8 +134,8 @@ export default function QualityProfileItemSize(
maxSize: value,
});
onSizeChange({
id,
onSizeChange?.({
qualityId: id,
minSize: sizes.minSize,
preferredSize: sizes.preferredSize,
maxSize: value,

View file

@ -1,204 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import Measure from 'Components/Measure';
import { icons, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import QualityProfileItemDragPreview from './QualityProfileItemDragPreview';
import QualityProfileItemDragSource from './QualityProfileItemDragSource';
import styles from './QualityProfileItems.css';
class QualityProfileItems extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
defaultHeight: 0,
editGroupsHeight: 0,
editSizesHeight: 0
};
}
//
// Listeners
onMeasure = ({ height }) => {
const heightKey = `${this.props.mode}Height`;
this.setState({
[heightKey]: height
});
};
onEditGroupsPress = () => {
this.props.onChangeMode('editGroups');
};
onEditSizesPress = () => {
this.props.onChangeMode('editSizes');
};
onDefaultModePress = () => {
this.props.onChangeMode('default');
};
//
// Render
render() {
const {
mode,
dropQualityIndex,
dropPosition,
qualityProfileItems,
errors,
warnings,
...otherProps
} = this.props;
const isDragging = dropQualityIndex !== null;
const isDraggingUp = isDragging && dropPosition === 'above';
const isDraggingDown = isDragging && dropPosition === 'below';
const height = this.state[`${mode}Height`];
return (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('Qualities')}
</FormLabel>
<div>
<FormInputHelpText
text={translate('QualitiesHelpText')}
/>
{
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={mode === 'editGroups' ? this.onDefaultModePress : this.onEditGroupsPress}
>
<div>
<Icon
className={styles.editButtonIcon}
name={mode === 'editGroups' ? icons.REORDER : icons.GROUP}
/>
{
mode === 'editGroups' ? translate('DoneEditingGroups') : translate('EditGroups')
}
</div>
</Button>
<Button
className={styles.editSizesButton}
kind={kinds.PRIMARY}
onPress={mode === 'editSizes' ? this.onDefaultModePress : this.onEditSizesPress}
>
<div>
<Icon
className={styles.editButtonIcon}
name={mode === 'editSizes' ? icons.REORDER : icons.FILE}
/>
{
mode === 'editSizes' ? translate('DoneEditingSizes') : translate('EditSizes')
}
</div>
</Button>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onMeasure}
>
<div
className={styles.qualities}
style={{ height: `${height}px` }}
>
{
qualityProfileItems.map(({ id, name, allowed, quality, items, minSize, maxSize, preferredSize }, index) => {
const identifier = quality ? quality.id : id;
return (
<QualityProfileItemDragSource
key={identifier}
mode={mode}
groupId={id}
qualityId={quality && quality.id}
name={quality ? quality.name : name}
allowed={allowed}
items={items}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
qualityIndex={`${index + 1}`}
isInGroup={false}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
{...otherProps}
/>
);
}).reverse()
}
<QualityProfileItemDragPreview />
</div>
</Measure>
</div>
</FormGroup>
);
}
}
QualityProfileItems.propTypes = {
mode: PropTypes.string.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),
onChangeMode: PropTypes.func.isRequired
};
QualityProfileItems.defaultProps = {
errors: [],
warnings: []
};
export default QualityProfileItems;

View file

@ -0,0 +1,209 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useMeasure from 'react-use-measure';
import FormGroup from 'Components/Form/FormGroup';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import { icons, kinds, sizes } from 'Helpers/Props';
import { Failure } from 'typings/pending';
import { QualityProfileItems as Items } from 'typings/QualityProfile';
import translate from 'Utilities/String/translate';
import QualityProfileItemDragSource, {
QualityProfileItemDragSourceActionProps,
} from './QualityProfileItemDragSource';
import styles from './QualityProfileItems.css';
export type EditQualityProfileMode = 'default' | 'editGroups' | 'editSizes';
interface QualityProfileItemsProps
extends QualityProfileItemDragSourceActionProps {
mode: EditQualityProfileMode;
dragQualityIndex: string | null;
dropQualityIndex: string | null;
dropPosition: string | null;
qualityProfileItems: Items;
errors?: Failure[];
warnings?: Failure[];
onChangeMode: (mode: EditQualityProfileMode) => void;
}
function QualityProfileItems({
mode,
dropQualityIndex,
dropPosition,
qualityProfileItems,
errors = [],
warnings = [],
onChangeMode,
...otherProps
}: QualityProfileItemsProps) {
const [measureRef, { height: measuredHeight }] = useMeasure();
const [defaultHeight, setDefaultHeight] = useState(0);
const [editGroupsHeight, setEditGroupsHeight] = useState(0);
const [editSizesHeight, setEditSizesHeight] = useState(0);
const isDragging = dropQualityIndex !== null;
const isDraggingUp = isDragging && dropPosition === 'above';
const isDraggingDown = isDragging && dropPosition === 'below';
const height = useMemo(() => {
if (mode === 'default' && defaultHeight > 0) {
return defaultHeight;
} else if (mode === 'editGroups' && editGroupsHeight > 0) {
return editGroupsHeight;
} else if (mode === 'editSizes' && editSizesHeight > 0) {
return editSizesHeight;
}
return 'auto';
}, [mode, defaultHeight, editGroupsHeight, editSizesHeight]);
const handleEditGroupsPress = useCallback(() => {
onChangeMode('editGroups');
}, [onChangeMode]);
const handleEditSizesPress = useCallback(() => {
onChangeMode('editSizes');
}, [onChangeMode]);
const handleDefaultModePress = useCallback(() => {
onChangeMode('default');
}, [onChangeMode]);
useEffect(() => {
if (mode === 'default') {
setDefaultHeight(measuredHeight);
} else if (mode === 'editGroups') {
setEditGroupsHeight(measuredHeight);
} else if (mode === 'editSizes') {
setEditSizesHeight(measuredHeight);
}
}, [mode, measuredHeight]);
return (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>{translate('Qualities')}</FormLabel>
<div>
<FormInputHelpText text={translate('QualitiesHelpText')} />
{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={
mode === 'editGroups'
? handleDefaultModePress
: handleEditGroupsPress
}
>
<div>
<Icon
className={styles.editButtonIcon}
name={mode === 'editGroups' ? icons.REORDER : icons.GROUP}
/>
{mode === 'editGroups'
? translate('DoneEditingGroups')
: translate('EditGroups')}
</div>
</Button>
<Button
className={styles.editSizesButton}
kind={kinds.PRIMARY}
onPress={
mode === 'editSizes' ? handleDefaultModePress : handleEditSizesPress
}
>
<div>
<Icon
className={styles.editButtonIcon}
name={mode === 'editSizes' ? icons.REORDER : icons.FILE}
/>
{mode === 'editSizes'
? translate('DoneEditingSizes')
: translate('EditSizes')}
</div>
</Button>
<div
ref={measureRef}
className={styles.qualities}
style={{ minHeight: `${height}px` }}
>
{qualityProfileItems
.map((item, index) => {
if ('quality' in item) {
const { quality, allowed, minSize, maxSize, preferredSize } =
item;
return (
<QualityProfileItemDragSource
key={item.quality.id}
{...otherProps}
mode={mode}
groupId={undefined}
qualityId={quality.id}
name={quality.name}
allowed={allowed}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
qualityIndex={`${index + 1}`}
isInGroup={false}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
/>
);
}
const { id, name, allowed, items } = item;
return (
<QualityProfileItemDragSource
key={id}
{...otherProps}
mode={mode}
groupId={id}
qualityId={undefined}
name={name}
allowed={allowed}
items={items}
qualityIndex={`${index + 1}`}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
/>
);
})
.reverse()}
</div>
</div>
</FormGroup>
);
}
export default QualityProfileItems;

View file

@ -0,0 +1,18 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createQualityProfileSelectorForHook } from 'Store/Selectors/createQualityProfileSelector';
import translate from 'Utilities/String/translate';
interface QualityProfileNameProps {
qualityProfileId: number;
}
function QualityProfileName({ qualityProfileId }: QualityProfileNameProps) {
const qualityProfile = useSelector(
createQualityProfileSelectorForHook(qualityProfileId)
);
return <span>{qualityProfile?.name ?? translate('Unknown')}</span>;
}
export default QualityProfileName;

View file

@ -1,31 +0,0 @@
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);

View file

@ -1,107 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
import QualityProfile from './QualityProfile';
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={translate('QualityProfiles')}>
<PageSectionContent
errorMessage={translate('QualityProfilesLoadError')}
{...otherProps}
>
<div className={styles.qualityProfiles}>
{
items.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;

View file

@ -0,0 +1,93 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { QualityProfilesAppState } from 'App/State/SettingsAppState';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import {
cloneQualityProfile,
fetchQualityProfiles,
} from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import QualityProfileModel from 'typings/QualityProfile';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import EditQualityProfileModal from './EditQualityProfileModal';
import QualityProfile from './QualityProfile';
import styles from './QualityProfiles.css';
function QualityProfiles() {
const dispatch = useDispatch();
const { error, isFetching, isPopulated, isDeleting, items } = useSelector(
createSortedSectionSelector<QualityProfileModel, QualityProfilesAppState>(
'settings.qualityProfiles',
sortByProp('name')
)
) as QualityProfilesAppState;
const [isQualityProfileModalOpen, setIsQualityProfileModalOpen] =
useState(false);
const handleEditQualityProfilePress = useCallback(() => {
setIsQualityProfileModalOpen(true);
}, []);
const handleEditQualityProfileClosePress = useCallback(() => {
setIsQualityProfileModalOpen(false);
}, []);
const handleCloneQualityProfilePress = useCallback(
(id: number) => {
dispatch(cloneQualityProfile({ id }));
setIsQualityProfileModalOpen(true);
},
[dispatch]
);
useEffect(() => {
dispatch(fetchQualityProfiles());
}, [dispatch]);
return (
<FieldSet legend={translate('QualityProfiles')}>
<PageSectionContent
errorMessage={translate('QualityProfilesLoadError')}
error={error}
isFetching={isFetching}
isPopulated={isPopulated}
>
<div className={styles.qualityProfiles}>
{items.map((item) => {
return (
<QualityProfile
key={item.id}
{...item}
isDeleting={isDeleting}
onCloneQualityProfilePress={handleCloneQualityProfilePress}
/>
);
})}
<Card
className={styles.addQualityProfile}
onPress={handleEditQualityProfilePress}
>
<div className={styles.center}>
<Icon name={icons.ADD} size={45} />
</div>
</Card>
</div>
<EditQualityProfileModal
isOpen={isQualityProfileModalOpen}
onModalClose={handleEditQualityProfileClosePress}
/>
</PageSectionContent>
</FieldSet>
);
}
export default QualityProfiles;

View file

@ -1,63 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { cloneQualityProfile, deleteQualityProfile, fetchQualityProfiles } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import QualityProfiles from './QualityProfiles';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')),
(qualityProfiles) => 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);

View file

@ -3,7 +3,7 @@ import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
interface QualityDefinitionLimitsProps {
bytes?: number;
bytes: number | null;
message: string;
}

View file

@ -6,7 +6,6 @@ import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useShowAdvancedSettings from 'Helpers/Hooks/useShowAdvancedSettings';
import {
fetchQualityDefinitions,
saveQualityDefinitions,
@ -16,7 +15,7 @@ import {
SetChildSave,
} from 'typings/Settings/SettingsState';
import translate from 'Utilities/String/translate';
import QualityDefinitionConnector from './QualityDefinitionConnector';
import QualityDefinition from './QualityDefinition';
import styles from './QualityDefinitions.css';
function createQualityDefinitionsSelector() {
@ -50,7 +49,6 @@ function QualityDefinitions({
onChildStateChange,
}: QualityDefinitionsProps) {
const dispatch = useDispatch();
const showAdvancedSettings = useShowAdvancedSettings();
const { items, isFetching, isPopulated, isSaving, error, hasPendingChanges } =
useSelector(createQualityDefinitionsSelector());
@ -90,32 +88,13 @@ function QualityDefinitions({
<div className={styles.header}>
<div className={styles.quality}>{translate('Quality')}</div>
<div className={styles.title}>{translate('Title')}</div>
<div className={styles.sizeLimit}>{translate('SizeLimit')}</div>
{showAdvancedSettings ? (
<div className={styles.megabytesPerMinute}>
{translate('MegabytesPerMinute')}
</div>
) : null}
</div>
<div className={styles.definitions}>
{items.map((item) => {
return (
<QualityDefinitionConnector
key={item.id}
{...item}
advancedSettings={showAdvancedSettings}
/>
);
return <QualityDefinition key={item.id} {...item} />;
})}
</div>
<div className={styles.sizeLimitHelpTextContainer}>
<div className={styles.sizeLimitHelpText}>
{translate('QualityLimitsSeriesRuntimeHelpText')}
</div>
</div>
</PageSectionContent>
</FieldSet>
);

View file

@ -1,105 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import QualityDefinitions from './Definition/QualityDefinitions';
import ResetQualityDefinitionsModal from './Reset/ResetQualityDefinitionsModal';
class Quality extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._saveCallback = null;
this.state = {
isSaving: false,
hasPendingChanges: false,
isConfirmQualityDefinitionResetModalOpen: false
};
}
//
// Listeners
onChildMounted = (saveCallback) => {
this._saveCallback = saveCallback;
};
onChildStateChange = (payload) => {
this.setState(payload);
};
onResetQualityDefinitionsPress = () => {
this.setState({ isConfirmQualityDefinitionResetModalOpen: true });
};
onCloseResetQualityDefinitionsModal = () => {
this.setState({ isConfirmQualityDefinitionResetModalOpen: false });
};
onSavePress = () => {
if (this._saveCallback) {
this._saveCallback();
}
};
//
// Render
render() {
const {
isSaving,
isResettingQualityDefinitions,
hasPendingChanges
} = this.state;
return (
<PageContent title={translate('QualitySettings')}>
<SettingsToolbarConnector
isSaving={isSaving}
hasPendingChanges={hasPendingChanges}
additionalButtons={
<Fragment>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('ResetDefinitions')}
iconName={icons.REFRESH}
isSpinning={isResettingQualityDefinitions}
onPress={this.onResetQualityDefinitionsPress}
/>
</Fragment>
}
onSavePress={this.onSavePress}
/>
<PageContentBody>
<QualityDefinitions
onChildMounted={this.onChildMounted}
onChildStateChange={this.onChildStateChange}
/>
</PageContentBody>
<ResetQualityDefinitionsModal
isOpen={this.state.isConfirmQualityDefinitionResetModalOpen}
onModalClose={this.onCloseResetQualityDefinitionsModal}
/>
</PageContent>
);
}
}
Quality.propTypes = {
isResettingQualityDefinitions: PropTypes.bool.isRequired
};
export default Quality;

View file

@ -1,25 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Series from 'Series/Series';
import ImportList from 'typings/ImportList';
import createAllSeriesSelector from './createAllSeriesSelector';
function createProfileInUseSelector(profileProp: string) {
return createSelector(
(_: AppState, { id }: { id: number }) => id,
createAllSeriesSelector(),
(state: AppState) => state.settings.importLists.items,
(id, series, lists) => {
if (!id) {
return false;
}
return (
series.some((s) => s[profileProp as keyof Series] === id) ||
lists.some((list) => list[profileProp as keyof ImportList] === id)
);
}
);
}
export default createProfileInUseSelector;

View file

@ -1,7 +1,7 @@
import _ from 'lodash';
import { createSelector } from 'reselect';
import ModelBase from 'App/ModelBase';
import {
AppSectionItemSchemaState,
AppSectionProviderState,
AppSectionSchemaState,
} from 'App/State/AppSectionState';
@ -11,30 +11,26 @@ import selectSettings, {
} from 'Store/Selectors/selectSettings';
import getSectionState from 'Utilities/State/getSectionState';
type SchemaState<T> = AppSectionSchemaState<T> | AppSectionItemSchemaState<T>;
function selector<
T extends ModelBaseSetting,
S extends AppSectionProviderState<T> & AppSectionSchemaState<T>
S extends AppSectionProviderState<T> & SchemaState<T>
>(id: number | undefined, section: S) {
if (!id) {
const item = _.isArray(section.schema)
? section.selectedSchema
: section.schema;
const settings = selectSettings(
Object.assign({ name: '' }, item),
section.pendingChanges ?? {},
section.saveError
);
if (id) {
const {
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
isFetching,
isPopulated,
error,
isSaving,
saveError,
isTesting,
pendingChanges,
} = section;
const item = section.items.find((i) => i.id === id)!;
const settings = selectSettings<T>(item, pendingChanges, saveError);
return {
isFetching,
isPopulated,
@ -43,24 +39,31 @@ function selector<
saveError,
isTesting,
...settings,
pendingChanges,
item: settings.settings,
};
}
const item =
'selectedSchema' in section
? section.selectedSchema
: (section.schema as T);
const settings = selectSettings(
Object.assign({ name: '' }, item),
section.pendingChanges ?? {},
section.saveError
);
const {
isFetching,
isPopulated,
error,
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
isSaving,
saveError,
isTesting,
pendingChanges,
} = section;
const item = section.items.find((i) => i.id === id)!;
const settings = selectSettings<T>(item, pendingChanges, saveError);
return {
isFetching,
isPopulated,
@ -69,13 +72,14 @@ function selector<
saveError,
isTesting,
...settings,
pendingChanges,
item: settings.settings,
};
}
export default function createProviderSettingsSelector<
T extends ModelBase,
S extends AppSectionProviderState<T> & AppSectionSchemaState<T>
S extends AppSectionProviderState<T> & SchemaState<T>
>(sectionName: string) {
// @ts-expect-error - This isn't fully typed
return createSelector(
@ -87,7 +91,7 @@ export default function createProviderSettingsSelector<
export function createProviderSettingsSelectorHook<
T extends ModelBaseSetting,
S extends AppSectionProviderState<T> & AppSectionSchemaState<T>
S extends AppSectionProviderState<T> & SchemaState<T>
>(sectionName: string, id: number | undefined) {
return createSelector(
(state: AppState) => state.settings,

View file

@ -0,0 +1,22 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import createAllSeriesSelector from './createAllSeriesSelector';
function createQualityProfileInUseSelector(id: number | undefined) {
return createSelector(
createAllSeriesSelector(),
(state: AppState) => state.settings.importLists.items,
(series, lists) => {
if (!id) {
return false;
}
return (
series.some((s) => s.qualityProfileId === id) ||
lists.some((list) => list.qualityProfileId === id)
);
}
);
}
export default createQualityProfileInUseSelector;

View file

@ -1,13 +1,13 @@
import Quality from 'Quality/Quality';
import { QualityProfileQualityItem } from 'typings/QualityProfile';
import { QualityProfileItems } from 'typings/QualityProfile';
export default function getQualities(qualities?: QualityProfileQualityItem[]) {
export default function getQualities(qualities?: QualityProfileItems) {
if (!qualities) {
return [];
}
return qualities.reduce<Quality[]>((acc, item) => {
if (item.quality) {
if ('quality' in item) {
acc.push(item.quality);
} else {
const groupQualities = item.items.reduce<Quality[]>((acc, i) => {

View file

@ -2,18 +2,30 @@ import Quality from 'Quality/Quality';
import { QualityProfileFormatItem } from './CustomFormat';
export interface QualityProfileQualityItem {
id?: number;
quality?: Quality;
quality: Quality;
allowed: boolean;
minSize: number | null;
maxSize: number | null;
preferredSize: number | null;
}
export interface QualityProfileGroup {
id: number;
items: QualityProfileQualityItem[];
allowed: boolean;
name?: string;
name: string;
}
export type QualityProfileItems = (
| QualityProfileQualityItem
| QualityProfileGroup
)[];
interface QualityProfile {
name: string;
upgradeAllowed: boolean;
cutoff: number;
items: QualityProfileQualityItem[];
items: QualityProfileItems;
minFormatScore: number;
cutoffFormatScore: number;
minUpgradeFormatScore: number;

View file

@ -2,5 +2,5 @@ import Column from 'Components/Table/Column';
export interface TableOptionsChangePayload {
pageSize?: number;
columns: Column[];
columns?: Column[];
}

View file

@ -49,15 +49,16 @@
"normalize.css": "8.0.1",
"prop-types": "15.8.1",
"qs": "6.13.0",
"rdndmb-html5-to-touch": "8.1.2",
"react": "18.3.1",
"react-addons-shallow-compare": "15.6.3",
"react-async-script": "1.2.0",
"react-autosuggest": "10.1.0",
"react-custom-scrollbars-2": "4.5.0",
"react-dnd": "14.0.4",
"react-dnd-html5-backend": "14.0.2",
"react-dnd-multi-backend": "6.0.2",
"react-dnd-touch-backend": "14.1.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dnd-multi-backend": "8.1.2",
"react-dnd-touch-backend": "16.0.1",
"react-document-title": "2.0.3",
"react-dom": "18.3.1",
"react-focus-lock": "2.9.4",

116
yarn.lock
View file

@ -1186,20 +1186,20 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@react-dnd/asap@^4.0.0":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.1.tgz#5291850a6b58ce6f2da25352a64f1b0674871aab"
integrity sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==
"@react-dnd/asap@^5.0.1":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
integrity sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==
"@react-dnd/invariant@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-2.0.0.tgz#09d2e81cd39e0e767d7da62df9325860f24e517e"
integrity sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==
"@react-dnd/invariant@^4.0.1":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-4.0.2.tgz#b92edffca10a26466643349fac7cdfb8799769df"
integrity sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==
"@react-dnd/shallowequal@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a"
integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
"@react-dnd/shallowequal@^4.0.1":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz#d1b4befa423f692fa4abf1c79209702e7d8ae4b4"
integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==
"@rtsao/scc@^1.1.0":
version "1.1.0"
@ -2768,19 +2768,19 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
dnd-core@14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-14.0.1.tgz#76d000e41c494983210fb20a48b835f81a203c2e"
integrity sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==
dnd-core@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19"
integrity sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==
dependencies:
"@react-dnd/asap" "^4.0.0"
"@react-dnd/invariant" "^2.0.0"
redux "^4.1.1"
"@react-dnd/asap" "^5.0.1"
"@react-dnd/invariant" "^4.0.1"
redux "^4.2.0"
dnd-multi-backend@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/dnd-multi-backend/-/dnd-multi-backend-6.0.0.tgz#4ed68229a3f6f1fb9e9bc45b4034e8330005280d"
integrity sha512-qfUO4V0IACs24xfE9m9OUnwIzoL+SWzSiFbKVIHE0pFddJeZ93BZOdHS1XEYr8X3HNh+CfnfjezXgOMgjvh74g==
dnd-multi-backend@^8.1.2:
version "8.1.2"
resolved "https://registry.yarnpkg.com/dnd-multi-backend/-/dnd-multi-backend-8.1.2.tgz#bf6a6ea9f6a9f5d58cabe12fd927753a753aeb92"
integrity sha512-KPDVEsiM+6gNEegqZYTWJQgJxYV4vB91tUrvoKJjaS0wwWqT/jNU0P7xJAwCue/cbasJNvk2dFZH7tC+bjX1Rg==
doctrine@^2.1.0:
version "2.1.0"
@ -5414,6 +5414,15 @@ raw-body@~1.1.0:
bytes "1"
string_decoder "0.10"
rdndmb-html5-to-touch@8.1.2:
version "8.1.2"
resolved "https://registry.yarnpkg.com/rdndmb-html5-to-touch/-/rdndmb-html5-to-touch-8.1.2.tgz#ab5379974e66e57624a95a79632248b2d32bc354"
integrity sha512-efi3MaXYxWaLMd5xzF1bVvmX8erTMhYHSlaMjQe+tynf4IdtgRYfKLwYg+4Z5eq4k7idrjKHQOIMDE6D8LjnOA==
dependencies:
dnd-multi-backend "^8.1.2"
react-dnd-html5-backend "^16.0.1"
react-dnd-touch-backend "^16.0.1"
react-addons-shallow-compare@15.6.3:
version "15.6.3"
resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.3.tgz#28a94b0dfee71530852c66a69053d59a1baf04cb"
@ -5456,45 +5465,42 @@ react-custom-scrollbars-2@4.5.0:
prop-types "^15.5.10"
raf "^3.1.0"
react-dnd-html5-backend@14.0.2:
version "14.0.2"
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz#25019388f6abdeeda3a6fea835dff155abb2085c"
integrity sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==
react-dnd-html5-backend@16.0.1, react-dnd-html5-backend@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz#87faef15845d512a23b3c08d29ecfd34871688b6"
integrity sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==
dependencies:
dnd-core "14.0.1"
dnd-core "^16.0.1"
react-dnd-multi-backend@6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/react-dnd-multi-backend/-/react-dnd-multi-backend-6.0.2.tgz#485878014dfbac46fcc898961871be6e5277c3f2"
integrity sha512-SwpqRv0HkJYu244FbHf9NbvGzGy14Ir9wIAhm909uvOVaHgsOq6I1THMSWSgpwUI31J3Bo5uS19tuvGpVPjzZw==
react-dnd-multi-backend@8.1.2:
version "8.1.2"
resolved "https://registry.yarnpkg.com/react-dnd-multi-backend/-/react-dnd-multi-backend-8.1.2.tgz#2be039e33d98d063d1f3d89d1a8ce1f487b900aa"
integrity sha512-Ecj+gwr5B7zRiWqkDU5sUvUmufcu97WnsZFHnqHrWFJhTXAXQnhrperHLFktNP2CnQYtAgbucodr1if0MWpEaA==
dependencies:
dnd-multi-backend "^6.0.0"
prop-types "^15.7.2"
react-dnd-preview "^6.0.2"
dnd-multi-backend "^8.1.2"
react-dnd-preview "^8.1.2"
react-dnd-preview@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/react-dnd-preview/-/react-dnd-preview-6.0.2.tgz#dd34931c270853c80438e1275e6c9e77174f8afe"
integrity sha512-F2+uK4Be+q+7mZfNh9kaZols7wp1hX6G7UBTVaTpDsBpMhjFvY7/v7odxYSerSFBShh23MJl33a4XOVRFj1zoQ==
dependencies:
prop-types "^15.7.2"
react-dnd-preview@^8.1.2:
version "8.1.2"
resolved "https://registry.yarnpkg.com/react-dnd-preview/-/react-dnd-preview-8.1.2.tgz#a679f62a7bdec30b167ed5a10c7f7ed58095b167"
integrity sha512-j5M1NcQBItOCYXONRbCNs6MzW7u4KygeOGZlztNNguTs1/f2d7q1fRnQjFLjCpgeg5Gy/JrTFrbRThZglJP5dg==
react-dnd-touch-backend@14.1.1:
version "14.1.1"
resolved "https://registry.yarnpkg.com/react-dnd-touch-backend/-/react-dnd-touch-backend-14.1.1.tgz#d8875ef1cf8dcbf1741a4e03dd5b147c4fbda5e4"
integrity sha512-ITmfzn3fJrkUBiVLO6aJZcnu7T8C+GfwZitFryGsXKn5wYcUv+oQBeh9FYcMychmVbDdeUCfvEtTk9O+DKmAaw==
react-dnd-touch-backend@16.0.1, react-dnd-touch-backend@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz#e73f8169e2b9fac0f687970f875cac0a4d02d6e2"
integrity sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==
dependencies:
"@react-dnd/invariant" "^2.0.0"
dnd-core "14.0.1"
"@react-dnd/invariant" "^4.0.1"
dnd-core "^16.0.1"
react-dnd@14.0.4:
version "14.0.4"
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-14.0.4.tgz#ffb4ea0e2a3a5532f9c6294d565742008a52b8b0"
integrity sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==
react-dnd@16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-16.0.1.tgz#2442a3ec67892c60d40a1559eef45498ba26fa37"
integrity sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==
dependencies:
"@react-dnd/invariant" "^2.0.0"
"@react-dnd/shallowequal" "^2.0.0"
dnd-core "14.0.1"
"@react-dnd/invariant" "^4.0.1"
"@react-dnd/shallowequal" "^4.0.1"
dnd-core "^16.0.1"
fast-deep-equal "^3.1.3"
hoist-non-react-statics "^3.3.2"
@ -5787,7 +5793,7 @@ redux-thunk@2.4.2:
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.2.tgz#b9d05d11994b99f7a91ea223e8b04cf0afa5ef3b"
integrity sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==
redux@4.2.1, redux@^4.0.0, redux@^4.1.1:
redux@4.2.1, redux@^4.0.0, redux@^4.2.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==