1
0
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:
Mark McDowall
2024-10-26 14:54:23 -07:00
committed by GitHub
parent c114e2ddb7
commit 682d2b4e1b
158 changed files with 5225 additions and 6112 deletions
@@ -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;
}
@@ -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);
}
+14
View File
@@ -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;