mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-20 21:54:58 -04:00
Convert Form Components to TypeScript
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
|
||||
import { Protocol } from 'typings/DownloadClient';
|
||||
import { EnhancedSelectInputChanged } from 'typings/inputs';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputProps,
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
|
||||
function createDownloadClientsSelector(
|
||||
includeAny: boolean,
|
||||
protocol: Protocol
|
||||
) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.downloadClients,
|
||||
(downloadClients) => {
|
||||
const { isFetching, isPopulated, error, items } = downloadClients;
|
||||
|
||||
const filteredItems = items.filter((item) => item.protocol === protocol);
|
||||
|
||||
const values = filteredItems
|
||||
.sort(sortByProp('name'))
|
||||
.map((downloadClient) => {
|
||||
return {
|
||||
key: downloadClient.id,
|
||||
value: downloadClient.name,
|
||||
hint: `(${downloadClient.id})`,
|
||||
};
|
||||
});
|
||||
|
||||
if (includeAny) {
|
||||
values.unshift({
|
||||
key: 0,
|
||||
value: `(${translate('Any')})`,
|
||||
hint: '',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
values,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface DownloadClientSelectInputProps
|
||||
extends EnhancedSelectInputProps<EnhancedSelectInputValue<number>, number> {
|
||||
name: string;
|
||||
value: number;
|
||||
includeAny?: boolean;
|
||||
protocol?: Protocol;
|
||||
onChange: (change: EnhancedSelectInputChanged<number>) => void;
|
||||
}
|
||||
|
||||
function DownloadClientSelectInput({
|
||||
includeAny = false,
|
||||
protocol = 'torrent',
|
||||
...otherProps
|
||||
}: DownloadClientSelectInputProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { isFetching, isPopulated, values } = useSelector(
|
||||
createDownloadClientsSelector(includeAny, protocol)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopulated) {
|
||||
dispatch(fetchDownloadClients());
|
||||
}
|
||||
}, [isPopulated, dispatch]);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
isFetching={isFetching}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DownloadClientSelectInput;
|
||||
@@ -0,0 +1,111 @@
|
||||
.enhancedSelect {
|
||||
composes: input from '~Components/Form/Input.css';
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editableContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.dropdownArrowContainer {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.dropdownArrowContainerEditable {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding-right: 17px;
|
||||
width: 30%;
|
||||
height: 35px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dropdownArrowContainerDisabled {
|
||||
composes: dropdownArrowContainer;
|
||||
|
||||
color: var(--disabledInputColor);
|
||||
}
|
||||
|
||||
.optionsContainer {
|
||||
z-index: $popperZIndex;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.options {
|
||||
composes: scroller from '~Components/Scroller/Scroller.css';
|
||||
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border-radius: 4px;
|
||||
background-color: var(--inputBackgroundColor);
|
||||
}
|
||||
|
||||
.optionsModal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 90%;
|
||||
max-height: 100%;
|
||||
width: 350px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.optionsModalBody {
|
||||
composes: modalBody from '~Components/Modal/ModalBody.css';
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.optionsInnerModalBody {
|
||||
composes: innerModalBody from '~Components/Modal/ModalBody.css';
|
||||
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.optionsModalScroller {
|
||||
composes: scroller from '~Components/Scroller/Scroller.css';
|
||||
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border-radius: 4px;
|
||||
background-color: var(--inputBackgroundColor);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
margin: 5px -5px 5px 0;
|
||||
}
|
||||
|
||||
.mobileCloseButtonContainer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--borderColor);
|
||||
}
|
||||
|
||||
.mobileCloseButton {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
line-height: 40px;
|
||||
|
||||
&:hover {
|
||||
color: var(--modalCloseButtonHoverColor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'dropdownArrowContainer': string;
|
||||
'dropdownArrowContainerDisabled': string;
|
||||
'dropdownArrowContainerEditable': string;
|
||||
'editableContainer': string;
|
||||
'enhancedSelect': string;
|
||||
'hasError': string;
|
||||
'hasWarning': string;
|
||||
'isDisabled': string;
|
||||
'loading': string;
|
||||
'mobileCloseButton': string;
|
||||
'mobileCloseButtonContainer': string;
|
||||
'options': string;
|
||||
'optionsContainer': string;
|
||||
'optionsInnerModalBody': string;
|
||||
'optionsModal': string;
|
||||
'optionsModalBody': string;
|
||||
'optionsModalScroller': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,622 @@
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
ElementType,
|
||||
KeyboardEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Measure from 'Components/Measure';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import Portal from 'Components/Portal';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import { icons, scrollDirections, sizes } from 'Helpers/Props';
|
||||
import ArrayElement from 'typings/Helpers/ArrayElement';
|
||||
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
|
||||
import { isMobile as isMobileUtil } from 'Utilities/browser';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import getUniqueElementId from 'Utilities/getUniqueElementId';
|
||||
import TextInput from '../TextInput';
|
||||
import HintedSelectInputOption from './HintedSelectInputOption';
|
||||
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
|
||||
import styles from './EnhancedSelectInput.css';
|
||||
|
||||
function isArrowKey(keyCode: number) {
|
||||
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
|
||||
}
|
||||
|
||||
function getSelectedOption<T extends EnhancedSelectInputValue<V>, V>(
|
||||
selectedIndex: number,
|
||||
values: T[]
|
||||
) {
|
||||
return values[selectedIndex];
|
||||
}
|
||||
|
||||
function findIndex<T extends EnhancedSelectInputValue<V>, V>(
|
||||
startingIndex: number,
|
||||
direction: 1 | -1,
|
||||
values: T[]
|
||||
) {
|
||||
let indexToTest = startingIndex + direction;
|
||||
|
||||
while (indexToTest !== startingIndex) {
|
||||
if (indexToTest < 0) {
|
||||
indexToTest = values.length - 1;
|
||||
} else if (indexToTest >= values.length) {
|
||||
indexToTest = 0;
|
||||
}
|
||||
|
||||
if (getSelectedOption(indexToTest, values).isDisabled) {
|
||||
indexToTest = indexToTest + direction;
|
||||
} else {
|
||||
return indexToTest;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function previousIndex<T extends EnhancedSelectInputValue<V>, V>(
|
||||
selectedIndex: number,
|
||||
values: T[]
|
||||
) {
|
||||
return findIndex(selectedIndex, -1, values);
|
||||
}
|
||||
|
||||
function nextIndex<T extends EnhancedSelectInputValue<V>, V>(
|
||||
selectedIndex: number,
|
||||
values: T[]
|
||||
) {
|
||||
return findIndex(selectedIndex, 1, values);
|
||||
}
|
||||
|
||||
function getSelectedIndex<T extends EnhancedSelectInputValue<V>, V>(
|
||||
value: V,
|
||||
values: T[]
|
||||
) {
|
||||
if (Array.isArray(value)) {
|
||||
return values.findIndex((v) => {
|
||||
return v.key === value[0];
|
||||
});
|
||||
}
|
||||
|
||||
return values.findIndex((v) => {
|
||||
return v.key === value;
|
||||
});
|
||||
}
|
||||
|
||||
function isSelectedItem<T extends EnhancedSelectInputValue<V>, V>(
|
||||
index: number,
|
||||
value: V,
|
||||
values: T[]
|
||||
) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes(values[index].key);
|
||||
}
|
||||
|
||||
return values[index].key === value;
|
||||
}
|
||||
|
||||
export interface EnhancedSelectInputValue<V> {
|
||||
key: ArrayElement<V>;
|
||||
value: string;
|
||||
hint?: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
isHidden?: boolean;
|
||||
parentKey?: V;
|
||||
additionalProperties?: object;
|
||||
}
|
||||
|
||||
export interface EnhancedSelectInputProps<
|
||||
T extends EnhancedSelectInputValue<V>,
|
||||
V
|
||||
> {
|
||||
className?: string;
|
||||
disabledClassName?: string;
|
||||
name: string;
|
||||
value: V;
|
||||
values: T[];
|
||||
isDisabled?: boolean;
|
||||
isFetching?: boolean;
|
||||
isEditable?: boolean;
|
||||
hasError?: boolean;
|
||||
hasWarning?: boolean;
|
||||
valueOptions?: object;
|
||||
selectedValueOptions?: object;
|
||||
selectedValueComponent?: string | ElementType;
|
||||
optionComponent?: ElementType;
|
||||
onOpen?: () => void;
|
||||
onChange: (change: EnhancedSelectInputChanged<V>) => void;
|
||||
}
|
||||
|
||||
function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
props: EnhancedSelectInputProps<T, V>
|
||||
) {
|
||||
const {
|
||||
className = styles.enhancedSelect,
|
||||
disabledClassName = styles.isDisabled,
|
||||
name,
|
||||
value,
|
||||
values,
|
||||
isDisabled = false,
|
||||
isEditable,
|
||||
isFetching,
|
||||
hasError,
|
||||
hasWarning,
|
||||
valueOptions,
|
||||
selectedValueOptions,
|
||||
selectedValueComponent:
|
||||
SelectedValueComponent = HintedSelectInputSelectedValue,
|
||||
optionComponent: OptionComponent = HintedSelectInputOption,
|
||||
onChange,
|
||||
onOpen,
|
||||
} = props;
|
||||
|
||||
const updater = useRef<(() => void) | null>(null);
|
||||
const buttonId = useMemo(() => getUniqueElementId(), []);
|
||||
const optionsId = useMemo(() => getUniqueElementId(), []);
|
||||
const [selectedIndex, setSelectedIndex] = useState(
|
||||
getSelectedIndex(value, values)
|
||||
);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isMobile = useMemo(() => isMobileUtil(), []);
|
||||
|
||||
const isMultiSelect = Array.isArray(value);
|
||||
const selectedOption = getSelectedOption(selectedIndex, values);
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (values.length) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isMultiSelect) {
|
||||
return [];
|
||||
} else if (typeof value === 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [value, values, isMultiSelect]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleComputeMaxHeight = useCallback((data: any) => {
|
||||
const { top, bottom } = data.offsets.reference;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (/^botton/.test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
}
|
||||
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const handleWindowClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const button = document.getElementById(buttonId);
|
||||
const options = document.getElementById(optionsId);
|
||||
const eventTarget = event.target as HTMLElement;
|
||||
|
||||
if (!button || !eventTarget.isConnected || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!button.contains(eventTarget) &&
|
||||
options &&
|
||||
!options.contains(eventTarget) &&
|
||||
isOpen
|
||||
) {
|
||||
setIsOpen(false);
|
||||
window.removeEventListener('click', handleWindowClick);
|
||||
}
|
||||
},
|
||||
[isMobile, isOpen, buttonId, optionsId, setIsOpen]
|
||||
);
|
||||
|
||||
const addListener = useCallback(() => {
|
||||
window.addEventListener('click', handleWindowClick);
|
||||
}, [handleWindowClick]);
|
||||
|
||||
const removeListener = useCallback(() => {
|
||||
window.removeEventListener('click', handleWindowClick);
|
||||
}, [handleWindowClick]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isOpen) {
|
||||
removeListener();
|
||||
} else {
|
||||
addListener();
|
||||
}
|
||||
|
||||
if (!isOpen && onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
}, [isOpen, setIsOpen, addListener, removeListener, onOpen]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(newValue: ArrayElement<V>) => {
|
||||
const additionalProperties = values.find(
|
||||
(v) => v.key === newValue
|
||||
)?.additionalProperties;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const index = value.indexOf(newValue);
|
||||
|
||||
if (index === -1) {
|
||||
const arrayValue = values
|
||||
.map((v) => v.key)
|
||||
.filter((v) => v === newValue || value.includes(v));
|
||||
|
||||
onChange({
|
||||
name,
|
||||
value: arrayValue as V,
|
||||
additionalProperties,
|
||||
});
|
||||
} else {
|
||||
const arrayValue = [...value];
|
||||
arrayValue.splice(index, 1);
|
||||
|
||||
onChange({
|
||||
name,
|
||||
value: arrayValue as V,
|
||||
additionalProperties,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
|
||||
onChange({
|
||||
name,
|
||||
value: newValue as V,
|
||||
additionalProperties,
|
||||
});
|
||||
}
|
||||
},
|
||||
[name, value, values, onChange, setIsOpen]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (!isEditable) {
|
||||
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
|
||||
const origIndex = getSelectedIndex(value, values);
|
||||
|
||||
if (origIndex !== selectedIndex) {
|
||||
setSelectedIndex(origIndex);
|
||||
}
|
||||
}
|
||||
}, [value, values, isEditable, selectedIndex, setSelectedIndex]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (isOpen) {
|
||||
removeListener();
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [isOpen, setIsOpen, removeListener]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const keyCode = event.keyCode;
|
||||
let nextIsOpen: boolean | null = null;
|
||||
let nextSelectedIndex: number | null = null;
|
||||
|
||||
if (!isOpen) {
|
||||
if (isArrowKey(keyCode)) {
|
||||
event.preventDefault();
|
||||
nextIsOpen = true;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedIndex == null ||
|
||||
selectedIndex === -1 ||
|
||||
getSelectedOption(selectedIndex, values).isDisabled
|
||||
) {
|
||||
if (keyCode === keyCodes.UP_ARROW) {
|
||||
nextSelectedIndex = previousIndex(0, values);
|
||||
} else if (keyCode === keyCodes.DOWN_ARROW) {
|
||||
nextSelectedIndex = nextIndex(values.length - 1, values);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextIsOpen !== null) {
|
||||
setIsOpen(nextIsOpen);
|
||||
}
|
||||
|
||||
if (nextSelectedIndex !== null) {
|
||||
setSelectedIndex(nextSelectedIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.UP_ARROW) {
|
||||
event.preventDefault();
|
||||
nextSelectedIndex = previousIndex(selectedIndex, values);
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.DOWN_ARROW) {
|
||||
event.preventDefault();
|
||||
nextSelectedIndex = nextIndex(selectedIndex, values);
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.ENTER) {
|
||||
event.preventDefault();
|
||||
nextIsOpen = false;
|
||||
handleSelect(values[selectedIndex].key);
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.TAB) {
|
||||
nextIsOpen = false;
|
||||
handleSelect(values[selectedIndex].key);
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.ESCAPE) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
nextIsOpen = false;
|
||||
nextSelectedIndex = getSelectedIndex(value, values);
|
||||
}
|
||||
|
||||
if (nextIsOpen !== null) {
|
||||
setIsOpen(nextIsOpen);
|
||||
}
|
||||
|
||||
if (nextSelectedIndex !== null) {
|
||||
setSelectedIndex(nextSelectedIndex);
|
||||
}
|
||||
},
|
||||
[
|
||||
value,
|
||||
isOpen,
|
||||
selectedIndex,
|
||||
values,
|
||||
setIsOpen,
|
||||
setSelectedIndex,
|
||||
handleSelect,
|
||||
]
|
||||
);
|
||||
|
||||
const handleMeasure = useCallback(
|
||||
({ width: newWidth }: { width: number }) => {
|
||||
setWidth(newWidth);
|
||||
},
|
||||
[setWidth]
|
||||
);
|
||||
|
||||
const handleOptionsModalClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const handleEditChange = useCallback(
|
||||
(change: InputChanged<string>) => {
|
||||
onChange(change as EnhancedSelectInputChanged<V>);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (updater.current) {
|
||||
updater.current();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div ref={ref} id={buttonId}>
|
||||
<Measure whitelist={['width']} onMeasure={handleMeasure}>
|
||||
{isEditable && typeof value === 'string' ? (
|
||||
<div className={styles.editableContainer}>
|
||||
<TextInput
|
||||
className={className}
|
||||
name={name}
|
||||
value={value}
|
||||
readOnly={isDisabled}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.dropdownArrowContainerEditable,
|
||||
isDisabled
|
||||
? styles.dropdownArrowContainerDisabled
|
||||
: styles.dropdownArrowContainer
|
||||
)}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<SelectedValueComponent
|
||||
values={values}
|
||||
{...selectedValueOptions}
|
||||
selectedValue={selectedValue}
|
||||
isDisabled={isDisabled}
|
||||
isMultiSelect={isMultiSelect}
|
||||
>
|
||||
{selectedOption ? selectedOption.value : selectedValue}
|
||||
</SelectedValueComponent>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDisabled
|
||||
? styles.dropdownArrowContainerDisabled
|
||||
: styles.dropdownArrowContainer
|
||||
}
|
||||
>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</Measure>
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom-start"
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: handleComputeMaxHeight,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
updater.current = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={optionsId}
|
||||
className={styles.optionsContainer}
|
||||
style={{
|
||||
...style,
|
||||
minWidth: width,
|
||||
}}
|
||||
>
|
||||
{isOpen && !isMobile ? (
|
||||
<Scroller
|
||||
className={styles.options}
|
||||
style={{
|
||||
maxHeight: style.maxHeight,
|
||||
}}
|
||||
>
|
||||
{values.map((v, index) => {
|
||||
const hasParent = v.parentKey !== undefined;
|
||||
const depth = hasParent ? 1 : 0;
|
||||
const parentSelected =
|
||||
v.parentKey !== undefined &&
|
||||
Array.isArray(value) &&
|
||||
value.includes(v.parentKey);
|
||||
|
||||
const { key, ...other } = v;
|
||||
|
||||
return (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
depth={depth}
|
||||
isSelected={isSelectedItem(index, value, values)}
|
||||
isDisabled={parentSelected}
|
||||
isMultiSelect={isMultiSelect}
|
||||
{...valueOptions}
|
||||
{...other}
|
||||
isMobile={false}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})}
|
||||
</Scroller>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
|
||||
{isMobile ? (
|
||||
<Modal
|
||||
className={styles.optionsModal}
|
||||
size={sizes.EXTRA_SMALL}
|
||||
isOpen={isOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
>
|
||||
<ModalBody
|
||||
className={styles.optionsModalBody}
|
||||
innerClassName={styles.optionsInnerModalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
<Scroller className={styles.optionsModalScroller}>
|
||||
<div className={styles.mobileCloseButtonContainer}>
|
||||
<Link
|
||||
className={styles.mobileCloseButton}
|
||||
onPress={handleOptionsModalClose}
|
||||
>
|
||||
<Icon name={icons.CLOSE} size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{values.map((v, index) => {
|
||||
const hasParent = v.parentKey !== undefined;
|
||||
const depth = hasParent ? 1 : 0;
|
||||
const parentSelected =
|
||||
v.parentKey !== undefined &&
|
||||
isMultiSelect &&
|
||||
value.includes(v.parentKey);
|
||||
|
||||
const { key, ...other } = v;
|
||||
|
||||
return (
|
||||
<OptionComponent
|
||||
key={key}
|
||||
id={key}
|
||||
depth={depth}
|
||||
isSelected={isSelectedItem(index, value, values)}
|
||||
isMultiSelect={isMultiSelect}
|
||||
isDisabled={parentSelected}
|
||||
{...valueOptions}
|
||||
{...other}
|
||||
isMobile={true}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})}
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnhancedSelectInput;
|
||||
@@ -0,0 +1,65 @@
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--inputHoverBackgroundColor);
|
||||
}
|
||||
|
||||
&.isDisabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.optionCheck {
|
||||
composes: container from '~Components/Form/CheckInput.css';
|
||||
|
||||
flex: 0 0 0;
|
||||
}
|
||||
|
||||
.optionCheckInput {
|
||||
composes: input from '~Components/Form/CheckInput.css';
|
||||
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.isSelected {
|
||||
background-color: var(--inputSelectedBackgroundColor);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--inputSelectedBackgroundColor);
|
||||
}
|
||||
|
||||
&.isMobile {
|
||||
background-color: inherit;
|
||||
|
||||
.iconContainer {
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
background-color: #aaa;
|
||||
}
|
||||
|
||||
.isHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isMobile {
|
||||
height: 50px;
|
||||
border-bottom: 1px solid var(--borderColor);
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'iconContainer': string;
|
||||
'isDisabled': string;
|
||||
'isHidden': string;
|
||||
'isMobile': string;
|
||||
'isSelected': string;
|
||||
'option': string;
|
||||
'optionCheck': string;
|
||||
'optionCheckInput': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,84 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { SyntheticEvent, useCallback } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import CheckInput from '../CheckInput';
|
||||
import styles from './EnhancedSelectInputOption.css';
|
||||
|
||||
function handleCheckPress() {
|
||||
// CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation.
|
||||
}
|
||||
|
||||
export interface EnhancedSelectInputOptionProps {
|
||||
className?: string;
|
||||
id: string | number;
|
||||
depth?: number;
|
||||
isSelected: boolean;
|
||||
isDisabled?: boolean;
|
||||
isHidden?: boolean;
|
||||
isMultiSelect?: boolean;
|
||||
isMobile: boolean;
|
||||
children: React.ReactNode;
|
||||
onSelect: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function EnhancedSelectInputOption({
|
||||
className = styles.option,
|
||||
id,
|
||||
depth = 0,
|
||||
isSelected,
|
||||
isDisabled = false,
|
||||
isHidden = false,
|
||||
isMultiSelect = false,
|
||||
isMobile,
|
||||
children,
|
||||
onSelect,
|
||||
}: EnhancedSelectInputOptionProps) {
|
||||
const handlePress = useCallback(
|
||||
(event: SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
onSelect(id);
|
||||
},
|
||||
[id, onSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
isSelected && !isMultiSelect && styles.isSelected,
|
||||
isDisabled && !isMultiSelect && styles.isDisabled,
|
||||
isHidden && styles.isHidden,
|
||||
isMobile && styles.isMobile
|
||||
)}
|
||||
component="div"
|
||||
isDisabled={isDisabled}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{depth !== 0 && <div style={{ width: `${depth * 20}px` }} />}
|
||||
|
||||
{isMultiSelect && (
|
||||
<CheckInput
|
||||
className={styles.optionCheckInput}
|
||||
containerClassName={styles.optionCheck}
|
||||
name={`select-${id}`}
|
||||
value={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
onChange={handleCheckPress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{isMobile && (
|
||||
<div className={styles.iconContainer}>
|
||||
<Icon name={isSelected ? icons.CHECK_CIRCLE : icons.CIRCLE_OUTLINE} />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnhancedSelectInputOption;
|
||||
@@ -0,0 +1,7 @@
|
||||
.selectedValue {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
color: var(--disabledInputColor);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'isDisabled': string;
|
||||
'selectedValue': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,23 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ReactNode } from 'react';
|
||||
import styles from './EnhancedSelectInputSelectedValue.css';
|
||||
|
||||
interface EnhancedSelectInputSelectedValueProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
function EnhancedSelectInputSelectedValue({
|
||||
className = styles.selectedValue,
|
||||
children,
|
||||
isDisabled = false,
|
||||
}: EnhancedSelectInputSelectedValueProps) {
|
||||
return (
|
||||
<div className={classNames(className, isDisabled && styles.isDisabled)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnhancedSelectInputSelectedValue;
|
||||
@@ -0,0 +1,23 @@
|
||||
.optionText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
|
||||
&.isMobile {
|
||||
display: block;
|
||||
|
||||
.hintText {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hintText {
|
||||
@add-mixin truncate;
|
||||
|
||||
margin-left: 15px;
|
||||
color: var(--darkGray);
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'hintText': string;
|
||||
'isMobile': string;
|
||||
'optionText': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,52 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import EnhancedSelectInputOption, {
|
||||
EnhancedSelectInputOptionProps,
|
||||
} from './EnhancedSelectInputOption';
|
||||
import styles from './HintedSelectInputOption.css';
|
||||
|
||||
interface HintedSelectInputOptionProps extends EnhancedSelectInputOptionProps {
|
||||
value: string;
|
||||
hint?: React.ReactNode;
|
||||
}
|
||||
|
||||
function HintedSelectInputOption(props: HintedSelectInputOptionProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
hint,
|
||||
depth,
|
||||
isSelected = false,
|
||||
isDisabled,
|
||||
isMobile,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<EnhancedSelectInputOption
|
||||
id={id}
|
||||
depth={depth}
|
||||
isSelected={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
isHidden={isDisabled}
|
||||
isMobile={isMobile}
|
||||
{...otherProps}
|
||||
>
|
||||
<div
|
||||
className={classNames(styles.optionText, isMobile && styles.isMobile)}
|
||||
>
|
||||
<div>{value}</div>
|
||||
|
||||
{hint != null && <div className={styles.hintText}>{hint}</div>}
|
||||
</div>
|
||||
</EnhancedSelectInputOption>
|
||||
);
|
||||
}
|
||||
|
||||
HintedSelectInputOption.defaultProps = {
|
||||
isDisabled: false,
|
||||
isHidden: false,
|
||||
isMultiSelect: false,
|
||||
};
|
||||
|
||||
export default HintedSelectInputOption;
|
||||
@@ -0,0 +1,24 @@
|
||||
.selectedValue {
|
||||
composes: selectedValue from '~./EnhancedSelectInputSelectedValue.css';
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.valueText {
|
||||
@add-mixin truncate;
|
||||
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.hintText {
|
||||
@add-mixin truncate;
|
||||
|
||||
flex: 1 10 0;
|
||||
margin-left: 15px;
|
||||
color: var(--gray);
|
||||
text-align: right;
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'hintText': string;
|
||||
'selectedValue': string;
|
||||
'valueText': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { ReactNode, useMemo } from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import ArrayElement from 'typings/Helpers/ArrayElement';
|
||||
import { EnhancedSelectInputValue } from './EnhancedSelectInput';
|
||||
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
|
||||
import styles from './HintedSelectInputSelectedValue.css';
|
||||
|
||||
interface HintedSelectInputSelectedValueProps<T, V> {
|
||||
selectedValue: V;
|
||||
values: T[];
|
||||
hint?: ReactNode;
|
||||
isMultiSelect?: boolean;
|
||||
includeHint?: boolean;
|
||||
}
|
||||
|
||||
function HintedSelectInputSelectedValue<
|
||||
T extends EnhancedSelectInputValue<V>,
|
||||
V extends number | string
|
||||
>(props: HintedSelectInputSelectedValueProps<T, V>) {
|
||||
const {
|
||||
selectedValue,
|
||||
values,
|
||||
hint,
|
||||
isMultiSelect = false,
|
||||
includeHint = true,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const valuesMap = useMemo(() => {
|
||||
return new Map(values.map((v) => [v.key, v.value]));
|
||||
}, [values]);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInputSelectedValue
|
||||
className={styles.selectedValue}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.valueText}>
|
||||
{isMultiSelect && Array.isArray(selectedValue)
|
||||
? selectedValue.map((key) => {
|
||||
const v = valuesMap.get(key);
|
||||
|
||||
return <Label key={key}>{v ? v : key}</Label>;
|
||||
})
|
||||
: valuesMap.get(selectedValue as ArrayElement<V>)}
|
||||
</div>
|
||||
|
||||
{hint != null && includeHint ? (
|
||||
<div className={styles.hintText}>{hint}</div>
|
||||
) : null}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
);
|
||||
}
|
||||
|
||||
export default HintedSelectInputSelectedValue;
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { EnhancedSelectInputChanged } from 'typings/inputs';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
const selectIndexerFlagsValues = (selectedFlags: number) =>
|
||||
createSelector(
|
||||
(state: AppState) => state.settings.indexerFlags,
|
||||
(indexerFlags) => {
|
||||
const value = indexerFlags.items.reduce((acc: number[], { id }) => {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if ((selectedFlags & id) === id) {
|
||||
acc.push(id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const values = indexerFlags.items.map(({ id, name }) => ({
|
||||
key: id,
|
||||
value: name,
|
||||
}));
|
||||
|
||||
return {
|
||||
value,
|
||||
values,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
interface IndexerFlagsSelectInputProps {
|
||||
name: string;
|
||||
indexerFlags: number;
|
||||
onChange(payload: EnhancedSelectInputChanged<number>): void;
|
||||
}
|
||||
|
||||
function IndexerFlagsSelectInput({
|
||||
name,
|
||||
indexerFlags,
|
||||
onChange,
|
||||
...otherProps
|
||||
}: IndexerFlagsSelectInputProps) {
|
||||
const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags));
|
||||
|
||||
const handleChange = useCallback(
|
||||
(change: EnhancedSelectInputChanged<number[]>) => {
|
||||
const indexerFlags = change.value.reduce(
|
||||
(acc, flagId) => acc + flagId,
|
||||
0
|
||||
);
|
||||
|
||||
onChange({ name, value: indexerFlags });
|
||||
},
|
||||
[name, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
value={value}
|
||||
values={values}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexerFlagsSelectInput;
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { fetchIndexers } from 'Store/Actions/settingsActions';
|
||||
import { EnhancedSelectInputChanged } from 'typings/inputs';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
function createIndexersSelector(includeAny: boolean) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.indexers,
|
||||
(indexers) => {
|
||||
const { isFetching, isPopulated, error, items } = indexers;
|
||||
|
||||
const values = items.sort(sortByProp('name')).map((indexer) => {
|
||||
return {
|
||||
key: indexer.id,
|
||||
value: indexer.name,
|
||||
};
|
||||
});
|
||||
|
||||
if (includeAny) {
|
||||
values.unshift({
|
||||
key: 0,
|
||||
value: `(${translate('Any')})`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
values,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface IndexerSelectInputConnectorProps {
|
||||
name: string;
|
||||
value: number;
|
||||
includeAny?: boolean;
|
||||
values: object[];
|
||||
onChange: (change: EnhancedSelectInputChanged<number>) => void;
|
||||
}
|
||||
|
||||
function IndexerSelectInput({
|
||||
name,
|
||||
value,
|
||||
includeAny = false,
|
||||
onChange,
|
||||
}: IndexerSelectInputConnectorProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { isFetching, isPopulated, values } = useSelector(
|
||||
createIndexersSelector(includeAny)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopulated) {
|
||||
dispatch(fetchIndexers());
|
||||
}
|
||||
}, [isPopulated, dispatch]);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
name={name}
|
||||
value={value}
|
||||
isFetching={isFetching}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
IndexerSelectInput.defaultProps = {
|
||||
includeAny: false,
|
||||
};
|
||||
|
||||
export default IndexerSelectInput;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { EnhancedSelectInputChanged } from 'typings/inputs';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
|
||||
interface LanguageSelectInputProps {
|
||||
name: string;
|
||||
value: number;
|
||||
values: EnhancedSelectInputValue<number>[];
|
||||
onChange: (change: EnhancedSelectInputChanged<number>) => void;
|
||||
}
|
||||
|
||||
function LanguageSelectInput({
|
||||
values,
|
||||
onChange,
|
||||
...otherProps
|
||||
}: LanguageSelectInputProps) {
|
||||
const mappedValues = useMemo(() => {
|
||||
const minId = values.reduce(
|
||||
(min: number, v) => (v.key < 1 ? v.key : min),
|
||||
values[0].key
|
||||
);
|
||||
|
||||
return values.map(({ key, value }) => {
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
dividerAfter: minId < 1 ? key === minId : false,
|
||||
};
|
||||
});
|
||||
}, [values]);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
values={mappedValues}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSelectInput;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import monitorOptions from 'Utilities/Series/monitorOptions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputProps,
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
|
||||
interface MonitorEpisodesSelectInputProps
|
||||
extends Omit<
|
||||
EnhancedSelectInputProps<EnhancedSelectInputValue<string>, string>,
|
||||
'values'
|
||||
> {
|
||||
includeNoChange: boolean;
|
||||
includeMixed: boolean;
|
||||
}
|
||||
|
||||
function MonitorEpisodesSelectInput(props: MonitorEpisodesSelectInputProps) {
|
||||
const {
|
||||
includeNoChange = false,
|
||||
includeMixed = false,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const values: EnhancedSelectInputValue<string>[] = [...monitorOptions];
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
values.unshift({
|
||||
key: 'mixed',
|
||||
get value() {
|
||||
return `(${translate('Mixed')})`;
|
||||
},
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return <EnhancedSelectInput {...otherProps} values={values} />;
|
||||
}
|
||||
|
||||
export default MonitorEpisodesSelectInput;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputProps,
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
|
||||
interface MonitorNewItemsSelectInputProps
|
||||
extends Omit<
|
||||
EnhancedSelectInputProps<EnhancedSelectInputValue<string>, string>,
|
||||
'values'
|
||||
> {
|
||||
includeNoChange?: boolean;
|
||||
includeMixed?: boolean;
|
||||
onChange: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function MonitorNewItemsSelectInput(props: MonitorNewItemsSelectInputProps) {
|
||||
const {
|
||||
includeNoChange = false,
|
||||
includeMixed = false,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const values: EnhancedSelectInputValue<string>[] = [
|
||||
...monitorNewItemsOptions,
|
||||
];
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
value: 'No Change',
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
values.unshift({
|
||||
key: 'mixed',
|
||||
value: '(Mixed)',
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return <EnhancedSelectInput {...otherProps} values={values} />;
|
||||
}
|
||||
|
||||
export default MonitorNewItemsSelectInput;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import ProviderOptionsAppState, {
|
||||
ProviderOptions,
|
||||
} from 'App/State/ProviderOptionsAppState';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import {
|
||||
clearOptions,
|
||||
fetchOptions,
|
||||
} from 'Store/Actions/providerOptionActions';
|
||||
import { FieldSelectOption } from 'typings/Field';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputProps,
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
|
||||
const importantFieldNames = ['baseUrl', 'apiPath', 'apiKey', 'authToken'];
|
||||
|
||||
function getProviderDataKey(providerData: ProviderOptions) {
|
||||
if (!providerData || !providerData.fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = providerData.fields
|
||||
.filter((f) => importantFieldNames.includes(f.name))
|
||||
.map((f) => f.value);
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function getSelectOptions(items: FieldSelectOption<unknown>[]) {
|
||||
if (!items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.map((option) => {
|
||||
return {
|
||||
key: option.value,
|
||||
value: option.name,
|
||||
hint: option.hint,
|
||||
parentKey: option.parentValue,
|
||||
isDisabled: option.isDisabled,
|
||||
additionalProperties: option.additionalProperties,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createProviderOptionsSelector(
|
||||
selectOptionsProviderAction: keyof Omit<ProviderOptionsAppState, 'devices'>
|
||||
) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.providerOptions[selectOptionsProviderAction],
|
||||
(options) => {
|
||||
if (!options) {
|
||||
return {
|
||||
isFetching: false,
|
||||
values: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isFetching: options.isFetching,
|
||||
values: getSelectOptions(options.items),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderOptionSelectInputProps
|
||||
extends Omit<
|
||||
EnhancedSelectInputProps<EnhancedSelectInputValue<unknown>, unknown>,
|
||||
'values'
|
||||
> {
|
||||
provider: string;
|
||||
providerData: ProviderOptions;
|
||||
name: string;
|
||||
value: unknown;
|
||||
selectOptionsProviderAction: keyof Omit<ProviderOptionsAppState, 'devices'>;
|
||||
}
|
||||
|
||||
function ProviderOptionSelectInput({
|
||||
provider,
|
||||
providerData,
|
||||
selectOptionsProviderAction,
|
||||
...otherProps
|
||||
}: ProviderOptionSelectInputProps) {
|
||||
const dispatch = useDispatch();
|
||||
const [isRefetchRequired, setIsRefetchRequired] = useState(false);
|
||||
const previousProviderData = usePrevious(providerData);
|
||||
const { isFetching, values } = useSelector(
|
||||
createProviderOptionsSelector(selectOptionsProviderAction)
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
if (isRefetchRequired && selectOptionsProviderAction) {
|
||||
setIsRefetchRequired(false);
|
||||
|
||||
dispatch(
|
||||
fetchOptions({
|
||||
section: selectOptionsProviderAction,
|
||||
action: selectOptionsProviderAction,
|
||||
provider,
|
||||
providerData,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isRefetchRequired,
|
||||
provider,
|
||||
providerData,
|
||||
selectOptionsProviderAction,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectOptionsProviderAction) {
|
||||
dispatch(
|
||||
fetchOptions({
|
||||
section: selectOptionsProviderAction,
|
||||
action: selectOptionsProviderAction,
|
||||
provider,
|
||||
providerData,
|
||||
})
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectOptionsProviderAction, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousProviderData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevKey = getProviderDataKey(previousProviderData);
|
||||
const nextKey = getProviderDataKey(providerData);
|
||||
|
||||
if (!isEqual(prevKey, nextKey)) {
|
||||
setIsRefetchRequired(true);
|
||||
}
|
||||
}, [providerData, previousProviderData, setIsRefetchRequired]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (selectOptionsProviderAction) {
|
||||
dispatch(clearOptions({ section: selectOptionsProviderAction }));
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
isFetching={isFetching}
|
||||
values={values}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProviderOptionSelectInput;
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { QualityProfilesAppState } from 'App/State/SettingsAppState';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import { EnhancedSelectInputChanged } from 'typings/inputs';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputProps,
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
|
||||
function createQualityProfilesSelector(
|
||||
includeNoChange: boolean,
|
||||
includeNoChangeDisabled: boolean,
|
||||
includeMixed: boolean
|
||||
) {
|
||||
return createSelector(
|
||||
createSortedSectionSelector(
|
||||
'settings.qualityProfiles',
|
||||
sortByProp<QualityProfile, 'name'>('name')
|
||||
),
|
||||
(qualityProfiles: QualityProfilesAppState) => {
|
||||
const values: EnhancedSelectInputValue<number | string>[] =
|
||||
qualityProfiles.items.map((qualityProfile) => {
|
||||
return {
|
||||
key: qualityProfile.id,
|
||||
value: qualityProfile.name,
|
||||
};
|
||||
});
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
isDisabled: includeNoChangeDisabled,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
values.unshift({
|
||||
key: 'mixed',
|
||||
get value() {
|
||||
return `(${translate('Mixed')})`;
|
||||
},
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface QualityProfileSelectInputConnectorProps
|
||||
extends Omit<
|
||||
EnhancedSelectInputProps<
|
||||
EnhancedSelectInputValue<number | string>,
|
||||
number | string
|
||||
>,
|
||||
'values'
|
||||
> {
|
||||
name: string;
|
||||
includeNoChange?: boolean;
|
||||
includeNoChangeDisabled?: boolean;
|
||||
includeMixed?: boolean;
|
||||
}
|
||||
|
||||
function QualityProfileSelectInput({
|
||||
name,
|
||||
value,
|
||||
includeNoChange = false,
|
||||
includeNoChangeDisabled = true,
|
||||
includeMixed = false,
|
||||
onChange,
|
||||
...otherProps
|
||||
}: QualityProfileSelectInputConnectorProps) {
|
||||
const values = useSelector(
|
||||
createQualityProfilesSelector(
|
||||
includeNoChange,
|
||||
includeNoChangeDisabled,
|
||||
includeMixed
|
||||
)
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ value: newValue }: EnhancedSelectInputChanged<string | number>) => {
|
||||
onChange({
|
||||
name,
|
||||
value: newValue === 'noChange' ? value : newValue,
|
||||
});
|
||||
},
|
||||
[name, value, onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!value ||
|
||||
!values.some((option) => option.key === value || option.key === value)
|
||||
) {
|
||||
const firstValue = values.find(
|
||||
(option) => typeof option.key === 'number'
|
||||
);
|
||||
|
||||
if (firstValue) {
|
||||
onChange({ name, value: firstValue.key });
|
||||
}
|
||||
}
|
||||
}, [name, value, values, onChange]);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
value={value}
|
||||
values={values}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfileSelectInput;
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { addRootFolder } from 'Store/Actions/rootFolderActions';
|
||||
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
|
||||
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputProps,
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
import RootFolderSelectInputOption from './RootFolderSelectInputOption';
|
||||
import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
|
||||
|
||||
const ADD_NEW_KEY = 'addNew';
|
||||
|
||||
export interface RootFolderSelectInputValue
|
||||
extends EnhancedSelectInputValue<string> {
|
||||
isMissing?: boolean;
|
||||
}
|
||||
|
||||
interface RootFolderSelectInputProps
|
||||
extends Omit<
|
||||
EnhancedSelectInputProps<EnhancedSelectInputValue<string>, string>,
|
||||
'value' | 'values'
|
||||
> {
|
||||
name: string;
|
||||
value?: string;
|
||||
isSaving: boolean;
|
||||
saveError?: object;
|
||||
includeNoChange: boolean;
|
||||
}
|
||||
|
||||
function createRootFolderOptionsSelector(
|
||||
value: string | undefined,
|
||||
includeMissingValue: boolean,
|
||||
includeNoChange: boolean,
|
||||
includeNoChangeDisabled: boolean
|
||||
) {
|
||||
return createSelector(
|
||||
createRootFoldersSelector(),
|
||||
|
||||
(rootFolders) => {
|
||||
const values: RootFolderSelectInputValue[] = rootFolders.items.map(
|
||||
(rootFolder) => {
|
||||
return {
|
||||
key: rootFolder.path,
|
||||
value: rootFolder.path,
|
||||
freeSpace: rootFolder.freeSpace,
|
||||
isMissing: false,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
isDisabled: includeNoChangeDisabled,
|
||||
isMissing: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!values.length) {
|
||||
values.push({
|
||||
key: '',
|
||||
value: '',
|
||||
isDisabled: true,
|
||||
isHidden: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
includeMissingValue &&
|
||||
value &&
|
||||
!values.find((v) => v.key === value)
|
||||
) {
|
||||
values.push({
|
||||
key: value,
|
||||
value,
|
||||
isMissing: true,
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
values.push({
|
||||
key: ADD_NEW_KEY,
|
||||
value: translate('AddANewPath'),
|
||||
});
|
||||
|
||||
return {
|
||||
values,
|
||||
isSaving: rootFolders.isSaving,
|
||||
saveError: rootFolders.saveError,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function RootFolderSelectInput({
|
||||
name,
|
||||
value,
|
||||
includeNoChange = false,
|
||||
onChange,
|
||||
...otherProps
|
||||
}: RootFolderSelectInputProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { values, isSaving, saveError } = useSelector(
|
||||
createRootFolderOptionsSelector(value, true, includeNoChange, false)
|
||||
);
|
||||
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
|
||||
useState(false);
|
||||
const [newRootFolderPath, setNewRootFolderPath] = useState('');
|
||||
const previousIsSaving = usePrevious(isSaving);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ value: newValue }: EnhancedSelectInputChanged<string>) => {
|
||||
if (newValue === 'addNew') {
|
||||
setIsAddNewRootFolderModalOpen(true);
|
||||
} else {
|
||||
onChange({ name, value: newValue });
|
||||
}
|
||||
},
|
||||
[name, setIsAddNewRootFolderModalOpen, onChange]
|
||||
);
|
||||
|
||||
const handleNewRootFolderSelect = useCallback(
|
||||
({ value: newValue }: InputChanged<string>) => {
|
||||
setNewRootFolderPath(newValue);
|
||||
dispatch(addRootFolder(newValue));
|
||||
},
|
||||
[setNewRootFolderPath, dispatch]
|
||||
);
|
||||
|
||||
const handleAddRootFolderModalClose = useCallback(() => {
|
||||
setIsAddNewRootFolderModalOpen(false);
|
||||
}, [setIsAddNewRootFolderModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!value &&
|
||||
values.length &&
|
||||
values.some((v) => !!v.key && v.key !== ADD_NEW_KEY)
|
||||
) {
|
||||
const defaultValue = values[0];
|
||||
|
||||
if (defaultValue.key !== ADD_NEW_KEY) {
|
||||
onChange({ name, value: defaultValue.key });
|
||||
}
|
||||
}
|
||||
|
||||
if (previousIsSaving && !isSaving && !saveError && newRootFolderPath) {
|
||||
onChange({ name, value: newRootFolderPath });
|
||||
setNewRootFolderPath('');
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
value,
|
||||
values,
|
||||
isSaving,
|
||||
saveError,
|
||||
previousIsSaving,
|
||||
newRootFolderPath,
|
||||
onChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value == null && values[0].key === '') {
|
||||
onChange({ name, value: '' });
|
||||
} else if (
|
||||
!value ||
|
||||
!values.some((v) => v.key === value) ||
|
||||
value === ADD_NEW_KEY
|
||||
) {
|
||||
const defaultValue = values[0];
|
||||
|
||||
if (defaultValue.key === ADD_NEW_KEY) {
|
||||
onChange({ name, value: '' });
|
||||
} else {
|
||||
onChange({ name, value: defaultValue.key });
|
||||
}
|
||||
}
|
||||
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
value={value ?? ''}
|
||||
values={values}
|
||||
selectedValueComponent={RootFolderSelectInputSelectedValue}
|
||||
optionComponent={RootFolderSelectInputOption}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={isAddNewRootFolderModalOpen}
|
||||
name="rootFolderPath"
|
||||
value=""
|
||||
onChange={handleNewRootFolderSelect}
|
||||
onModalClose={handleAddRootFolderModalClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default RootFolderSelectInput;
|
||||
@@ -0,0 +1,35 @@
|
||||
.optionText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 1 0 0;
|
||||
|
||||
&.isMobile {
|
||||
display: block;
|
||||
|
||||
.freeSpace {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.seriesFolder {
|
||||
flex: 0 0 auto;
|
||||
color: var(--disabledColor);
|
||||
}
|
||||
|
||||
.freeSpace {
|
||||
margin-left: 15px;
|
||||
color: var(--darkGray);
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
|
||||
.isMissing {
|
||||
margin-left: 15px;
|
||||
color: var(--dangerColor);
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'freeSpace': string;
|
||||
'isMissing': string;
|
||||
'isMobile': string;
|
||||
'optionText': string;
|
||||
'seriesFolder': string;
|
||||
'value': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,67 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInputOption, {
|
||||
EnhancedSelectInputOptionProps,
|
||||
} from './EnhancedSelectInputOption';
|
||||
import styles from './RootFolderSelectInputOption.css';
|
||||
|
||||
interface RootFolderSelectInputOptionProps
|
||||
extends EnhancedSelectInputOptionProps {
|
||||
id: string;
|
||||
value: string;
|
||||
freeSpace?: number;
|
||||
isMissing?: boolean;
|
||||
seriesFolder?: string;
|
||||
isMobile: boolean;
|
||||
isWindows?: boolean;
|
||||
}
|
||||
|
||||
function RootFolderSelectInputOption(props: RootFolderSelectInputOptionProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
freeSpace,
|
||||
isMissing,
|
||||
seriesFolder,
|
||||
isMobile,
|
||||
isWindows,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const slashCharacter = isWindows ? '\\' : '/';
|
||||
|
||||
return (
|
||||
<EnhancedSelectInputOption id={id} isMobile={isMobile} {...otherProps}>
|
||||
<div
|
||||
className={classNames(styles.optionText, isMobile && styles.isMobile)}
|
||||
>
|
||||
<div className={styles.value}>
|
||||
{value}
|
||||
|
||||
{seriesFolder && id !== 'addNew' ? (
|
||||
<div className={styles.seriesFolder}>
|
||||
{slashCharacter}
|
||||
{seriesFolder}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{freeSpace == null ? null : (
|
||||
<div className={styles.freeSpace}>
|
||||
{translate('RootFolderSelectFreeSpace', {
|
||||
freeSpace: formatBytes(freeSpace),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMissing ? (
|
||||
<div className={styles.isMissing}>{translate('Missing')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</EnhancedSelectInputOption>
|
||||
);
|
||||
}
|
||||
|
||||
export default RootFolderSelectInputOption;
|
||||
@@ -0,0 +1,32 @@
|
||||
.selectedValue {
|
||||
composes: selectedValue from '~./EnhancedSelectInputSelectedValue.css';
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pathContainer {
|
||||
@add-mixin truncate;
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.path {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.seriesFolder {
|
||||
@add-mixin truncate;
|
||||
flex: 0 1 auto;
|
||||
color: var(--disabledColor);
|
||||
}
|
||||
|
||||
.freeSpace {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 15px;
|
||||
color: var(--gray);
|
||||
text-align: right;
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'freeSpace': string;
|
||||
'path': string;
|
||||
'pathContainer': string;
|
||||
'selectedValue': string;
|
||||
'seriesFolder': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
|
||||
import { RootFolderSelectInputValue } from './RootFolderSelectInput';
|
||||
import styles from './RootFolderSelectInputSelectedValue.css';
|
||||
|
||||
interface RootFolderSelectInputSelectedValueProps {
|
||||
selectedValue: string;
|
||||
values: RootFolderSelectInputValue[];
|
||||
freeSpace?: number;
|
||||
seriesFolder?: string;
|
||||
isWindows?: boolean;
|
||||
includeFreeSpace?: boolean;
|
||||
}
|
||||
|
||||
function RootFolderSelectInputSelectedValue(
|
||||
props: RootFolderSelectInputSelectedValueProps
|
||||
) {
|
||||
const {
|
||||
selectedValue,
|
||||
values,
|
||||
freeSpace,
|
||||
seriesFolder,
|
||||
includeFreeSpace = true,
|
||||
isWindows,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const slashCharacter = isWindows ? '\\' : '/';
|
||||
const value = values.find((v) => v.key === selectedValue)?.value;
|
||||
|
||||
return (
|
||||
<EnhancedSelectInputSelectedValue
|
||||
className={styles.selectedValue}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.pathContainer}>
|
||||
<div className={styles.path}>{value}</div>
|
||||
|
||||
{seriesFolder ? (
|
||||
<div className={styles.seriesFolder}>
|
||||
{slashCharacter}
|
||||
{seriesFolder}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{freeSpace != null && includeFreeSpace ? (
|
||||
<div className={styles.freeSpace}>
|
||||
{translate('RootFolderSelectFreeSpace', {
|
||||
freeSpace: formatBytes(freeSpace),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
);
|
||||
}
|
||||
|
||||
export default RootFolderSelectInputSelectedValue;
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import * as seriesTypes from 'Utilities/Series/seriesTypes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputProps,
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
import SeriesTypeSelectInputOption from './SeriesTypeSelectInputOption';
|
||||
import SeriesTypeSelectInputSelectedValue from './SeriesTypeSelectInputSelectedValue';
|
||||
|
||||
interface SeriesTypeSelectInputProps
|
||||
extends EnhancedSelectInputProps<EnhancedSelectInputValue<string>, string> {
|
||||
includeNoChange: boolean;
|
||||
includeNoChangeDisabled?: boolean;
|
||||
includeMixed: boolean;
|
||||
}
|
||||
|
||||
export interface ISeriesTypeOption {
|
||||
key: string;
|
||||
value: string;
|
||||
format?: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const seriesTypeOptions: ISeriesTypeOption[] = [
|
||||
{
|
||||
key: seriesTypes.STANDARD,
|
||||
value: 'Standard',
|
||||
get format() {
|
||||
return translate('StandardEpisodeTypeFormat', { format: 'S01E05' });
|
||||
},
|
||||
},
|
||||
{
|
||||
key: seriesTypes.DAILY,
|
||||
value: 'Daily / Date',
|
||||
get format() {
|
||||
return translate('DailyEpisodeTypeFormat', { format: '2020-05-25' });
|
||||
},
|
||||
},
|
||||
{
|
||||
key: seriesTypes.ANIME,
|
||||
value: 'Anime / Absolute',
|
||||
get format() {
|
||||
return translate('AnimeEpisodeTypeFormat', { format: '005' });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
|
||||
const {
|
||||
includeNoChange = false,
|
||||
includeNoChangeDisabled = true,
|
||||
includeMixed = false,
|
||||
} = props;
|
||||
|
||||
const values = useMemo(() => {
|
||||
const result = [...seriesTypeOptions];
|
||||
|
||||
if (includeNoChange) {
|
||||
result.unshift({
|
||||
key: 'noChange',
|
||||
value: translate('NoChange'),
|
||||
isDisabled: includeNoChangeDisabled,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
result.unshift({
|
||||
key: 'mixed',
|
||||
value: `(${translate('Mixed')})`,
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [includeNoChange, includeNoChangeDisabled, includeMixed]);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...props}
|
||||
values={values}
|
||||
optionComponent={SeriesTypeSelectInputOption}
|
||||
selectedValueComponent={SeriesTypeSelectInputSelectedValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesTypeSelectInput;
|
||||
@@ -0,0 +1,24 @@
|
||||
.optionText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 1 0 0;
|
||||
|
||||
&.isMobile {
|
||||
display: block;
|
||||
|
||||
.format {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format {
|
||||
margin-left: 15px;
|
||||
color: var(--darkGray);
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'format': string;
|
||||
'isMobile': string;
|
||||
'optionText': string;
|
||||
'value': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,32 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import EnhancedSelectInputOption, {
|
||||
EnhancedSelectInputOptionProps,
|
||||
} from './EnhancedSelectInputOption';
|
||||
import styles from './SeriesTypeSelectInputOption.css';
|
||||
|
||||
interface SeriesTypeSelectInputOptionProps
|
||||
extends EnhancedSelectInputOptionProps {
|
||||
id: string;
|
||||
value: string;
|
||||
format: string;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
function SeriesTypeSelectInputOption(props: SeriesTypeSelectInputOptionProps) {
|
||||
const { id, value, format, isMobile, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<EnhancedSelectInputOption {...otherProps} id={id} isMobile={isMobile}>
|
||||
<div
|
||||
className={classNames(styles.optionText, isMobile && styles.isMobile)}
|
||||
>
|
||||
<div className={styles.value}>{value}</div>
|
||||
|
||||
{format == null ? null : <div className={styles.format}>{format}</div>}
|
||||
</div>
|
||||
</EnhancedSelectInputOption>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesTypeSelectInputOption;
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
|
||||
import { ISeriesTypeOption } from './SeriesTypeSelectInput';
|
||||
|
||||
interface SeriesTypeSelectInputOptionProps {
|
||||
selectedValue: string;
|
||||
values: ISeriesTypeOption[];
|
||||
format: string;
|
||||
}
|
||||
function SeriesTypeSelectInputSelectedValue(
|
||||
props: SeriesTypeSelectInputOptionProps
|
||||
) {
|
||||
const { selectedValue, values, ...otherProps } = props;
|
||||
const format = values.find((v) => v.key === selectedValue)?.format;
|
||||
|
||||
return (
|
||||
<HintedSelectInputSelectedValue
|
||||
{...otherProps}
|
||||
selectedValue={selectedValue}
|
||||
values={values}
|
||||
hint={format}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesTypeSelectInputSelectedValue;
|
||||
@@ -0,0 +1,53 @@
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inputFolder {
|
||||
composes: input from '~Components/Form/Input.css';
|
||||
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.inputUnitWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputUnit {
|
||||
composes: inputUnit from '~Components/Form/FormInputGroup.css';
|
||||
|
||||
right: 40px;
|
||||
font-family: $monoSpaceFontFamily;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-family: $monoSpaceFontFamily;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-top: 5px;
|
||||
margin-left: 17px;
|
||||
line-height: 20px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
|
||||
label {
|
||||
flex: 0 0 50px;
|
||||
}
|
||||
|
||||
.value {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.unit {
|
||||
width: 90px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
background-color: var(--inputReadOnlyBackgroundColor);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'details': string;
|
||||
'inputFolder': string;
|
||||
'inputUnit': string;
|
||||
'inputUnitWrapper': string;
|
||||
'inputWrapper': string;
|
||||
'readOnly': string;
|
||||
'unit': string;
|
||||
'value': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable no-bitwise */
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { SyntheticEvent } from 'react';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
import styles from './UMaskInput.css';
|
||||
|
||||
const umaskOptions = [
|
||||
{
|
||||
key: '755',
|
||||
get value() {
|
||||
return translate('Umask755Description', { octal: '755' });
|
||||
},
|
||||
hint: 'drwxr-xr-x',
|
||||
},
|
||||
{
|
||||
key: '775',
|
||||
get value() {
|
||||
return translate('Umask775Description', { octal: '775' });
|
||||
},
|
||||
hint: 'drwxrwxr-x',
|
||||
},
|
||||
{
|
||||
key: '770',
|
||||
get value() {
|
||||
return translate('Umask770Description', { octal: '770' });
|
||||
},
|
||||
hint: 'drwxrwx---',
|
||||
},
|
||||
{
|
||||
key: '750',
|
||||
get value() {
|
||||
return translate('Umask750Description', { octal: '750' });
|
||||
},
|
||||
hint: 'drwxr-x---',
|
||||
},
|
||||
{
|
||||
key: '777',
|
||||
get value() {
|
||||
return translate('Umask777Description', { octal: '777' });
|
||||
},
|
||||
hint: 'drwxrwxrwx',
|
||||
},
|
||||
];
|
||||
|
||||
function formatPermissions(permissions: number) {
|
||||
const hasSticky = permissions & 0o1000;
|
||||
const hasSetGID = permissions & 0o2000;
|
||||
const hasSetUID = permissions & 0o4000;
|
||||
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const bit = (permissions & (1 << i)) !== 0;
|
||||
let digit = bit ? 'xwr'[i % 3] : '-';
|
||||
if (i === 6 && hasSetUID) {
|
||||
digit = bit ? 's' : 'S';
|
||||
} else if (i === 3 && hasSetGID) {
|
||||
digit = bit ? 's' : 'S';
|
||||
} else if (i === 0 && hasSticky) {
|
||||
digit = bit ? 't' : 'T';
|
||||
}
|
||||
result = digit + result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface UMaskInputProps {
|
||||
name: string;
|
||||
value: string;
|
||||
hasError?: boolean;
|
||||
hasWarning?: boolean;
|
||||
onChange: (change: InputChanged) => void;
|
||||
onFocus?: (event: SyntheticEvent) => void;
|
||||
onBlur?: (event: SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
function UMaskInput({ name, value, onChange }: UMaskInputProps) {
|
||||
const valueNum = parseInt(value, 8);
|
||||
const umaskNum = 0o777 & ~valueNum;
|
||||
const umask = umaskNum.toString(8).padStart(4, '0');
|
||||
const folderNum = 0o777 & ~umaskNum;
|
||||
const folder = folderNum.toString(8).padStart(3, '0');
|
||||
const fileNum = 0o666 & ~umaskNum;
|
||||
const file = fileNum.toString(8).padStart(3, '0');
|
||||
const unit = formatPermissions(folderNum);
|
||||
|
||||
const values = umaskOptions.map((v) => {
|
||||
return { ...v, hint: <span className={styles.unit}>{v.hint}</span> };
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={styles.inputUnitWrapper}>
|
||||
<EnhancedSelectInput
|
||||
name={name}
|
||||
value={value}
|
||||
values={values}
|
||||
isEditable={true}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<div className={styles.inputUnit}>d{unit}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
<label>{translate('Umask')}</label>
|
||||
<div className={styles.value}>{umask}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{translate('Folder')}</label>
|
||||
<div className={styles.value}>{folder}</div>
|
||||
<div className={styles.unit}>d{formatPermissions(folderNum)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{translate('File')}</label>
|
||||
<div className={styles.value}>{file}</div>
|
||||
<div className={styles.unit}>{formatPermissions(fileNum)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UMaskInput.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func,
|
||||
};
|
||||
|
||||
export default UMaskInput;
|
||||
Reference in New Issue
Block a user