From 1bc1b080d1c45b260a8dafa2139d69c8fefe69f9 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 29 Dec 2024 18:21:01 -0800 Subject: [PATCH] Upgrade react-dnd and DnD Components to TypeScript --- frontend/src/App/State/SettingsAppState.ts | 8 +- frontend/src/Components/DragPreviewLayer.css | 9 - .../src/Components/DragPreviewLayer.css.d.ts | 7 - frontend/src/Components/DragPreviewLayer.tsx | 21 - frontend/src/Components/Modal/ModalFooter.tsx | 23 +- frontend/src/Components/Modal/ModalHeader.tsx | 23 +- .../Table/TableOptions/TableOptionsColumn.css | 19 +- .../TableOptions/TableOptionsColumn.css.d.ts | 5 +- .../Table/TableOptions/TableOptionsColumn.js | 68 -- .../Table/TableOptions/TableOptionsColumn.tsx | 163 ++++ .../TableOptionsColumnDragPreview.css | 4 - .../TableOptionsColumnDragPreview.css.d.ts | 7 - .../TableOptionsColumnDragPreview.js | 78 -- .../TableOptionsColumnDragSource.css | 18 - .../TableOptionsColumnDragSource.css.d.ts | 10 - .../TableOptionsColumnDragSource.js | 164 ---- .../Table/TableOptions/TableOptionsModal.js | 263 ------ .../Table/TableOptions/TableOptionsModal.tsx | 216 +++++ .../src/Episode/Summary/EpisodeSummary.tsx | 2 +- frontend/src/Helpers/DragType.ts | 7 + frontend/src/Helpers/dragTypes.js | 3 - frontend/src/Quality/Quality.ts | 3 + frontend/src/Series/Details/SeriesDetails.js | 2 +- .../Settings/Profiles/Delay/DelayProfile.css | 14 + .../Profiles/Delay/DelayProfile.css.d.ts | 3 + .../Settings/Profiles/Delay/DelayProfile.js | 173 ---- .../Settings/Profiles/Delay/DelayProfile.tsx | 240 ++++++ .../Delay/DelayProfileDragPreview.css | 3 - .../Delay/DelayProfileDragPreview.css.d.ts | 7 - .../Profiles/Delay/DelayProfileDragPreview.js | 78 -- .../Profiles/Delay/DelayProfileDragSource.css | 17 - .../Delay/DelayProfileDragSource.css.d.ts | 10 - .../Profiles/Delay/DelayProfileDragSource.js | 148 ---- .../Settings/Profiles/Delay/DelayProfiles.js | 169 ---- .../Settings/Profiles/Delay/DelayProfiles.tsx | 186 +++++ .../Profiles/Delay/DelayProfilesConnector.js | 105 --- .../Profiles/Delay/EditDelayProfileModal.js | 27 - .../Profiles/Delay/EditDelayProfileModal.tsx | 37 + .../Delay/EditDelayProfileModalConnector.js | 43 - .../Delay/EditDelayProfileModalContent.js | 266 ------ .../Delay/EditDelayProfileModalContent.tsx | 370 +++++++++ .../EditDelayProfileModalContentConnector.js | 172 ---- frontend/src/Settings/Profiles/Profiles.js | 37 - frontend/src/Settings/Profiles/Profiles.tsx | 31 + .../Quality/EditQualityProfileModal.js | 61 -- .../Quality/EditQualityProfileModal.tsx | 55 ++ .../EditQualityProfileModalConnector.js | 43 - .../Quality/EditQualityProfileModalContent.js | 363 --------- .../EditQualityProfileModalContent.tsx | 763 ++++++++++++++++++ ...EditQualityProfileModalContentConnector.js | 532 ------------ .../Profiles/Quality/QualityProfile.js | 187 ----- .../Profiles/Quality/QualityProfile.tsx | 165 ++++ .../Quality/QualityProfileFormatItem.js | 68 -- .../Quality/QualityProfileFormatItem.tsx | 45 ++ .../Quality/QualityProfileFormatItems.js | 158 ---- .../Quality/QualityProfileFormatItems.tsx | 113 +++ .../Profiles/Quality/QualityProfileItem.js | 162 ---- .../Profiles/Quality/QualityProfileItem.tsx | 129 +++ .../Quality/QualityProfileItemDragPreview.css | 4 - .../QualityProfileItemDragPreview.css.d.ts | 7 - .../Quality/QualityProfileItemDragPreview.js | 92 --- .../Quality/QualityProfileItemDragSource.css | 4 +- .../Quality/QualityProfileItemDragSource.js | 254 ------ .../Quality/QualityProfileItemDragSource.tsx | 251 ++++++ .../Quality/QualityProfileItemGroup.js | 228 ------ .../Quality/QualityProfileItemGroup.tsx | 194 +++++ .../Quality/QualityProfileItemSize.tsx | 46 +- .../Profiles/Quality/QualityProfileItems.js | 204 ----- .../Profiles/Quality/QualityProfileItems.tsx | 209 +++++ .../Profiles/Quality/QualityProfileName.tsx | 18 + .../Quality/QualityProfileNameConnector.js | 31 - .../Profiles/Quality/QualityProfiles.js | 107 --- .../Profiles/Quality/QualityProfiles.tsx | 93 +++ .../Quality/QualityProfilesConnector.js | 63 -- .../Definition/QualityDefinitionLimits.tsx | 2 +- .../Quality/Definition/QualityDefinitions.tsx | 25 +- frontend/src/Settings/Quality/Quality.js | 105 --- .../Selectors/createProfileInUseSelector.ts | 25 - .../createProviderSettingsSelector.ts | 52 +- .../createQualityProfileInUseSelector.ts | 22 + .../src/Utilities/Quality/getQualities.ts | 6 +- frontend/src/typings/QualityProfile.ts | 20 +- frontend/src/typings/Table.ts | 2 +- package.json | 9 +- yarn.lock | 116 +-- 85 files changed, 3525 insertions(+), 4767 deletions(-) delete mode 100644 frontend/src/Components/DragPreviewLayer.css delete mode 100644 frontend/src/Components/DragPreviewLayer.css.d.ts delete mode 100644 frontend/src/Components/DragPreviewLayer.tsx delete mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumn.js create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumn.tsx delete mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css delete mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css.d.ts delete mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js delete mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css delete mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css.d.ts delete mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js delete mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsModal.js create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx create mode 100644 frontend/src/Helpers/DragType.ts delete mode 100644 frontend/src/Helpers/dragTypes.js delete mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfile.js create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfile.tsx delete mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css delete mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css.d.ts delete mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js delete mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css delete mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css.d.ts delete mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js delete mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfiles.js create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx delete mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js delete mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js create mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.tsx delete mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js delete mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js create mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.tsx delete mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js delete mode 100644 frontend/src/Settings/Profiles/Profiles.js create mode 100644 frontend/src/Settings/Profiles/Profiles.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js create mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js delete mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js create mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfile.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfile.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItem.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css.d.ts delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItems.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItems.tsx create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileName.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfiles.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfiles.tsx delete mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js delete mode 100644 frontend/src/Settings/Quality/Quality.js delete mode 100644 frontend/src/Store/Selectors/createProfileInUseSelector.ts create mode 100644 frontend/src/Store/Selectors/createQualityProfileInUseSelector.ts diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index e5af53911..ab046fba9 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -39,7 +39,7 @@ export interface AutoTaggingSpecificationAppState AppSectionSchemaState {} export interface DelayProfileAppState - extends AppSectionState, + extends AppSectionListState, AppSectionDeleteState, AppSectionSaveState {} @@ -77,7 +77,7 @@ export interface NotificationAppState AppSectionDeleteState {} export interface QualityDefinitionsAppState - extends AppSectionState, + extends AppSectionState, AppSectionSaveState { pendingChanges: { [key: number]: Partial; @@ -86,7 +86,9 @@ export interface QualityDefinitionsAppState export interface QualityProfilesAppState extends AppSectionState, - AppSectionItemSchemaState {} + AppSectionItemSchemaState, + AppSectionDeleteState, + AppSectionSaveState {} export interface ReleaseProfilesAppState extends AppSectionState, diff --git a/frontend/src/Components/DragPreviewLayer.css b/frontend/src/Components/DragPreviewLayer.css deleted file mode 100644 index 46f721fef..000000000 --- a/frontend/src/Components/DragPreviewLayer.css +++ /dev/null @@ -1,9 +0,0 @@ -.dragLayer { - position: fixed; - top: 0; - left: 0; - z-index: 9999; - width: 100%; - height: 100%; - pointer-events: none; -} diff --git a/frontend/src/Components/DragPreviewLayer.css.d.ts b/frontend/src/Components/DragPreviewLayer.css.d.ts deleted file mode 100644 index 6944a829d..000000000 --- a/frontend/src/Components/DragPreviewLayer.css.d.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Components/DragPreviewLayer.tsx b/frontend/src/Components/DragPreviewLayer.tsx deleted file mode 100644 index 2e578504b..000000000 --- a/frontend/src/Components/DragPreviewLayer.tsx +++ /dev/null @@ -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 ( -
- {children} -
- ); -} - -export default DragPreviewLayer; diff --git a/frontend/src/Components/Modal/ModalFooter.tsx b/frontend/src/Components/Modal/ModalFooter.tsx index 801d51bc9..14dd5ebe0 100644 --- a/frontend/src/Components/Modal/ModalFooter.tsx +++ b/frontend/src/Components/Modal/ModalFooter.tsx @@ -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 { - children?: React.ReactNode; + children: ReactNode; } -function ModalFooter({ children, ...otherProps }: ModalFooterProps) { - return ( -
- {children} -
- ); -} +const ModalFooter = forwardRef( + ( + { children, ...otherProps }: ModalFooterProps, + ref: ForwardedRef + ) => { + return ( +
+ {children} +
+ ); + } +); export default ModalFooter; diff --git a/frontend/src/Components/Modal/ModalHeader.tsx b/frontend/src/Components/Modal/ModalHeader.tsx index 5e7f64ba5..86f2c9ac1 100644 --- a/frontend/src/Components/Modal/ModalHeader.tsx +++ b/frontend/src/Components/Modal/ModalHeader.tsx @@ -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 { - children?: React.ReactNode; + children: ReactNode; } -function ModalHeader({ children, ...otherProps }: ModalHeaderProps) { - return ( -
- {children} -
- ); -} +const ModalHeader = forwardRef( + ( + { children, ...otherProps }: ModalHeaderProps, + ref: ForwardedRef + ) => { + return ( +
+ {children} +
+ ); + } +); export default ModalHeader; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css index ef3d9b062..3292ffe3f 100644 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css @@ -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; } diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css.d.ts b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css.d.ts index d17f47db6..7bdf6ee05 100644 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css.d.ts +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css.d.ts @@ -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; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js deleted file mode 100644 index 402ef5ae1..000000000 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js +++ /dev/null @@ -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 ( -
-
- - - { - !!connectDragSource && - connectDragSource( -
- -
- ) - } -
-
- ); -} - -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; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.tsx b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.tsx new file mode 100644 index 000000000..f6a7ec244 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.tsx @@ -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(null); + + const [{ isOver }, dropRef] = useDrop({ + 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) => ({ + isDragging: monitor.isDragging(), + }), + end: (_item: DragItem, monitor) => { + onColumnDragEnd(monitor.didDrop()); + }, + }); + + dropRef(previewRef(ref)); + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + return ( +
+ {isBefore ? ( +
+ ) : null} + +
+ + + {isModifiable ? ( +
+ +
+ ) : null} +
+ + {isAfter ? ( +
+ ) : null} +
+ ); +} + +export default TableOptionsColumn; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css deleted file mode 100644 index b927d9bce..000000000 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css +++ /dev/null @@ -1,4 +0,0 @@ -.dragPreview { - width: 380px; - opacity: 0.75; -} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css.d.ts b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css.d.ts deleted file mode 100644 index 1f1f3c320..000000000 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css.d.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js deleted file mode 100644 index f30822022..000000000 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js +++ /dev/null @@ -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 ( - -
- -
-
- ); - } -} - -TableOptionsColumnDragPreview.propTypes = { - item: PropTypes.object, - itemType: PropTypes.string, - currentOffset: PropTypes.shape({ - x: PropTypes.number.isRequired, - y: PropTypes.number.isRequired - }) -}; - -export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview); diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css deleted file mode 100644 index 9354a35c0..000000000 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css +++ /dev/null @@ -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; -} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css.d.ts b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css.d.ts deleted file mode 100644 index 0c3826e8f..000000000 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css.d.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js deleted file mode 100644 index 77d18463f..000000000 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js +++ /dev/null @@ -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( -
- { - isBefore && -
- } - - - - { - isAfter && -
- } -
- ); - } -} - -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)); diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js deleted file mode 100644 index ab5048717..000000000 --- a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js +++ /dev/null @@ -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 ( - - - { - isOpen ? - - - {translate('TableOptions')} - - - -
- { - hasPageSize ? - - {translate('TablePageSize')} - - - : - null - } - - { - OptionsComponent ? - : null - } - - { - canModifyColumns ? - - {translate('TableColumns')} - -
- - -
- { - columns.map((column, index) => { - const { - name, - label, - columnLabel, - isVisible, - isModifiable - } = column; - - if (isModifiable !== false) { - return ( - - ); - } - - return ( - - ); - }) - } - - -
-
-
: - null - } - -
- - - -
: - null - } -
-
- ); - } -} - -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; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx b/frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx new file mode 100644 index 000000000..7812371fe --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx @@ -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(null); + const [dragIndex, setDragIndex] = useState(null); + const [dropIndex, setDropIndex] = useState(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) => { + 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 ( + + + {isOpen ? ( + + {translate('TableOptions')} + + +
+ {hasPageSize ? ( + + {translate('TablePageSize')} + + + + ) : null} + + {OptionsComponent ? ( + + ) : null} + + {canModifyColumns ? ( + + {translate('TableColumns')} + +
+ + +
+ {columns.map((column, index) => { + const { + name, + label, + columnLabel, + isVisible, + isModifiable = true, + } = column; + + return ( + + ); + })} +
+
+
+ ) : null} + +
+ + + +
+ ) : null} +
+
+ ); +} + +TableOptionsModal.defaultProps = { + canModifyColumns: true, +}; + +export default TableOptionsModal; diff --git a/frontend/src/Episode/Summary/EpisodeSummary.tsx b/frontend/src/Episode/Summary/EpisodeSummary.tsx index 75d2993d5..3b9909764 100644 --- a/frontend/src/Episode/Summary/EpisodeSummary.tsx +++ b/frontend/src/Episode/Summary/EpisodeSummary.tsx @@ -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, diff --git a/frontend/src/Helpers/DragType.ts b/frontend/src/Helpers/DragType.ts new file mode 100644 index 000000000..8a2b63262 --- /dev/null +++ b/frontend/src/Helpers/DragType.ts @@ -0,0 +1,7 @@ +enum DragType { + DelayProfile = 'delayProfile', + QualityProfileItem = 'qualityProfileItem', + TableColumn = 'tableColumn', +} + +export default DragType; diff --git a/frontend/src/Helpers/dragTypes.js b/frontend/src/Helpers/dragTypes.js deleted file mode 100644 index ed6ba080d..000000000 --- a/frontend/src/Helpers/dragTypes.js +++ /dev/null @@ -1,3 +0,0 @@ -export const QUALITY_PROFILE_ITEM = 'qualityProfileItem'; -export const DELAY_PROFILE = 'delayProfile'; -export const TABLE_COLUMN = 'tableColumn'; diff --git a/frontend/src/Quality/Quality.ts b/frontend/src/Quality/Quality.ts index ca6a40f00..483ad8c3d 100644 --- a/frontend/src/Quality/Quality.ts +++ b/frontend/src/Quality/Quality.ts @@ -20,6 +20,9 @@ interface Quality { name: string; resolution: number; source: QualitySource; + minSize: number | null; + maxSize: number | null; + preferredSize: number | null; } export interface QualityModel { diff --git a/frontend/src/Series/Details/SeriesDetails.js b/frontend/src/Series/Details/SeriesDetails.js index c30e55900..424d698b1 100644 --- a/frontend/src/Series/Details/SeriesDetails.js +++ b/frontend/src/Series/Details/SeriesDetails.js @@ -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'; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css b/frontend/src/Settings/Profiles/Delay/DelayProfile.css index e2d6cd199..2b8dbae92 100644 --- a/frontend/src/Settings/Profiles/Delay/DelayProfile.css +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css @@ -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; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css.d.ts b/frontend/src/Settings/Profiles/Delay/DelayProfile.css.d.ts index 4ec05c2d6..a0904f4a2 100644 --- a/frontend/src/Settings/Profiles/Delay/DelayProfile.css.d.ts +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css.d.ts @@ -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; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.js b/frontend/src/Settings/Profiles/Delay/DelayProfile.js deleted file mode 100644 index 24ed6a29a..000000000 --- a/frontend/src/Settings/Profiles/Delay/DelayProfile.js +++ /dev/null @@ -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 ( -
-
{preferred}
-
{getDelay(enableUsenet, usenetDelay)}
-
{getDelay(enableTorrent, torrentDelay)}
- - - -
- - - - - { - id !== 1 && - connectDragSource( -
- -
- ) - } -
- - - - -
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.tsx b/frontend/src/Settings/Profiles/Delay/DelayProfile.tsx new file mode 100644 index 000000000..878ed530f --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.tsx @@ -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(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({ + 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) => ({ + 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 ( +
+ {isBefore ? ( +
+ ) : null} + +
+
{preferred}
+
+ {getDelay(enableUsenet, usenetDelay)} +
+
+ {getDelay(enableTorrent, torrentDelay)} +
+ + + +
+ + + + + {id === 1 ? null : ( +
+ +
+ )} +
+
+ + {isAfter ? ( +
+ ) : null} + + + + +
+ ); +} + +export default DelayProfile; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css deleted file mode 100644 index cc5a92830..000000000 --- a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css +++ /dev/null @@ -1,3 +0,0 @@ -.dragPreview { - opacity: 0.75; -} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css.d.ts b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css.d.ts deleted file mode 100644 index 1f1f3c320..000000000 --- a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css.d.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js deleted file mode 100644 index 1ebb32a95..000000000 --- a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js +++ /dev/null @@ -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 ( - -
- -
-
- ); - } -} - -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); diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css deleted file mode 100644 index 835250678..000000000 --- a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css +++ /dev/null @@ -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; -} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css.d.ts b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css.d.ts deleted file mode 100644 index 0554ea7e8..000000000 --- a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css.d.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js deleted file mode 100644 index 8bf739ceb..000000000 --- a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js +++ /dev/null @@ -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( -
- { - isBefore && -
- } - - - - { - isAfter && -
- } -
- ); - } -} - -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)); diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js deleted file mode 100644 index 1a872c2fd..000000000 --- a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js +++ /dev/null @@ -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 ( - -
- - -
-
-
- {translate('PreferredProtocol')} -
-
- {translate('UsenetDelay')} -
-
- {translate('TorrentDelay')} -
-
- {translate('Tags')} -
-
- -
- { - items.map((item, index) => { - return ( - - ); - }) - } - - -
- - { - defaultProfile ? -
- -
: - null - } -
-
- -
- - - -
- - -
-
-
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx b/frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx new file mode 100644 index 000000000..f70877321 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx @@ -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(null); + const [dropIndex, setDropIndex] = useState(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 ( +
+ + +
+
+
+ {translate('PreferredProtocol')} +
+
{translate('UsenetDelay')}
+
{translate('TorrentDelay')}
+
{translate('Tags')}
+
+ +
+ {items.map((item) => { + return ( + + ); + })} +
+ + {defaultProfile ? ( +
+ +
+ ) : null} +
+
+ +
+ + + +
+ + +
+
+ ); +} + +export default DelayProfiles; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js deleted file mode 100644 index b2f822a5a..000000000 --- a/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js +++ /dev/null @@ -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 ( - - ); - } -} - -DelayProfilesConnector.propTypes = { - fetchDelayProfiles: PropTypes.func.isRequired, - deleteDelayProfile: PropTypes.func.isRequired, - reorderDelayProfile: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js deleted file mode 100644 index ddcd8cf7f..000000000 --- a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js +++ /dev/null @@ -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 ( - - - - ); -} - -EditDelayProfileModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default EditDelayProfileModal; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.tsx b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.tsx new file mode 100644 index 000000000..ca3a71cd9 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.tsx @@ -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 ( + + + + ); +} + +export default EditDelayProfileModal; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js deleted file mode 100644 index 5eb8ce871..000000000 --- a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js +++ /dev/null @@ -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 ( - - ); - } -} - -EditDelayProfileModalConnector.propTypes = { - onModalClose: PropTypes.func.isRequired, - clearPendingChanges: PropTypes.func.isRequired -}; - -export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js deleted file mode 100644 index e2799e581..000000000 --- a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js +++ /dev/null @@ -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 ( - - - {id ? translate('EditDelayProfile') : translate('AddDelayProfile')} - - - - { - isFetching ? - : - null - } - - { - !isFetching && !!error ? - - {translate('AddDelayProfileError')} - : - null - } - - { - !isFetching && !error ? -
- - {translate('PreferredProtocol')} - - - - - { - enableUsenet.value ? - - {translate('UsenetDelay')} - - - : - null - } - - { - enableTorrent.value ? - - {translate('TorrentDelay')} - - - : - null - } - - - {translate('BypassDelayIfHighestQuality')} - - - - - - {translate('BypassDelayIfAboveCustomFormatScore')} - - - - - { - bypassIfAboveCustomFormatScore.value ? - - {translate('BypassDelayIfAboveCustomFormatScoreMinimumScore')} - - - : - null - } - - { - id === 1 ? - - {translate('DefaultDelayProfileSeries')} - : - - - {translate('Tags')} - - - - } -
: - null - } -
- - { - id && id > 1 ? - : - null - } - - - - - {translate('Save')} - - -
- ); -} - -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; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.tsx b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.tsx new file mode 100644 index 000000000..3f562d53b --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.tsx @@ -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 = { + 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 ( + + + {id ? translate('EditDelayProfile') : translate('AddDelayProfile')} + + + + {isFetching ? : null} + + {!isFetching && !!error ? ( + {translate('AddDelayProfileError')} + ) : null} + + {!isFetching && !error ? ( +
+ + {translate('PreferredProtocol')} + + + + + {enableUsenet.value ? ( + + {translate('UsenetDelay')} + + + + ) : null} + + {enableTorrent.value ? ( + + {translate('TorrentDelay')} + + + + ) : null} + + + {translate('BypassDelayIfHighestQuality')} + + + + + + + {translate('BypassDelayIfAboveCustomFormatScore')} + + + + + + {bypassIfAboveCustomFormatScore.value ? ( + + + {translate('BypassDelayIfAboveCustomFormatScoreMinimumScore')} + + + + + ) : null} + + {id === 1 ? ( + {translate('DefaultDelayProfileSeries')} + ) : ( + + {translate('Tags')} + + + + )} +
+ ) : null} +
+ + + {id && id > 1 ? ( + + ) : null} + + + + + {translate('Save')} + + +
+ ); +} + +export default EditDelayProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js deleted file mode 100644 index 3643bb158..000000000 --- a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js +++ /dev/null @@ -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 ( - - ); - } -} - -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); diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js deleted file mode 100644 index 3452530eb..000000000 --- a/frontend/src/Settings/Profiles/Profiles.js +++ /dev/null @@ -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 ( - - - - - - - - - - - - ); - } -} - -export default Profiles; diff --git a/frontend/src/Settings/Profiles/Profiles.tsx b/frontend/src/Settings/Profiles/Profiles.tsx new file mode 100644 index 000000000..df3844885 --- /dev/null +++ b/frontend/src/Settings/Profiles/Profiles.tsx @@ -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 ( + + + + + + + + + + + + ); +} + +export default Profiles; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js deleted file mode 100644 index 4b980c67c..000000000 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js +++ /dev/null @@ -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 ( - - - - ); - } -} - -EditQualityProfileModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default EditQualityProfileModal; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.tsx b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.tsx new file mode 100644 index 000000000..7f5a4ee0b --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.tsx @@ -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 ( + + + + ); +} + +export default EditQualityProfileModal; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js deleted file mode 100644 index 5d7f48d29..000000000 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js +++ /dev/null @@ -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 ( - - ); - } -} - -EditQualityProfileModalConnector.propTypes = { - onModalClose: PropTypes.func.isRequired, - clearPendingChanges: PropTypes.func.isRequired -}; - -export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js deleted file mode 100644 index e318f669b..000000000 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js +++ /dev/null @@ -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 ( - - ); -} - -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 ( - - - - {id ? translate('EditQualityProfile') : translate('AddQualityProfile')} - - - - - -
- { - isFetching && - - } - - { - !isFetching && !!error && - - {translate('AddQualityProfileError')} - - } - - { - !isFetching && !error && -
-
-
- - - {translate('Name')} - - - - - - - - {translate('UpgradesAllowed')} - - - - - - { - upgradeAllowed.value && - - - {translate('UpgradeUntil')} - - - - - } - - { - formatItems.value.length > 0 && - - - {translate('MinimumCustomFormatScore')} - - - - - } - - { - upgradeAllowed.value && formatItems.value.length > 0 && - - - {translate('UpgradeUntilCustomFormatScore')} - - - - - } - - { - upgradeAllowed.value && formatItems.value.length > 0 ? - - - {translate('MinimumCustomFormatScoreIncrement')} - - - - : - null - } - -
- {getCustomFormatRender(formatItems, otherProps)} -
-
- -
- -
- -
- {getCustomFormatRender(formatItems, otherProps)} -
-
-
- - } -
-
-
- - - - { - id ? -
- -
: - null - } - - - - - {translate('Save')} - -
-
-
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx new file mode 100644 index 000000000..fa6ef0b87 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx @@ -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('default'); + const [defaultBodyHeight, setDefaultBodyHeight] = useState(0); + const [editGroupsBodyHeight, setEditGroupsBodyHeight] = useState(0); + const [editSizesBodyHeight, setEditSizesBodyHeight] = useState(0); + const [dndState, setDndState] = useState({ + 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) => { + 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( + (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 ( + + + {id ? translate('EditQualityProfile') : translate('AddQualityProfile')} + + + +
+ {isPopulated ? null : } + + {!isFetching && error ? ( + + {translate('AddQualityProfileError')} + + ) : null} + + {isPopulated && !error ? ( +
+
+
+ + + {translate('Name')} + + + + + + + + {translate('UpgradesAllowed')} + + + + + + {upgradeAllowed.value ? ( + + + {translate('UpgradeUntil')} + + + + + ) : null} + + {formatItems.value.length > 0 ? ( + + + {translate('MinimumCustomFormatScore')} + + + + + ) : null} + + {upgradeAllowed.value && formatItems.value.length > 0 ? ( + + + {translate('UpgradeUntilCustomFormatScore')} + + + + + ) : null} + + {upgradeAllowed.value && formatItems.value.length > 0 ? ( + + + {translate('MinimumCustomFormatScoreIncrement')} + + + + + ) : null} + +
+ +
+
+ +
+ +
+ +
+ +
+
+
+ ) : null} +
+
+ + + {id ? ( +
+ +
+ ) : null} + + + + + {translate('Save')} + +
+
+ ); +} + +export default EditQualityProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js deleted file mode 100644 index 4790a7229..000000000 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js +++ /dev/null @@ -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 ( - - ); - } -} - -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); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js deleted file mode 100644 index 55f0dbe75..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfile.js +++ /dev/null @@ -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 ( - -
-
- {name} -
- - -
- -
- { - items.map((item) => { - if (!item.allowed) { - return null; - } - - if (item.quality) { - const isCutoff = upgradeAllowed && item.quality.id === cutoff; - - return ( - - ); - } - - const isCutoff = upgradeAllowed && item.id === cutoff; - - return ( - - {item.name} - - } - tooltip={ -
- { - item.items.map((groupItem) => { - return ( - - ); - }) - } -
- } - kind={kinds.INVERSE} - position={tooltipPositions.TOP} - /> - ); - }) - } -
- - - - -
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfile.tsx new file mode 100644 index 000000000..75d6d0634 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.tsx @@ -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 ( + +
+
{name}
+ + +
+ +
+ {items.map((item) => { + if (!item.allowed) { + return null; + } + + if ('quality' in item) { + const isCutoff = upgradeAllowed && item.quality.id === cutoff; + + return ( + + ); + } + + const isCutoff = upgradeAllowed && item.id === cutoff; + + return ( + + {item.name} + + } + tooltip={ +
+ {item.items.map((groupItem) => { + return ( + + ); + })} +
+ } + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> + ); + })} +
+ + + + +
+ ); +} + +export default QualityProfile; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js deleted file mode 100644 index 5ef4add2d..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js +++ /dev/null @@ -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 ( -
-
- - -
-
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.tsx new file mode 100644 index 000000000..5ffac47d6 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.tsx @@ -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) => { + onScoreChange(formatId, value); + }, + [formatId, onScoreChange] + ); + + return ( +
+
+ +
+
+ ); +} + +export default QualityProfileFormatItem; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js deleted file mode 100644 index 3a204665b..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js +++ /dev/null @@ -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 ( - - ); - } - - return ( - - - {translate('CustomFormats')} - - -
- - - { - errors.map((error, index) => { - return ( - - ); - }) - } - - { - warnings.map((warning, index) => { - return ( - - ); - }) - } - -
-
-
- {translate('CustomFormat')} -
-
- {translate('Score')} -
-
- { - order.map((index) => { - const { - format, - name, - score - } = profileFormatItems[index]; - return ( - - ); - }) - } -
-
-
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.tsx new file mode 100644 index 000000000..9938049fb --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.tsx @@ -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>( + (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 ( + + ); + } + + return ( + + {translate('CustomFormats')} + +
+ + + {errors.map((error, index) => { + return ( + + ); + })} + + {warnings.map((warning, index) => { + return ( + + ); + })} + +
+
+
+ {translate('CustomFormat')} +
+
{translate('Score')}
+
+ + {order.map((index) => { + const { format, name, score } = profileFormatItems[index]; + return ( + + ); + })} +
+
+
+ ); +} + +export default QualityProfileFormatItems; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js deleted file mode 100644 index ac05a9f23..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js +++ /dev/null @@ -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 ( -
- - - { - mode === 'editSizes' && qualityId != null ? -
- -
: - null - } - - { - mode === 'editSizes' ? null : - connectDragSource( -
- -
- ) - } -
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx new file mode 100644 index 000000000..09a363ded --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx @@ -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) => { + onItemAllowedChange?.(qualityId, value); + }, + [qualityId, onItemAllowedChange] + ); + + const handleCreateGroupPress = useCallback(() => { + onCreateGroupPress?.(qualityId); + }, [qualityId, onCreateGroupPress]); + + return ( +
+ + + {mode === 'editSizes' && qualityId != null ? ( +
+ +
+ ) : null} + + {mode === 'editSizes' ? null : ( +
+ +
+ )} +
+ ); +} + +export default QualityProfileItem; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css deleted file mode 100644 index b927d9bce..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css +++ /dev/null @@ -1,4 +0,0 @@ -.dragPreview { - width: 380px; - opacity: 0.75; -} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css.d.ts deleted file mode 100644 index 1f1f3c320..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css.d.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js deleted file mode 100644 index 31290baa9..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js +++ /dev/null @@ -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 ( - -
- -
-
- ); - } -} - -QualityProfileItemDragPreview.propTypes = { - item: PropTypes.object, - itemType: PropTypes.string, - currentOffset: PropTypes.shape({ - x: PropTypes.number.isRequired, - y: PropTypes.number.isRequired - }) -}; - -export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css index d5061cc95..777655187 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css @@ -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; } diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js deleted file mode 100644 index 4318e3489..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js +++ /dev/null @@ -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( -
- { - isBefore && -
- } - - { - !!groupId && qualityId == null && - - } - - { - qualityId != null && - - } - - { - isAfter && -
- } -
- ); - } -} - -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)); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.tsx new file mode 100644 index 000000000..e7a0483dc --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.tsx @@ -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(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) => ({ + isDragging: monitor.isDragging(), + }), + end: (_item: DragItem, monitor) => { + onDragEnd(monitor.didDrop()); + }, + }); + + dropRef(previewRef(ref)); + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + return ( +
+ {isBefore ? ( +
+ ) : null} + +
+ {'items' in otherProps && groupId ? ( + + ) : null} + + {!('items' in otherProps) && qualityId ? ( + + ) : null} +
+ + {isAfter ? ( +
+ ) : null} +
+ ); +} + +export default QualityProfileItemDragSource; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js deleted file mode 100644 index 2a307ee3e..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js +++ /dev/null @@ -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 ( -
-
- { - mode === 'editGroups' && -
- - - -
- } - - { - mode === 'default' && - - } - - { - mode === 'editSizes' && - - } - - { - mode === 'editSizes' ? null : - connectDragSource( -
- -
- ) - } -
- - { - mode === 'default' ? - null : -
- { - items.map(({ quality }, index) => { - return ( - - ); - }).reverse() - } -
- } -
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.tsx new file mode 100644 index 000000000..1db6f0599 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.tsx @@ -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) => { + onGroupAllowedChange?.(groupId, value); + }, + [groupId, onGroupAllowedChange] + ); + + const handleNameChange = useCallback( + ({ value }: InputChanged) => { + onItemGroupNameChange?.(groupId, value); + }, + [groupId, onItemGroupNameChange] + ); + + const handleDeleteGroupPress = useCallback(() => { + onDeleteGroupPress?.(groupId); + }, [groupId, onDeleteGroupPress]); + + return ( +
+
+ {mode === 'editGroups' ? ( +
+ + + +
+ ) : null} + + {mode === 'default' ? ( + + ) : null} + + {mode === 'editSizes' ? ( + + ) : null} + + {mode === 'editSizes' ? null : ( +
+ +
+ )} +
+ + {mode === 'default' ? null : ( +
+ {items + .map(({ quality }, index) => { + return ( + + ); + }) + .reverse()} +
+ )} +
+ ); +} + +export default QualityProfileItemGroup; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemSize.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileItemSize.tsx index f14350fa3..10c88c96b 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItemSize.tsx +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemSize.tsx @@ -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) { @@ -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({ 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, diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js deleted file mode 100644 index e2b3ec71f..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js +++ /dev/null @@ -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 ( - - - {translate('Qualities')} - - -
- - - { - errors.map((error, index) => { - return ( - - ); - }) - } - - { - warnings.map((warning, index) => { - return ( - - ); - }) - } - - - - - - -
- { - qualityProfileItems.map(({ id, name, allowed, quality, items, minSize, maxSize, preferredSize }, index) => { - const identifier = quality ? quality.id : id; - - return ( - - ); - }).reverse() - } - - -
-
-
-
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.tsx new file mode 100644 index 000000000..42598f119 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.tsx @@ -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 ( + + {translate('Qualities')} + +
+ + + {errors.map((error, index) => { + return ( + + ); + })} + + {warnings.map((warning, index) => { + return ( + + ); + })} + + + + + +
+ {qualityProfileItems + .map((item, index) => { + if ('quality' in item) { + const { quality, allowed, minSize, maxSize, preferredSize } = + item; + + return ( + + ); + } + + const { id, name, allowed, items } = item; + + return ( + + ); + }) + .reverse()} +
+
+
+ ); +} + +export default QualityProfileItems; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileName.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileName.tsx new file mode 100644 index 000000000..bbb3e6f08 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileName.tsx @@ -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 {qualityProfile?.name ?? translate('Unknown')}; +} + +export default QualityProfileName; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js deleted file mode 100644 index bf13815ff..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js +++ /dev/null @@ -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 ( - - {name} - - ); -} - -QualityProfileNameConnector.propTypes = { - qualityProfileId: PropTypes.number.isRequired, - name: PropTypes.string.isRequired -}; - -export default connect(createMapStateToProps)(QualityProfileNameConnector); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.js b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js deleted file mode 100644 index 6e40bedad..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfiles.js +++ /dev/null @@ -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 ( -
- -
- { - items.map((item) => { - return ( - - ); - }) - } - - -
- -
-
-
- - -
-
- ); - } -} - -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; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfiles.tsx new file mode 100644 index 000000000..fad09d099 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.tsx @@ -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( + '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 ( +
+ +
+ {items.map((item) => { + return ( + + ); + })} + + +
+ +
+
+
+ + +
+
+ ); +} + +export default QualityProfiles; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js deleted file mode 100644 index 4cb318463..000000000 --- a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js +++ /dev/null @@ -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 ( - - ); - } -} - -QualityProfilesConnector.propTypes = { - dispatchFetchQualityProfiles: PropTypes.func.isRequired, - dispatchDeleteQualityProfile: PropTypes.func.isRequired, - dispatchCloneQualityProfile: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.tsx b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.tsx index 9ae5d48e1..121d03d3c 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.tsx +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.tsx @@ -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; } diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.tsx b/frontend/src/Settings/Quality/Definition/QualityDefinitions.tsx index 7baefd3a6..a843d2225 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinitions.tsx +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.tsx @@ -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({
{translate('Quality')}
{translate('Title')}
-
{translate('SizeLimit')}
- - {showAdvancedSettings ? ( -
- {translate('MegabytesPerMinute')} -
- ) : null}
{items.map((item) => { - return ( - - ); + return ; })}
- -
-
- {translate('QualityLimitsSeriesRuntimeHelpText')} -
-
); diff --git a/frontend/src/Settings/Quality/Quality.js b/frontend/src/Settings/Quality/Quality.js deleted file mode 100644 index 3d458cb1c..000000000 --- a/frontend/src/Settings/Quality/Quality.js +++ /dev/null @@ -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 ( - - - - - - - } - onSavePress={this.onSavePress} - /> - - - - - - - - ); - } -} - -Quality.propTypes = { - isResettingQualityDefinitions: PropTypes.bool.isRequired -}; - -export default Quality; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.ts b/frontend/src/Store/Selectors/createProfileInUseSelector.ts deleted file mode 100644 index 085cee0fc..000000000 --- a/frontend/src/Store/Selectors/createProfileInUseSelector.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.ts b/frontend/src/Store/Selectors/createProviderSettingsSelector.ts index 161dc7df5..90d6fe0fe 100644 --- a/frontend/src/Store/Selectors/createProviderSettingsSelector.ts +++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.ts @@ -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 = AppSectionSchemaState | AppSectionItemSchemaState; + function selector< T extends ModelBaseSetting, - S extends AppSectionProviderState & AppSectionSchemaState + S extends AppSectionProviderState & SchemaState >(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(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(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 & AppSectionSchemaState + S extends AppSectionProviderState & SchemaState >(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 & AppSectionSchemaState + S extends AppSectionProviderState & SchemaState >(sectionName: string, id: number | undefined) { return createSelector( (state: AppState) => state.settings, diff --git a/frontend/src/Store/Selectors/createQualityProfileInUseSelector.ts b/frontend/src/Store/Selectors/createQualityProfileInUseSelector.ts new file mode 100644 index 000000000..06aa0b2cd --- /dev/null +++ b/frontend/src/Store/Selectors/createQualityProfileInUseSelector.ts @@ -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; diff --git a/frontend/src/Utilities/Quality/getQualities.ts b/frontend/src/Utilities/Quality/getQualities.ts index cf35b7992..3fb4a21e7 100644 --- a/frontend/src/Utilities/Quality/getQualities.ts +++ b/frontend/src/Utilities/Quality/getQualities.ts @@ -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((acc, item) => { - if (item.quality) { + if ('quality' in item) { acc.push(item.quality); } else { const groupQualities = item.items.reduce((acc, i) => { diff --git a/frontend/src/typings/QualityProfile.ts b/frontend/src/typings/QualityProfile.ts index 41063cb3e..c63c99432 100644 --- a/frontend/src/typings/QualityProfile.ts +++ b/frontend/src/typings/QualityProfile.ts @@ -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; diff --git a/frontend/src/typings/Table.ts b/frontend/src/typings/Table.ts index 4f99e2045..63c079612 100644 --- a/frontend/src/typings/Table.ts +++ b/frontend/src/typings/Table.ts @@ -2,5 +2,5 @@ import Column from 'Components/Table/Column'; export interface TableOptionsChangePayload { pageSize?: number; - columns: Column[]; + columns?: Column[]; } diff --git a/package.json b/package.json index 90f4f185f..30df90a47 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 2de31465d..a8ebf899c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==