parent
86e44731bb
commit
9fe13a2d14
@ -0,0 +1,33 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
|
||||||
|
function AlbumFormats({ formats }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
formats.map((format) => {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
key={format.id}
|
||||||
|
kind={kinds.INFO}
|
||||||
|
>
|
||||||
|
{format.name}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumFormats.propTypes = {
|
||||||
|
formats: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
AlbumFormats.defaultProps = {
|
||||||
|
formats: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlbumFormats;
|
@ -0,0 +1,19 @@
|
|||||||
|
.input {
|
||||||
|
composes: input from '~Components/Form/Input.css';
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readOnly {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hasError {
|
||||||
|
composes: hasError from '~Components/Form/Input.css';
|
||||||
|
}
|
||||||
|
|
||||||
|
.hasWarning {
|
||||||
|
composes: hasWarning from '~Components/Form/Input.css';
|
||||||
|
}
|
@ -0,0 +1,172 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import styles from './TextArea.css';
|
||||||
|
|
||||||
|
class TextArea extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this._input = null;
|
||||||
|
this._selectionStart = null;
|
||||||
|
this._selectionEnd = null;
|
||||||
|
this._selectionTimeout = null;
|
||||||
|
this._isMouseTarget = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
window.addEventListener('mouseup', this.onDocumentMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
window.removeEventListener('mouseup', this.onDocumentMouseUp);
|
||||||
|
|
||||||
|
if (this._selectionTimeout) {
|
||||||
|
this._selectionTimeout = clearTimeout(this._selectionTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Control
|
||||||
|
|
||||||
|
setInputRef = (ref) => {
|
||||||
|
this._input = ref;
|
||||||
|
};
|
||||||
|
|
||||||
|
selectionChange() {
|
||||||
|
if (this._selectionTimeout) {
|
||||||
|
this._selectionTimeout = clearTimeout(this._selectionTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._selectionTimeout = setTimeout(() => {
|
||||||
|
const selectionStart = this._input.selectionStart;
|
||||||
|
const selectionEnd = this._input.selectionEnd;
|
||||||
|
|
||||||
|
const selectionChanged = (
|
||||||
|
this._selectionStart !== selectionStart ||
|
||||||
|
this._selectionEnd !== selectionEnd
|
||||||
|
);
|
||||||
|
|
||||||
|
this._selectionStart = selectionStart;
|
||||||
|
this._selectionEnd = selectionEnd;
|
||||||
|
|
||||||
|
if (this.props.onSelectionChange && selectionChanged) {
|
||||||
|
this.props.onSelectionChange(selectionStart, selectionEnd);
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onChange = (event) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
onChange
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
value: event.target.value
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
onFocus = (event) => {
|
||||||
|
if (this.props.onFocus) {
|
||||||
|
this.props.onFocus(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectionChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
onKeyUp = () => {
|
||||||
|
this.selectionChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMouseDown = () => {
|
||||||
|
this._isMouseTarget = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMouseUp = () => {
|
||||||
|
this.selectionChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
onDocumentMouseUp = () => {
|
||||||
|
if (this._isMouseTarget) {
|
||||||
|
this.selectionChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isMouseTarget = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
readOnly,
|
||||||
|
autoFocus,
|
||||||
|
placeholder,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
hasError,
|
||||||
|
hasWarning,
|
||||||
|
onBlur
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={this.setInputRef}
|
||||||
|
readOnly={readOnly}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
readOnly && styles.readOnly,
|
||||||
|
hasError && styles.hasError,
|
||||||
|
hasWarning && styles.hasWarning
|
||||||
|
)}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={this.onChange}
|
||||||
|
onFocus={this.onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onKeyUp={this.onKeyUp}
|
||||||
|
onMouseDown={this.onMouseDown}
|
||||||
|
onMouseUp={this.onMouseUp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextArea.propTypes = {
|
||||||
|
className: PropTypes.string.isRequired,
|
||||||
|
readOnly: PropTypes.bool,
|
||||||
|
autoFocus: PropTypes.bool,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
|
||||||
|
hasError: PropTypes.bool,
|
||||||
|
hasWarning: PropTypes.bool,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
onFocus: PropTypes.func,
|
||||||
|
onBlur: PropTypes.func,
|
||||||
|
onSelectionChange: PropTypes.func
|
||||||
|
};
|
||||||
|
|
||||||
|
TextArea.defaultProps = {
|
||||||
|
className: styles.input,
|
||||||
|
type: 'text',
|
||||||
|
readOnly: false,
|
||||||
|
autoFocus: false,
|
||||||
|
value: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextArea;
|
@ -0,0 +1,32 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import { DndProvider } from 'react-dnd';
|
||||||
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
|
import PageContent from 'Components/Page/PageContent';
|
||||||
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
|
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||||
|
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
|
||||||
|
|
||||||
|
class CustomFormatSettingsConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<PageContent title="Custom Format Settings">
|
||||||
|
<SettingsToolbarConnector
|
||||||
|
showSave={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageContentBody>
|
||||||
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
<CustomFormatsConnector />
|
||||||
|
</DndProvider>
|
||||||
|
</PageContentBody>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomFormatSettingsConnector;
|
||||||
|
|
@ -0,0 +1,49 @@
|
|||||||
|
.customFormat {
|
||||||
|
composes: card from '~Components/Card.css';
|
||||||
|
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
@add-mixin truncate;
|
||||||
|
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloneButton {
|
||||||
|
composes: button from '~Components/Link/IconButton.css';
|
||||||
|
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 5px;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipLabel {
|
||||||
|
composes: label from '~Components/Label.css';
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@add-mixin truncate;
|
||||||
|
composes: label from '~Components/Label.css';
|
||||||
|
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
@ -0,0 +1,174 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Card from 'Components/Card';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
|
import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
|
||||||
|
import ExportCustomFormatModal from './ExportCustomFormatModal';
|
||||||
|
import styles from './CustomFormat.css';
|
||||||
|
|
||||||
|
class CustomFormat extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isEditCustomFormatModalOpen: false,
|
||||||
|
isExportCustomFormatModalOpen: false,
|
||||||
|
isDeleteCustomFormatModalOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onEditCustomFormatPress = () => {
|
||||||
|
this.setState({ isEditCustomFormatModalOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
onEditCustomFormatModalClose = () => {
|
||||||
|
this.setState({ isEditCustomFormatModalOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
onExportCustomFormatPress = () => {
|
||||||
|
this.setState({ isExportCustomFormatModalOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
onExportCustomFormatModalClose = () => {
|
||||||
|
this.setState({ isExportCustomFormatModalOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
onDeleteCustomFormatPress = () => {
|
||||||
|
this.setState({
|
||||||
|
isEditCustomFormatModalOpen: false,
|
||||||
|
isDeleteCustomFormatModalOpen: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onDeleteCustomFormatModalClose = () => {
|
||||||
|
this.setState({ isDeleteCustomFormatModalOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
onConfirmDeleteCustomFormat = () => {
|
||||||
|
this.props.onConfirmDeleteCustomFormat(this.props.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
onCloneCustomFormatPress = () => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
onCloneCustomFormatPress
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
onCloneCustomFormatPress(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
specifications,
|
||||||
|
isDeleting
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={styles.customFormat}
|
||||||
|
overlayContent={true}
|
||||||
|
onPress={this.onEditCustomFormatPress}
|
||||||
|
>
|
||||||
|
<div className={styles.nameContainer}>
|
||||||
|
<div className={styles.name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<IconButton
|
||||||
|
className={styles.cloneButton}
|
||||||
|
title="Clone Custom Format"
|
||||||
|
name={icons.CLONE}
|
||||||
|
onPress={this.onCloneCustomFormatPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className={styles.cloneButton}
|
||||||
|
title="Export Custom Format"
|
||||||
|
name={icons.EXPORT}
|
||||||
|
onPress={this.onExportCustomFormatPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
specifications.map((item, index) => {
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = kinds.DEFAULT;
|
||||||
|
if (item.required) {
|
||||||
|
kind = kinds.SUCCESS;
|
||||||
|
}
|
||||||
|
if (item.negate) {
|
||||||
|
kind = kinds.DANGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
className={styles.label}
|
||||||
|
key={index}
|
||||||
|
kind={kind}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditCustomFormatModalConnector
|
||||||
|
id={id}
|
||||||
|
isOpen={this.state.isEditCustomFormatModalOpen}
|
||||||
|
onModalClose={this.onEditCustomFormatModalClose}
|
||||||
|
onDeleteCustomFormatPress={this.onDeleteCustomFormatPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExportCustomFormatModal
|
||||||
|
id={id}
|
||||||
|
isOpen={this.state.isExportCustomFormatModalOpen}
|
||||||
|
onModalClose={this.onExportCustomFormatModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={this.state.isDeleteCustomFormatModalOpen}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
title="Delete Custom Format"
|
||||||
|
message={`Are you sure you want to delete the custom format '${name}'?`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
isSpinning={isDeleting}
|
||||||
|
onConfirm={this.onConfirmDeleteCustomFormat}
|
||||||
|
onCancel={this.onDeleteCustomFormatModalClose}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomFormat.propTypes = {
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
specifications: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
isDeleting: PropTypes.bool.isRequired,
|
||||||
|
onConfirmDeleteCustomFormat: PropTypes.func.isRequired,
|
||||||
|
onCloneCustomFormatPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomFormat;
|
@ -0,0 +1,21 @@
|
|||||||
|
.customFormats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCustomFormat {
|
||||||
|
composes: customFormat from '~./CustomFormat.css';
|
||||||
|
|
||||||
|
background-color: var(--cardAlternateBackgroundColor);
|
||||||
|
color: var(--gray);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 20px 0;
|
||||||
|
border: 1px solid var(--borderColor);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--cardCenterBackgroundColor);
|
||||||
|
}
|
@ -0,0 +1,115 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Card from 'Components/Card';
|
||||||
|
import FieldSet from 'Components/FieldSet';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import CustomFormat from './CustomFormat';
|
||||||
|
import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
|
||||||
|
import styles from './CustomFormats.css';
|
||||||
|
|
||||||
|
class CustomFormats extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isCustomFormatModalOpen: false,
|
||||||
|
tagsFromId: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onCloneCustomFormatPress = (id) => {
|
||||||
|
this.props.onCloneCustomFormatPress(id);
|
||||||
|
this.setState({
|
||||||
|
isCustomFormatModalOpen: true,
|
||||||
|
tagsFromId: id
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onEditCustomFormatPress = () => {
|
||||||
|
this.setState({ isCustomFormatModalOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
onModalClose = () => {
|
||||||
|
this.setState({
|
||||||
|
isCustomFormatModalOpen: false,
|
||||||
|
tagsFromId: undefined
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
isDeleting,
|
||||||
|
onConfirmDeleteCustomFormat,
|
||||||
|
onCloneCustomFormatPress,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldSet legend="Custom Formats">
|
||||||
|
<PageSectionContent
|
||||||
|
errorMessage="Unable to load custom formats"
|
||||||
|
{...otherProps}c={true}
|
||||||
|
>
|
||||||
|
<div className={styles.customFormats}>
|
||||||
|
{
|
||||||
|
items.map((item) => {
|
||||||
|
return (
|
||||||
|
<CustomFormat
|
||||||
|
key={item.id}
|
||||||
|
{...item}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
onConfirmDeleteCustomFormat={onConfirmDeleteCustomFormat}
|
||||||
|
onCloneCustomFormatPress={this.onCloneCustomFormatPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={styles.addCustomFormat}
|
||||||
|
onPress={this.onEditCustomFormatPress}
|
||||||
|
>
|
||||||
|
<div className={styles.center}>
|
||||||
|
<Icon
|
||||||
|
name={icons.ADD}
|
||||||
|
size={45}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditCustomFormatModalConnector
|
||||||
|
isOpen={this.state.isCustomFormatModalOpen}
|
||||||
|
tagsFromId={this.state.tagsFromId}
|
||||||
|
onModalClose={this.onModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</PageSectionContent>
|
||||||
|
</FieldSet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomFormats.propTypes = {
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
isDeleting: PropTypes.bool.isRequired,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
onConfirmDeleteCustomFormat: PropTypes.func.isRequired,
|
||||||
|
onCloneCustomFormatPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomFormats;
|
@ -0,0 +1,63 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { cloneCustomFormat, deleteCustomFormat, fetchCustomFormats } from 'Store/Actions/settingsActions';
|
||||||
|
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||||
|
import sortByName from 'Utilities/Array/sortByName';
|
||||||
|
import CustomFormats from './CustomFormats';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
createSortedSectionSelector('settings.customFormats', sortByName),
|
||||||
|
(customFormats) => customFormats
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
dispatchFetchCustomFormats: fetchCustomFormats,
|
||||||
|
dispatchDeleteCustomFormat: deleteCustomFormat,
|
||||||
|
dispatchCloneCustomFormat: cloneCustomFormat
|
||||||
|
};
|
||||||
|
|
||||||
|
class CustomFormatsConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.dispatchFetchCustomFormats();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onConfirmDeleteCustomFormat = (id) => {
|
||||||
|
this.props.dispatchDeleteCustomFormat({ id });
|
||||||
|
};
|
||||||
|
|
||||||
|
onCloneCustomFormatPress = (id) => {
|
||||||
|
this.props.dispatchCloneCustomFormat({ id });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<CustomFormats
|
||||||
|
onConfirmDeleteCustomFormat={this.onConfirmDeleteCustomFormat}
|
||||||
|
onCloneCustomFormatPress={this.onCloneCustomFormatPress}
|
||||||
|
{...this.props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomFormatsConnector.propTypes = {
|
||||||
|
dispatchFetchCustomFormats: PropTypes.func.isRequired,
|
||||||
|
dispatchDeleteCustomFormat: PropTypes.func.isRequired,
|
||||||
|
dispatchCloneCustomFormat: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(CustomFormatsConnector);
|
@ -0,0 +1,61 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import EditCustomFormatModalContentConnector from './EditCustomFormatModalContentConnector';
|
||||||
|
|
||||||
|
class EditCustomFormatModal extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
height: 'auto'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onContentHeightChange = (height) => {
|
||||||
|
if (this.state.height === 'auto' || height > this.state.height) {
|
||||||
|
this.setState({ height });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
onModalClose,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
style={{ height: `${this.state.height}px` }}
|
||||||
|
isOpen={isOpen}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<EditCustomFormatModalContentConnector
|
||||||
|
{...otherProps}
|
||||||
|
onContentHeightChange={this.onContentHeightChange}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditCustomFormatModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditCustomFormatModal;
|
@ -0,0 +1,43 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||||
|
import EditCustomFormatModal from './EditCustomFormatModal';
|
||||||
|
|
||||||
|
function mapStateToProps() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
clearPendingChanges
|
||||||
|
};
|
||||||
|
|
||||||
|
class EditCustomFormatModalConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onModalClose = () => {
|
||||||
|
this.props.clearPendingChanges({ section: 'settings.customFormats' });
|
||||||
|
this.props.onModalClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<EditCustomFormatModal
|
||||||
|
{...this.props}
|
||||||
|
onModalClose={this.onModalClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditCustomFormatModalConnector.propTypes = {
|
||||||
|
onModalClose: PropTypes.func.isRequired,
|
||||||
|
clearPendingChanges: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(EditCustomFormatModalConnector);
|
@ -0,0 +1,27 @@
|
|||||||
|
.deleteButton {
|
||||||
|
composes: button from '~Components/Link/Button.css';
|
||||||
|
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightButtons {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addSpecification {
|
||||||
|
composes: customFormat from '~./CustomFormat.css';
|
||||||
|
|
||||||
|
background-color: var(--cardAlternateBackgroundColor);
|
||||||
|
color: var(--gray);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 20px 0;
|
||||||
|
border: 1px solid var(--borderColor);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--cardCenterBackgroundColor);
|
||||||
|
}
|
@ -0,0 +1,256 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Card from 'Components/Card';
|
||||||
|
import FieldSet from 'Components/FieldSet';
|
||||||
|
import Form from 'Components/Form/Form';
|
||||||
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import { icons, inputTypes, kinds } from 'Helpers/Props';
|
||||||
|
import ImportCustomFormatModal from './ImportCustomFormatModal';
|
||||||
|
import AddSpecificationModal from './Specifications/AddSpecificationModal';
|
||||||
|
import EditSpecificationModalConnector from './Specifications/EditSpecificationModalConnector';
|
||||||
|
import Specification from './Specifications/Specification';
|
||||||
|
import styles from './EditCustomFormatModalContent.css';
|
||||||
|
|
||||||
|
class EditCustomFormatModalContent extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isAddSpecificationModalOpen: false,
|
||||||
|
isEditSpecificationModalOpen: false,
|
||||||
|
isImportCustomFormatModalOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onAddSpecificationPress = () => {
|
||||||
|
this.setState({ isAddSpecificationModalOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
onAddSpecificationModalClose = ({ specificationSelected = false } = {}) => {
|
||||||
|
this.setState({
|
||||||
|
isAddSpecificationModalOpen: false,
|
||||||
|
isEditSpecificationModalOpen: specificationSelected
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onEditSpecificationModalClose = () => {
|
||||||
|
this.setState({ isEditSpecificationModalOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
onImportPress = () => {
|
||||||
|
this.setState({ isImportCustomFormatModalOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
onImportCustomFormatModalClose = () => {
|
||||||
|
this.setState({ isImportCustomFormatModalOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
isSaving,
|
||||||
|
saveError,
|
||||||
|
item,
|
||||||
|
specificationsPopulated,
|
||||||
|
specifications,
|
||||||
|
onInputChange,
|
||||||
|
onSavePress,
|
||||||
|
onModalClose,
|
||||||
|
onDeleteCustomFormatPress,
|
||||||
|
onCloneSpecificationPress,
|
||||||
|
onConfirmDeleteSpecification,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isAddSpecificationModalOpen,
|
||||||
|
isEditSpecificationModalOpen,
|
||||||
|
isImportCustomFormatModalOpen
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
includeCustomFormatWhenRenaming
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
|
||||||
|
<ModalHeader>
|
||||||
|
{id ? 'Edit Custom Format' : 'Add Custom Format'}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
isFetching &&
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !!error &&
|
||||||
|
<div>
|
||||||
|
{'Unable to add a new custom format, please try again.'}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !error && specificationsPopulated &&
|
||||||
|
<div>
|
||||||
|
<Form
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
Name
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.TEXT}
|
||||||
|
name="name"
|
||||||
|
{...name}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{'Include Custom Format when Renaming'}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="includeCustomFormatWhenRenaming"
|
||||||
|
helpText={'Include in {Custom Formats} renaming format'}
|
||||||
|
{...includeCustomFormatWhenRenaming}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<FieldSet legend={'Conditions'}>
|
||||||
|
<div className={styles.customFormats}>
|
||||||
|
{
|
||||||
|
specifications.map((tag) => {
|
||||||
|
return (
|
||||||
|
<Specification
|
||||||
|
key={tag.id}
|
||||||
|
{...tag}
|
||||||
|
onCloneSpecificationPress={onCloneSpecificationPress}
|
||||||
|
onConfirmDeleteSpecification={onConfirmDeleteSpecification}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={styles.addSpecification}
|
||||||
|
onPress={this.onAddSpecificationPress}
|
||||||
|
>
|
||||||
|
<div className={styles.center}>
|
||||||
|
<Icon
|
||||||
|
name={icons.ADD}
|
||||||
|
size={45}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</FieldSet>
|
||||||
|
|
||||||
|
<AddSpecificationModal
|
||||||
|
isOpen={isAddSpecificationModalOpen}
|
||||||
|
onModalClose={this.onAddSpecificationModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditSpecificationModalConnector
|
||||||
|
isOpen={isEditSpecificationModalOpen}
|
||||||
|
onModalClose={this.onEditSpecificationModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImportCustomFormatModal
|
||||||
|
isOpen={isImportCustomFormatModalOpen}
|
||||||
|
onModalClose={this.onImportCustomFormatModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div className={styles.rightButtons}>
|
||||||
|
{
|
||||||
|
id &&
|
||||||
|
<Button
|
||||||
|
className={styles.deleteButton}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
onPress={onDeleteCustomFormatPress}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={styles.deleteButton}
|
||||||
|
onPress={this.onImportPress}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={onModalClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SpinnerErrorButton
|
||||||
|
isSpinning={isSaving}
|
||||||
|
error={saveError}
|
||||||
|
onPress={onSavePress}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</SpinnerErrorButton>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditCustomFormatModalContent.propTypes = {
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
isSaving: PropTypes.bool.isRequired,
|
||||||
|
saveError: PropTypes.object,
|
||||||
|
item: PropTypes.object.isRequired,
|
||||||
|
specificationsPopulated: PropTypes.bool.isRequired,
|
||||||
|
specifications: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
onInputChange: PropTypes.func.isRequired,
|
||||||
|
onSavePress: PropTypes.func.isRequired,
|
||||||
|
onContentHeightChange: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired,
|
||||||
|
onDeleteCustomFormatPress: PropTypes.func,
|
||||||
|
onCloneSpecificationPress: PropTypes.func.isRequired,
|
||||||
|
onConfirmDeleteSpecification: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditCustomFormatModalContent;
|
@ -0,0 +1,102 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { cloneCustomFormatSpecification, deleteCustomFormatSpecification, fetchCustomFormatSpecifications, saveCustomFormat, setCustomFormatValue } from 'Store/Actions/settingsActions';
|
||||||
|
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
|
||||||
|
import EditCustomFormatModalContent from './EditCustomFormatModalContent';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.settings.advancedSettings,
|
||||||
|
createProviderSettingsSelector('customFormats'),
|
||||||
|
(state) => state.settings.customFormatSpecifications,
|
||||||
|
(advancedSettings, customFormat, specifications) => {
|
||||||
|
return {
|
||||||
|
advancedSettings,
|
||||||
|
...customFormat,
|
||||||
|
specificationsPopulated: specifications.isPopulated,
|
||||||
|
specifications: specifications.items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
setCustomFormatValue,
|
||||||
|
saveCustomFormat,
|
||||||
|
fetchCustomFormatSpecifications,
|
||||||
|
cloneCustomFormatSpecification,
|
||||||
|
deleteCustomFormatSpecification
|
||||||
|
};
|
||||||
|
|
||||||
|
class EditCustomFormatModalContentConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
tagsFromId
|
||||||
|
} = this.props;
|
||||||
|
this.props.fetchCustomFormatSpecifications({ id: tagsFromId || id });
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||||
|
this.props.onModalClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onInputChange = ({ name, value }) => {
|
||||||
|
this.props.setCustomFormatValue({ name, value });
|
||||||
|
};
|
||||||
|
|
||||||
|
onSavePress = () => {
|
||||||
|
this.props.saveCustomFormat({ id: this.props.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
onCloneSpecificationPress = (id) => {
|
||||||
|
this.props.cloneCustomFormatSpecification({ id });
|
||||||
|
};
|
||||||
|
|
||||||
|
onConfirmDeleteSpecification = (id) => {
|
||||||
|
this.props.deleteCustomFormatSpecification({ id });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<EditCustomFormatModalContent
|
||||||
|
{...this.props}
|
||||||
|
onSavePress={this.onSavePress}
|
||||||
|
onInputChange={this.onInputChange}
|
||||||
|
onCloneSpecificationPress={this.onCloneSpecificationPress}
|
||||||
|
onConfirmDeleteSpecification={this.onConfirmDeleteSpecification}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditCustomFormatModalContentConnector.propTypes = {
|
||||||
|
id: PropTypes.number,
|
||||||
|
tagsFromId: PropTypes.number,
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
isSaving: PropTypes.bool.isRequired,
|
||||||
|
saveError: PropTypes.object,
|
||||||
|
item: PropTypes.object.isRequired,
|
||||||
|
setCustomFormatValue: PropTypes.func.isRequired,
|
||||||
|
saveCustomFormat: PropTypes.func.isRequired,
|
||||||
|
fetchCustomFormatSpecifications: PropTypes.func.isRequired,
|
||||||
|
cloneCustomFormatSpecification: PropTypes.func.isRequired,
|
||||||
|
deleteCustomFormatSpecification: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(EditCustomFormatModalContentConnector);
|
@ -0,0 +1,61 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import ExportCustomFormatModalContentConnector from './ExportCustomFormatModalContentConnector';
|
||||||
|
|
||||||
|
class ExportCustomFormatModal extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
height: 'auto'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onContentHeightChange = (height) => {
|
||||||
|
if (this.state.height === 'auto' || height > this.state.height) {
|
||||||
|
this.setState({ height });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
onModalClose,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
style={{ height: `${this.state.height}px` }}
|
||||||
|
isOpen={isOpen}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<ExportCustomFormatModalContentConnector
|
||||||
|
{...otherProps}
|
||||||
|
onContentHeightChange={this.onContentHeightChange}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExportCustomFormatModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExportCustomFormatModal;
|
@ -0,0 +1,5 @@
|
|||||||
|
.button {
|
||||||
|
composes: button from '~Components/Link/Button.css';
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import styles from './ExportCustomFormatModalContent.css';
|
||||||
|
|
||||||
|
class ExportCustomFormatModalContent extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
json,
|
||||||
|
specificationsPopulated,
|
||||||
|
onModalClose
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
|
||||||
|
<ModalHeader>
|
||||||
|
Export Custom Format
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
isFetching &&
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !!error &&
|
||||||
|
<div>
|
||||||
|
Unable to load custom formats
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !error && specificationsPopulated &&
|
||||||
|
<div>
|
||||||
|
<pre>
|
||||||
|
{json}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<ClipboardButton
|
||||||
|
className={styles.button}
|
||||||
|
value={json}
|
||||||
|
title="Copy to clipboard"
|
||||||
|
kind={kinds.DEFAULT}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onPress={onModalClose}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExportCustomFormatModalContent.propTypes = {
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
json: PropTypes.string.isRequired,
|
||||||
|
specificationsPopulated: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExportCustomFormatModalContent;
|
@ -0,0 +1,83 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { fetchCustomFormatSpecifications } from 'Store/Actions/settingsActions';
|
||||||
|
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
|
||||||
|
import ExportCustomFormatModalContent from './ExportCustomFormatModalContent';
|
||||||
|
|
||||||
|
const omittedProperties = ['id', 'implementationName', 'infoLink'];
|
||||||
|
|
||||||
|
function replacer(key, value) {
|
||||||
|
if (omittedProperties.includes(key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// provider fields
|
||||||
|
if (key === 'fields') {
|
||||||
|
return value.reduce((acc, cur) => {
|
||||||
|
acc[cur.name] = cur.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// regular setting values
|
||||||
|
if (value.hasOwnProperty('value')) {
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.settings.advancedSettings,
|
||||||
|
createProviderSettingsSelector('customFormats'),
|
||||||
|
(state) => state.settings.customFormatSpecifications,
|
||||||
|
(advancedSettings, customFormat, specifications) => {
|
||||||
|
const json = customFormat.item ? JSON.stringify(customFormat.item, replacer, 2) : '';
|
||||||
|
return {
|
||||||
|
advancedSettings,
|
||||||
|
...customFormat,
|
||||||
|
json,
|
||||||
|
specificationsPopulated: specifications.isPopulated,
|
||||||
|
specifications: specifications.items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
fetchCustomFormatSpecifications
|
||||||
|
};
|
||||||
|
|
||||||
|
class ExportCustomFormatModalContentConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const {
|
||||||
|
id
|
||||||
|
} = this.props;
|
||||||
|
this.props.fetchCustomFormatSpecifications({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<ExportCustomFormatModalContent
|
||||||
|
{...this.props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExportCustomFormatModalContentConnector.propTypes = {
|
||||||
|
id: PropTypes.number,
|
||||||
|
fetchCustomFormatSpecifications: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(ExportCustomFormatModalContentConnector);
|
@ -0,0 +1,61 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import ImportCustomFormatModalContentConnector from './ImportCustomFormatModalContentConnector';
|
||||||
|
|
||||||
|
class ImportCustomFormatModal extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
height: 'auto'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onContentHeightChange = (height) => {
|
||||||
|
if (this.state.height === 'auto' || height > this.state.height) {
|
||||||
|
this.setState({ height });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
onModalClose,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
style={{ height: `${this.state.height}px` }}
|
||||||
|
isOpen={isOpen}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<ImportCustomFormatModalContentConnector
|
||||||
|
{...otherProps}
|
||||||
|
onContentHeightChange={this.onContentHeightChange}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportCustomFormatModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportCustomFormatModal;
|
@ -0,0 +1,5 @@
|
|||||||
|
.input {
|
||||||
|
composes: input from '~Components/Form/TextArea.css';
|
||||||
|
|
||||||
|
font-family: $monoSpaceFontFamily;
|
||||||
|
}
|
@ -0,0 +1,151 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Form from 'Components/Form/Form';
|
||||||
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import { inputTypes, sizes } from 'Helpers/Props';
|
||||||
|
import styles from './ImportCustomFormatModalContent.css';
|
||||||
|
|
||||||
|
class ImportCustomFormatModalContent extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this._importTimeout = null;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
json: '',
|
||||||
|
isSpinning: false,
|
||||||
|
parseError: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this._importTimeout) {
|
||||||
|
clearTimeout(this._importTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Control
|
||||||
|
|
||||||
|
onChange = (event) => {
|
||||||
|
this.setState({ json: event.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
onImportPress = () => {
|
||||||
|
this.setState({ isSpinning: true });
|
||||||
|
// this is a bodge as we need to register a isSpinning: true to get the spinner button to update
|
||||||
|
this._importTimeout = setTimeout(this.doImport, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
doImport = () => {
|
||||||
|
const parseError = this.props.onImportPress(this.state.json);
|
||||||
|
this.setState({
|
||||||
|
parseError,
|
||||||
|
isSpinning: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parseError) {
|
||||||
|
this.props.onModalClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
specificationsPopulated,
|
||||||
|
onModalClose
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
json,
|
||||||
|
isSpinning,
|
||||||
|
parseError
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
|
||||||
|
<ModalHeader>
|
||||||
|
Import Custom Format
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
isFetching &&
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !!error &&
|
||||||
|
<div>
|
||||||
|
Unable to load custom formats
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !error && specificationsPopulated &&
|
||||||
|
<Form>
|
||||||
|
<FormGroup size={sizes.MEDIUM}>
|
||||||
|
<FormLabel>
|
||||||
|
Custom Format JSON
|
||||||
|
</FormLabel>
|
||||||
|
<FormInputGroup
|
||||||
|
key={0}
|
||||||
|
inputClassName={styles.input}
|
||||||
|
type={inputTypes.TEXT_AREA}
|
||||||
|
name="customFormatJson"
|
||||||
|
value={json}
|
||||||
|
onChange={this.onChange}
|
||||||
|
placeholder={'{\n "name": "Custom Format"\n}'}
|
||||||
|
errors={parseError ? [parseError] : []}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
onPress={onModalClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<SpinnerErrorButton
|
||||||
|
onPress={this.onImportPress}
|
||||||
|
isSpinning={isSpinning}
|
||||||
|
error={parseError}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</SpinnerErrorButton>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportCustomFormatModalContent.propTypes = {
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
specificationsPopulated: PropTypes.bool.isRequired,
|
||||||
|
onImportPress: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportCustomFormatModalContent;
|
@ -0,0 +1,145 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||||
|
import { clearCustomFormatSpecificationPending, deleteAllCustomFormatSpecification, fetchCustomFormatSpecificationSchema, saveCustomFormatSpecification, selectCustomFormatSpecificationSchema, setCustomFormatSpecificationFieldValue, setCustomFormatSpecificationValue, setCustomFormatValue } from 'Store/Actions/settingsActions';
|
||||||
|
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
|
||||||
|
import ImportCustomFormatModalContent from './ImportCustomFormatModalContent';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.settings.advancedSettings,
|
||||||
|
createProviderSettingsSelector('customFormats'),
|
||||||
|
(state) => state.settings.customFormatSpecifications,
|
||||||
|
(advancedSettings, customFormat, specifications) => {
|
||||||
|
return {
|
||||||
|
advancedSettings,
|
||||||
|
...customFormat,
|
||||||
|
specificationsPopulated: specifications.isPopulated,
|
||||||
|
specificationSchema: specifications.schema
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
deleteAllCustomFormatSpecification,
|
||||||
|
clearCustomFormatSpecificationPending,
|
||||||
|
clearPendingChanges,
|
||||||
|
saveCustomFormatSpecification,
|
||||||
|
selectCustomFormatSpecificationSchema,
|
||||||
|
setCustomFormatSpecificationFieldValue,
|
||||||
|
setCustomFormatSpecificationValue,
|
||||||
|
setCustomFormatValue,
|
||||||
|
fetchCustomFormatSpecificationSchema
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImportCustomFormatModalContentConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.fetchCustomFormatSpecificationSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
clearPending = () => {
|
||||||
|
this.props.clearPendingChanges({ section: 'settings.customFormats' });
|
||||||
|
this.props.clearCustomFormatSpecificationPending();
|
||||||
|
this.props.deleteAllCustomFormatSpecification();
|
||||||
|
};
|
||||||
|
|
||||||
|
onImportPress = (payload) => {
|
||||||
|
|
||||||
|
this.clearPending();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cf = JSON.parse(payload);
|
||||||
|
this.parseCf(cf);
|
||||||
|
} catch (err) {
|
||||||
|
this.clearPending();
|
||||||
|
return {
|
||||||
|
message: err.message,
|
||||||
|
detailedMessage: err.stack
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
parseCf = (cf) => {
|
||||||
|
for (const [key, value] of Object.entries(cf)) {
|
||||||
|
if (key === 'specifications') {
|
||||||
|
for (const spec of value) {
|
||||||
|
this.parseSpecification(spec);
|
||||||
|
}
|
||||||
|
} else if (key !== 'id') {
|
||||||
|
this.props.setCustomFormatValue({ name: key, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
parseSpecification = (spec) => {
|
||||||
|
const selectedImplementation = _.find(this.props.specificationSchema, { implementation: spec.implementation });
|
||||||
|
|
||||||
|
if (!selectedImplementation) {
|
||||||
|
throw new Error(`Unknown Custom Format condition '${spec.implementation}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.selectCustomFormatSpecificationSchema({ implementation: spec.implementation });
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(spec)) {
|
||||||
|
if (key === 'fields') {
|
||||||
|
this.parseFields(value, selectedImplementation);
|
||||||
|
} else if (key !== 'id') {
|
||||||
|
this.props.setCustomFormatSpecificationValue({ name: key, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.saveCustomFormatSpecification();
|
||||||
|
};
|
||||||
|
|
||||||
|
parseFields = (fields, schema) => {
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
const field = _.find(schema.fields, { name: key });
|
||||||
|
if (!field) {
|
||||||
|
throw new Error(`Unknown option '${key}' for condition '${schema.implementationName}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.setCustomFormatSpecificationFieldValue({ name: key, value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<ImportCustomFormatModalContent
|
||||||
|
{...this.props}
|
||||||
|
onImportPress={this.onImportPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportCustomFormatModalContentConnector.propTypes = {
|
||||||
|
specificationSchema: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
clearPendingChanges: PropTypes.func.isRequired,
|
||||||
|
deleteAllCustomFormatSpecification: PropTypes.func.isRequired,
|
||||||
|
clearCustomFormatSpecificationPending: PropTypes.func.isRequired,
|
||||||
|
saveCustomFormatSpecification: PropTypes.func.isRequired,
|
||||||
|
fetchCustomFormatSpecificationSchema: PropTypes.func.isRequired,
|
||||||
|
selectCustomFormatSpecificationSchema: PropTypes.func.isRequired,
|
||||||
|
setCustomFormatSpecificationValue: PropTypes.func.isRequired,
|
||||||
|
setCustomFormatSpecificationFieldValue: PropTypes.func.isRequired,
|
||||||
|
setCustomFormatValue: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(ImportCustomFormatModalContentConnector);
|
@ -0,0 +1,44 @@
|
|||||||
|
.specification {
|
||||||
|
composes: card from '~Components/Card.css';
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
width: 300px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.underlay {
|
||||||
|
@add-mixin cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
@add-mixin linkOverlay;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: lighter;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presetsMenu {
|
||||||
|
composes: menu from '~Components/Menu/Menu.css';
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presetsMenuButton {
|
||||||
|
composes: button from '~Components/Link/Button.css';
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
margin-left: 5px;
|
||||||
|
content: '\25BE';
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,110 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import Menu from 'Components/Menu/Menu';
|
||||||
|
import MenuContent from 'Components/Menu/MenuContent';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem';
|
||||||
|
import styles from './AddSpecificationItem.css';
|
||||||
|
|
||||||
|
class AddSpecificationItem extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onSpecificationSelect = () => {
|
||||||
|
const {
|
||||||
|
implementation
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
this.props.onSpecificationSelect({ implementation });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
implementation,
|
||||||
|
implementationName,
|
||||||
|
infoLink,
|
||||||
|
presets,
|
||||||
|
onSpecificationSelect
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const hasPresets = !!presets && !!presets.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.specification}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className={styles.underlay}
|
||||||
|
onPress={this.onSpecificationSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.name}>
|
||||||
|
{implementationName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{
|
||||||
|
hasPresets &&
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size={sizes.SMALL}
|
||||||
|
onPress={this.onSpecificationSelect}
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Menu className={styles.presetsMenu}>
|
||||||
|
<Button
|
||||||
|
className={styles.presetsMenuButton}
|
||||||
|
size={sizes.SMALL}
|
||||||
|
>
|
||||||
|
Presets
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<MenuContent>
|
||||||
|
{
|
||||||
|
presets.map((preset, index) => {
|
||||||
|
return (
|
||||||
|
<AddSpecificationPresetMenuItem
|
||||||
|
key={index}
|
||||||
|
name={preset.name}
|
||||||
|
implementation={implementation}
|
||||||
|
onPress={onSpecificationSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
to={infoLink}
|
||||||
|
size={sizes.SMALL}
|
||||||
|
>
|
||||||
|
More Info
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpecificationItem.propTypes = {
|
||||||
|
implementation: PropTypes.string.isRequired,
|
||||||
|
implementationName: PropTypes.string.isRequired,
|
||||||
|
infoLink: PropTypes.string.isRequired,
|
||||||
|
presets: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
onSpecificationSelect: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddSpecificationItem;
|
@ -0,0 +1,25 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import AddSpecificationModalContentConnector from './AddSpecificationModalContentConnector';
|
||||||
|
|
||||||
|
function AddSpecificationModal({ isOpen, onModalClose, ...otherProps }) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<AddSpecificationModalContentConnector
|
||||||
|
{...otherProps}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpecificationModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddSpecificationModal;
|
@ -0,0 +1,5 @@
|
|||||||
|
.specifications {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
@ -0,0 +1,101 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Alert from 'Components/Alert';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import AddSpecificationItem from './AddSpecificationItem';
|
||||||
|
import styles from './AddSpecificationModalContent.css';
|
||||||
|
|
||||||
|
class AddSpecificationModalContent extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isSchemaFetching,
|
||||||
|
isSchemaPopulated,
|
||||||
|
schemaError,
|
||||||
|
schema,
|
||||||
|
onSpecificationSelect,
|
||||||
|
onModalClose
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>
|
||||||
|
Add Condition
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
{
|
||||||
|
isSchemaFetching &&
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isSchemaFetching && !!schemaError &&
|
||||||
|
<div>
|
||||||
|
{'Unable to add a new condition, please try again.'}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isSchemaPopulated && !schemaError &&
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<Alert kind={kinds.INFO}>
|
||||||
|
<div>
|
||||||
|
{'Lidarr supports custom conditions against the release properties below.'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{'Visit the wiki for more details: '}
|
||||||
|
<Link to="https://wiki.servarr.com/lidarr/settings#custom-formats-2">{'Wiki'}</Link>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className={styles.specifications}>
|
||||||
|
{
|
||||||
|
schema.map((specification) => {
|
||||||
|
return (
|
||||||
|
<AddSpecificationItem
|
||||||
|
key={specification.implementation}
|
||||||
|
{...specification}
|
||||||
|
onSpecificationSelect={onSpecificationSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
onPress={onModalClose}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpecificationModalContent.propTypes = {
|
||||||
|
isSchemaFetching: PropTypes.bool.isRequired,
|
||||||
|
isSchemaPopulated: PropTypes.bool.isRequired,
|
||||||
|
schemaError: PropTypes.object,
|
||||||
|
schema: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
onSpecificationSelect: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddSpecificationModalContent;
|
@ -0,0 +1,70 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { fetchCustomFormatSpecificationSchema, selectCustomFormatSpecificationSchema } from 'Store/Actions/settingsActions';
|
||||||
|
import AddSpecificationModalContent from './AddSpecificationModalContent';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.settings.customFormatSpecifications,
|
||||||
|
(specifications) => {
|
||||||
|
const {
|
||||||
|
isSchemaFetching,
|
||||||
|
isSchemaPopulated,
|
||||||
|
schemaError,
|
||||||
|
schema
|
||||||
|
} = specifications;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSchemaFetching,
|
||||||
|
isSchemaPopulated,
|
||||||
|
schemaError,
|
||||||
|
schema
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
fetchCustomFormatSpecificationSchema,
|
||||||
|
selectCustomFormatSpecificationSchema
|
||||||
|
};
|
||||||
|
|
||||||
|
class AddSpecificationModalContentConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.fetchCustomFormatSpecificationSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onSpecificationSelect = ({ implementation, name }) => {
|
||||||
|
this.props.selectCustomFormatSpecificationSchema({ implementation, presetName: name });
|
||||||
|
this.props.onModalClose({ specificationSelected: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<AddSpecificationModalContent
|
||||||
|
{...this.props}
|
||||||
|
onSpecificationSelect={this.onSpecificationSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpecificationModalContentConnector.propTypes = {
|
||||||
|
fetchCustomFormatSpecificationSchema: PropTypes.func.isRequired,
|
||||||
|
selectCustomFormatSpecificationSchema: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(AddSpecificationModalContentConnector);
|
@ -0,0 +1,49 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import MenuItem from 'Components/Menu/MenuItem';
|
||||||
|
|
||||||
|
class AddSpecificationPresetMenuItem extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onPress = () => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
implementation
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
this.props.onPress({
|
||||||
|
name,
|
||||||
|
implementation
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
implementation,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
{...otherProps}
|
||||||
|
onPress={this.onPress}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpecificationPresetMenuItem.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
implementation: PropTypes.string.isRequired,
|
||||||
|
onPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddSpecificationPresetMenuItem;
|
@ -0,0 +1,27 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import EditSpecificationModalContentConnector from './EditSpecificationModalContentConnector';
|
||||||
|
|
||||||
|
function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
size={sizes.MEDIUM}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<EditSpecificationModalContentConnector
|
||||||
|
{...otherProps}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditSpecificationModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditSpecificationModal;
|
@ -0,0 +1,50 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||||
|
import EditSpecificationModal from './EditSpecificationModal';
|
||||||
|
|
||||||
|
function createMapDispatchToProps(dispatch, props) {
|
||||||
|
const section = 'settings.customFormatSpecifications';
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispatchClearPendingChanges() {
|
||||||
|
dispatch(clearPendingChanges({ section }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class EditSpecificationModalConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onModalClose = () => {
|
||||||
|
this.props.dispatchClearPendingChanges();
|
||||||
|
this.props.onModalClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
dispatchClearPendingChanges,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditSpecificationModal
|
||||||
|
{...otherProps}
|
||||||
|
onModalClose={this.onModalClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditSpecificationModalConnector.propTypes = {
|
||||||
|
onModalClose: PropTypes.func.isRequired,
|
||||||
|
dispatchClearPendingChanges: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(null, createMapDispatchToProps)(EditSpecificationModalConnector);
|
@ -0,0 +1,5 @@
|
|||||||
|
.deleteButton {
|
||||||
|
composes: button from '~Components/Link/Button.css';
|
||||||
|
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
@ -0,0 +1,160 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import Alert from 'Components/Alert';
|
||||||
|
import Form from 'Components/Form/Form';
|
||||||
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import { inputTypes, kinds } from 'Helpers/Props';
|
||||||
|
import styles from './EditSpecificationModalContent.css';
|
||||||
|
|
||||||
|
function EditSpecificationModalContent(props) {
|
||||||
|
const {
|
||||||
|
advancedSettings,
|
||||||
|
item,
|
||||||
|
onInputChange,
|
||||||
|
onFieldChange,
|
||||||
|
onCancelPress,
|
||||||
|
onSavePress,
|
||||||
|
onDeleteSpecificationPress,
|
||||||
|
...otherProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
implementationName,
|
||||||
|
name,
|
||||||
|
negate,
|
||||||
|
required,
|
||||||
|
fields
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onCancelPress}>
|
||||||
|
<ModalHeader>
|
||||||
|
{`${id ? 'Edit' : 'Add'} Condition - ${implementationName}`}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<Form
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
fields && fields.some((x) => x.label === 'Regular Expression') &&
|
||||||
|
<Alert kind={kinds.INFO}>
|
||||||
|
<div>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: 'This condition matches using Regular Expressions. Note that the characters <code>\\^$.|?*+()[{</code> have special meanings and need escaping with a <code>\\</code>' }} />
|
||||||
|
{'More details'} <Link to="https://www.regular-expressions.info/tutorial.html">{'Here'}</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{'Regular expressions can be tested '}
|
||||||
|
<Link to="http://regexstorm.net/tester">Here</Link>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
Name
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.TEXT}
|
||||||
|
name="name"
|
||||||
|
{...name}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{
|
||||||
|
fields && fields.map((field) => {
|
||||||
|
return (
|
||||||
|
<ProviderFieldFormGroup
|
||||||
|
key={field.name}
|
||||||
|
advancedSettings={advancedSettings}
|
||||||
|
provider="specifications"
|
||||||
|
providerData={item}
|
||||||
|
{...field}
|
||||||
|
onChange={onFieldChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
Negate
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="negate"
|
||||||
|
{...negate}
|
||||||
|
helpText={`If checked, the custom format will not apply if this ${implementationName} condition matches.`}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
Required
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="required"
|
||||||
|
{...required}
|
||||||
|
helpText={`This ${implementationName} condition must match for the custom format to apply. Otherwise a single ${implementationName} match is sufficient.`}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
{
|
||||||
|
id &&
|
||||||
|
<Button
|
||||||
|
className={styles.deleteButton}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
onPress={onDeleteSpecificationPress}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={onCancelPress}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SpinnerErrorButton
|
||||||
|
isSpinning={false}
|
||||||
|
onPress={onSavePress}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</SpinnerErrorButton>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditSpecificationModalContent.propTypes = {
|
||||||
|
advancedSettings: PropTypes.bool.isRequired,
|
||||||
|
item: PropTypes.object.isRequired,
|
||||||
|
onInputChange: PropTypes.func.isRequired,
|
||||||
|
onFieldChange: PropTypes.func.isRequired,
|
||||||
|
onCancelPress: PropTypes.func.isRequired,
|
||||||
|
onSavePress: PropTypes.func.isRequired,
|
||||||
|
onDeleteSpecificationPress: PropTypes.func
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditSpecificationModalContent;
|
@ -0,0 +1,78 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { clearCustomFormatSpecificationPending, saveCustomFormatSpecification, setCustomFormatSpecificationFieldValue, setCustomFormatSpecificationValue } from 'Store/Actions/settingsActions';
|
||||||
|
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
|
||||||
|
import EditSpecificationModalContent from './EditSpecificationModalContent';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.settings.advancedSettings,
|
||||||
|
createProviderSettingsSelector('customFormatSpecifications'),
|
||||||
|
(advancedSettings, specification) => {
|
||||||
|
return {
|
||||||
|
advancedSettings,
|
||||||
|
...specification
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
setCustomFormatSpecificationValue,
|
||||||
|
setCustomFormatSpecificationFieldValue,
|
||||||
|
saveCustomFormatSpecification,
|
||||||
|
clearCustomFormatSpecificationPending
|
||||||
|
};
|
||||||
|
|
||||||
|
class EditSpecificationModalContentConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onInputChange = ({ name, value }) => {
|
||||||
|
this.props.setCustomFormatSpecificationValue({ name, value });
|
||||||
|
};
|
||||||
|
|
||||||
|
onFieldChange = ({ name, value }) => {
|
||||||
|
this.props.setCustomFormatSpecificationFieldValue({ name, value });
|
||||||
|
};
|
||||||
|
|
||||||
|
onCancelPress = () => {
|
||||||
|
this.props.clearCustomFormatSpecificationPending();
|
||||||
|
this.props.onModalClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
onSavePress = () => {
|
||||||
|
this.props.saveCustomFormatSpecification({ id: this.props.id });
|
||||||
|
this.props.onModalClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<EditSpecificationModalContent
|
||||||
|
{...this.props}
|
||||||
|
onCancelPress={this.onCancelPress}
|
||||||
|
onSavePress={this.onSavePress}
|
||||||
|
onInputChange={this.onInputChange}
|
||||||
|
onFieldChange={this.onFieldChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditSpecificationModalContentConnector.propTypes = {
|
||||||
|
id: PropTypes.number,
|
||||||
|
item: PropTypes.object.isRequired,
|
||||||
|
setCustomFormatSpecificationValue: PropTypes.func.isRequired,
|
||||||
|
setCustomFormatSpecificationFieldValue: PropTypes.func.isRequired,
|
||||||
|
clearCustomFormatSpecificationPending: PropTypes.func.isRequired,
|
||||||
|
saveCustomFormatSpecification: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(EditSpecificationModalContentConnector);
|
@ -0,0 +1,38 @@
|
|||||||
|
.customFormat {
|
||||||
|
composes: card from '~Components/Card.css';
|
||||||
|
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
@add-mixin truncate;
|
||||||
|
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloneButton {
|
||||||
|
composes: button from '~Components/Link/IconButton.css';
|
||||||
|
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labels {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 5px;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipLabel {
|
||||||
|
composes: label from '~Components/Label.css';
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
@ -0,0 +1,139 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Card from 'Components/Card';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
|
import EditSpecificationModalConnector from './EditSpecificationModal';
|
||||||
|
import styles from './Specification.css';
|
||||||
|
|
||||||
|
class Specification extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isEditSpecificationModalOpen: false,
|
||||||
|
isDeleteSpecificationModalOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onEditSpecificationPress = () => {
|
||||||
|
this.setState({ isEditSpecificationModalOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
onEditSpecificationModalClose = () => {
|
||||||
|
this.setState({ isEditSpecificationModalOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
onDeleteSpecificationPress = () => {
|
||||||
|
this.setState({
|
||||||
|
isEditSpecificationModalOpen: false,
|
||||||
|
isDeleteSpecificationModalOpen: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onDeleteSpecificationModalClose = () => {
|
||||||
|
this.setState({ isDeleteSpecificationModalOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
onCloneSpecificationPress = () => {
|
||||||
|
this.props.onCloneSpecificationPress(this.props.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
onConfirmDeleteSpecification = () => {
|
||||||
|
this.props.onConfirmDeleteSpecification(this.props.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
implementationName,
|
||||||
|
name,
|
||||||
|
required,
|
||||||
|
negate
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={styles.customFormat}
|
||||||
|
overlayContent={true}
|
||||||
|
onPress={this.onEditSpecificationPress}
|
||||||
|
>
|
||||||
|
<div className={styles.nameContainer}>
|
||||||
|
<div className={styles.name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className={styles.cloneButton}
|
||||||
|
title="Clone"
|
||||||
|
name={icons.CLONE}
|
||||||
|
onPress={this.onCloneSpecificationPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.labels}>
|
||||||
|
<Label kind={kinds.DEFAULT}>
|
||||||
|
{implementationName}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{
|
||||||
|
negate &&
|
||||||
|
<Label kind={kinds.DANGER}>
|
||||||
|
Negated
|
||||||
|
</Label>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
required &&
|
||||||
|
<Label kind={kinds.SUCCESS}>
|
||||||
|
Required
|
||||||
|
</Label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditSpecificationModalConnector
|
||||||
|
id={id}
|
||||||
|
isOpen={this.state.isEditSpecificationModalOpen}
|
||||||
|
onModalClose={this.onEditSpecificationModalClose}
|
||||||
|
onDeleteSpecificationPress={this.onDeleteSpecificationPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={this.state.isDeleteSpecificationModalOpen}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
title="Delete Format"
|
||||||
|
message={`Are you sure you want to delete format tag ${name} ?`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={this.onConfirmDeleteSpecification}
|
||||||
|
onCancel={this.onDeleteSpecificationModalClose}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Specification.propTypes = {
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
implementation: PropTypes.string.isRequired,
|
||||||
|
implementationName: PropTypes.string.isRequired,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
negate: PropTypes.bool.isRequired,
|
||||||
|
required: PropTypes.bool.isRequired,
|
||||||
|
fields: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
onConfirmDeleteSpecification: PropTypes.func.isRequired,
|
||||||
|
onCloneSpecificationPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Specification;
|
@ -0,0 +1,45 @@
|
|||||||
|
.qualityProfileFormatItemContainer {
|
||||||
|
display: flex;
|
||||||
|
padding: $qualityProfileItemDragSourcePadding 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityProfileFormatItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--inputBackgroundColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatNameContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-left: 14px;
|
||||||
|
width: 100%;
|
||||||
|
font-weight: normal;
|
||||||
|
line-height: $qualityProfileItemHeight;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatName {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreInput {
|
||||||
|
composes: input from '~Components/Form/Input.css';
|
||||||
|
|
||||||
|
width: 100px;
|
||||||
|
height: 30px;
|
||||||
|
border: unset;
|
||||||
|
border-radius: unset;
|
||||||
|
background-color: unset;
|
||||||
|
}
|
@ -0,0 +1,68 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import NumberInput from 'Components/Form/NumberInput';
|
||||||
|
import styles from './QualityProfileFormatItem.css';
|
||||||
|
|
||||||
|
class QualityProfileFormatItem extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onScoreChange = ({ value }) => {
|
||||||
|
const {
|
||||||
|
formatId
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
this.props.onScoreChange(formatId, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
score
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.qualityProfileFormatItemContainer}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.qualityProfileFormatItem}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className={styles.formatNameContainer}
|
||||||
|
>
|
||||||
|
<div className={styles.formatName}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<NumberInput
|
||||||
|
containerClassName={styles.scoreContainer}
|
||||||
|
className={styles.scoreInput}
|
||||||
|
name={name}
|
||||||
|
value={score}
|
||||||
|
onChange={this.onScoreChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QualityProfileFormatItem.propTypes = {
|
||||||
|
formatId: PropTypes.number.isRequired,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
score: PropTypes.number.isRequired,
|
||||||
|
onScoreChange: PropTypes.func
|
||||||
|
};
|
||||||
|
|
||||||
|
QualityProfileFormatItem.defaultProps = {
|
||||||
|
// To handle the case score is deleted during edit
|
||||||
|
score: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QualityProfileFormatItem;
|
@ -0,0 +1,31 @@
|
|||||||
|
.formats {
|
||||||
|
margin-top: 10px;
|
||||||
|
/* TODO: This should consider the number of languages in the list */
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContainer {
|
||||||
|
display: flex;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerTitle {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerScore {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCustomFormatMessage {
|
||||||
|
max-width: $formGroupExtraSmallWidth;
|
||||||
|
color: var(--helpTextColor);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
@ -0,0 +1,159 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
|
import FormInputHelpText from 'Components/Form/FormInputHelpText';
|
||||||
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import QualityProfileFormatItem from './QualityProfileFormatItem';
|
||||||
|
import styles from './QualityProfileFormatItems.css';
|
||||||
|
|
||||||
|
function calcOrder(profileFormatItems) {
|
||||||
|
const items = profileFormatItems.reduce((acc, cur, index) => {
|
||||||
|
acc[cur.format] = index;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return [...profileFormatItems].sort((a, b) => {
|
||||||
|
if (b.score !== a.score) {
|
||||||
|
return b.score - a.score;
|
||||||
|
}
|
||||||
|
return a.name > b.name ? 1 : -1;
|
||||||
|
}).map((x) => items[x.format]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class QualityProfileFormatItems extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
order: calcOrder(this.props.profileFormatItems)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onScoreChange = (formatId, value) => {
|
||||||
|
const {
|
||||||
|
onQualityProfileFormatItemScoreChange
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
onQualityProfileFormatItemScoreChange(formatId, value);
|
||||||
|
this.reorderItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
reorderItems = _.debounce(() => this.setState({ order: calcOrder(this.props.profileFormatItems) }), 1000);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
profileFormatItems,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
order
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
if (profileFormatItems.length < 1) {
|
||||||
|
return (
|
||||||
|
<div className={styles.addCustomFormatMessage}>
|
||||||
|
{'Want more control over which downloads are preferred? Add a'}
|
||||||
|
<Link to='/settings/customformats'> Custom Format </Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||||
|
<FormLabel size={sizes.SMALL}>
|
||||||
|
Custom Formats
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormInputHelpText
|
||||||
|
text="Lidarr scores each release using the sum of scores for matching custom formats. If a new release would improve the score, at the same or better quality, then LIdarr will grab it."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{
|
||||||
|
errors.map((error, index) => {
|
||||||
|
return (
|
||||||
|
<FormInputHelpText
|
||||||
|
key={index}
|
||||||
|
text={error.message}
|
||||||
|
isError={true}
|
||||||
|
isCheckInput={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
warnings.map((warning, index) => {
|
||||||
|
return (
|
||||||
|
<FormInputHelpText
|
||||||
|
key={index}
|
||||||
|
text={warning.message}
|
||||||
|
isWarning={true}
|
||||||
|
isCheckInput={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={styles.formats}>
|
||||||
|
<div className={styles.headerContainer}>
|
||||||
|
<div className={styles.headerTitle}>
|
||||||
|
Custom Format
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerScore}>
|
||||||
|
Score
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
order.map((index) => {
|
||||||
|
const {
|
||||||
|
format,
|
||||||
|
name,
|
||||||
|
score
|
||||||
|
} = profileFormatItems[index];
|
||||||
|
return (
|
||||||
|
<QualityProfileFormatItem
|
||||||
|
key={format}
|
||||||
|
formatId={format}
|
||||||
|
name={name}
|
||||||
|
score={score}
|
||||||
|
onScoreChange={this.onScoreChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QualityProfileFormatItems.propTypes = {
|
||||||
|
profileFormatItems: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
errors: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
warnings: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
onQualityProfileFormatItemScoreChange: PropTypes.func
|
||||||
|
};
|
||||||
|
|
||||||
|
QualityProfileFormatItems.defaultProps = {
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QualityProfileFormatItems;
|
@ -0,0 +1,193 @@
|
|||||||
|
import { createAction } from 'redux-actions';
|
||||||
|
import { batchActions } from 'redux-batched-actions';
|
||||||
|
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
|
||||||
|
import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer';
|
||||||
|
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
|
||||||
|
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||||
|
import { createThunk } from 'Store/thunks';
|
||||||
|
import getNextId from 'Utilities/State/getNextId';
|
||||||
|
import getProviderState from 'Utilities/State/getProviderState';
|
||||||
|
import getSectionState from 'Utilities/State/getSectionState';
|
||||||
|
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
|
||||||
|
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||||
|
import { removeItem, set, update, updateItem } from '../baseActions';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Variables
|
||||||
|
|
||||||
|
const section = 'settings.customFormatSpecifications';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Actions Types
|
||||||
|
|
||||||
|
export const FETCH_CUSTOM_FORMAT_SPECIFICATIONS = 'settings/customFormatSpecifications/fetchCustomFormatSpecifications';
|
||||||
|
export const FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA = 'settings/customFormatSpecifications/fetchCustomFormatSpecificationSchema';
|
||||||
|
export const SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA = 'settings/customFormatSpecifications/selectCustomFormatSpecificationSchema';
|
||||||
|
export const SET_CUSTOM_FORMAT_SPECIFICATION_VALUE = 'settings/customFormatSpecifications/setCustomFormatSpecificationValue';
|
||||||
|
export const SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE = 'settings/customFormatSpecifications/setCustomFormatSpecificationFieldValue';
|
||||||
|
export const SAVE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/saveCustomFormatSpecification';
|
||||||
|
export const DELETE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/deleteCustomFormatSpecification';
|
||||||
|
export const DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/deleteAllCustomFormatSpecification';
|
||||||
|
export const CLONE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/cloneCustomFormatSpecification';
|
||||||
|
export const CLEAR_CUSTOM_FORMAT_SPECIFICATIONS = 'settings/customFormatSpecifications/clearCustomFormatSpecifications';
|
||||||
|
export const CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING = 'settings/customFormatSpecifications/clearCustomFormatSpecificationPending';
|
||||||
|
//
|
||||||
|
// Action Creators
|
||||||
|
|
||||||
|
export const fetchCustomFormatSpecifications = createThunk(FETCH_CUSTOM_FORMAT_SPECIFICATIONS);
|
||||||
|
export const fetchCustomFormatSpecificationSchema = createThunk(FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA);
|
||||||
|
export const selectCustomFormatSpecificationSchema = createAction(SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA);
|
||||||
|
|
||||||
|
export const saveCustomFormatSpecification = createThunk(SAVE_CUSTOM_FORMAT_SPECIFICATION);
|
||||||
|
export const deleteCustomFormatSpecification = createThunk(DELETE_CUSTOM_FORMAT_SPECIFICATION);
|
||||||
|
export const deleteAllCustomFormatSpecification = createThunk(DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION);
|
||||||
|
|
||||||
|
export const setCustomFormatSpecificationValue = createAction(SET_CUSTOM_FORMAT_SPECIFICATION_VALUE, (payload) => {
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setCustomFormatSpecificationFieldValue = createAction(SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE, (payload) => {
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cloneCustomFormatSpecification = createAction(CLONE_CUSTOM_FORMAT_SPECIFICATION);
|
||||||
|
|
||||||
|
export const clearCustomFormatSpecification = createAction(CLEAR_CUSTOM_FORMAT_SPECIFICATIONS);
|
||||||
|
|
||||||
|
export const clearCustomFormatSpecificationPending = createThunk(CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Details
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
//
|
||||||
|
// State
|
||||||
|
|
||||||
|
defaultState: {
|
||||||
|
isPopulated: false,
|
||||||
|
error: null,
|
||||||
|
isSchemaFetching: false,
|
||||||
|
isSchemaPopulated: false,
|
||||||
|
schemaError: null,
|
||||||
|
schema: [],
|
||||||
|
selectedSchema: {},
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
items: [],
|
||||||
|
pendingChanges: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// Action Handlers
|
||||||
|
|
||||||
|
actionHandlers: {
|
||||||
|
[FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/customformat/schema'),
|
||||||
|
|
||||||
|
[FETCH_CUSTOM_FORMAT_SPECIFICATIONS]: (getState, payload, dispatch) => {
|
||||||
|
let tags = [];
|
||||||
|
if (payload.id) {
|
||||||
|
const cfState = getSectionState(getState(), 'settings.customFormats', true);
|
||||||
|
const cf = cfState.items[cfState.itemMap[payload.id]];
|
||||||
|
tags = cf.specifications.map((tag, i) => {
|
||||||
|
return {
|
||||||
|
id: i + 1,
|
||||||
|
...tag
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(batchActions([
|
||||||
|
update({ section, data: tags }),
|
||||||
|
set({
|
||||||
|
section,
|
||||||
|
isPopulated: true
|
||||||
|
})
|
||||||
|
]));
|
||||||
|
},
|
||||||
|
|
||||||
|
[SAVE_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
...otherPayload
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
const saveData = getProviderState({ id, ...otherPayload }, getState, section, false);
|
||||||
|
|
||||||
|
// we have to set id since not actually posting to server yet
|
||||||
|
if (!saveData.id) {
|
||||||
|
saveData.id = getNextId(getState().settings.customFormatSpecifications.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(batchActions([
|
||||||
|
updateItem({ section, ...saveData }),
|
||||||
|
set({
|
||||||
|
section,
|
||||||
|
pendingChanges: {}
|
||||||
|
})
|
||||||
|
]));
|
||||||
|
},
|
||||||
|
|
||||||
|
[DELETE_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => {
|
||||||
|
const id = payload.id;
|
||||||
|
return dispatch(removeItem({ section, id }));
|
||||||
|
},
|
||||||
|
|
||||||
|
[DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => {
|
||||||
|
return dispatch(set({
|
||||||
|
section,
|
||||||
|
items: []
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
[CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING]: (getState, payload, dispatch) => {
|
||||||
|
return dispatch(set({
|
||||||
|
section,
|
||||||
|
pendingChanges: {}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// Reducers
|
||||||
|
|
||||||
|
reducers: {
|
||||||
|
[SET_CUSTOM_FORMAT_SPECIFICATION_VALUE]: createSetSettingValueReducer(section),
|
||||||
|
[SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
|
||||||
|
|
||||||
|
[SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA]: (state, { payload }) => {
|
||||||
|
return selectProviderSchema(state, section, payload, (selectedSchema) => {
|
||||||
|
return selectedSchema;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
[CLONE_CUSTOM_FORMAT_SPECIFICATION]: function(state, { payload }) {
|
||||||
|
const id = payload.id;
|
||||||
|
const newState = getSectionState(state, section);
|
||||||
|
const items = newState.items;
|
||||||
|
const item = items.find((i) => i.id === id);
|
||||||
|
const newId = getNextId(newState.items);
|
||||||
|
const newItem = {
|
||||||
|
...item,
|
||||||
|
id: newId,
|
||||||
|
name: `${item.name} - Copy`
|
||||||
|
};
|
||||||
|
newState.items = [...items, newItem];
|
||||||
|
newState.itemMap[newId] = newState.items.length - 1;
|
||||||
|
|
||||||
|
return updateSectionState(state, section, newState);
|
||||||
|
},
|
||||||
|
|
||||||
|
[CLEAR_CUSTOM_FORMAT_SPECIFICATIONS]: createClearReducer(section, {
|
||||||
|
isPopulated: false,
|
||||||
|
error: null,
|
||||||
|
items: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,108 @@
|
|||||||
|
import { createAction } from 'redux-actions';
|
||||||
|
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||||
|
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
|
||||||
|
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
|
||||||
|
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||||
|
import { createThunk } from 'Store/thunks';
|
||||||
|
import getSectionState from 'Utilities/State/getSectionState';
|
||||||
|
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||||
|
import { set } from '../baseActions';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Variables
|
||||||
|
|
||||||
|
const section = 'settings.customFormats';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Actions Types
|
||||||
|
|
||||||
|
export const FETCH_CUSTOM_FORMATS = 'settings/customFormats/fetchCustomFormats';
|
||||||
|
export const SAVE_CUSTOM_FORMAT = 'settings/customFormats/saveCustomFormat';
|
||||||
|
export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat';
|
||||||
|
export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue';
|
||||||
|
export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Action Creators
|
||||||
|
|
||||||
|
export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS);
|
||||||
|
export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT);
|
||||||
|
export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT);
|
||||||
|
|
||||||
|
export const setCustomFormatValue = createAction(SET_CUSTOM_FORMAT_VALUE, (payload) => {
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cloneCustomFormat = createAction(CLONE_CUSTOM_FORMAT);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Details
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
//
|
||||||
|
// State
|
||||||
|
|
||||||
|
defaultState: {
|
||||||
|
isSchemaFetching: false,
|
||||||
|
isSchemaPopulated: false,
|
||||||
|
isFetching: false,
|
||||||
|
isPopulated: false,
|
||||||
|
schema: {
|
||||||
|
includeCustomFormatWhenRenaming: false
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
isDeleting: false,
|
||||||
|
deleteError: null,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
items: [],
|
||||||
|
pendingChanges: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// Action Handlers
|
||||||
|
|
||||||
|
actionHandlers: {
|
||||||
|
[FETCH_CUSTOM_FORMATS]: createFetchHandler(section, '/customformat'),
|
||||||
|
|
||||||
|
[DELETE_CUSTOM_FORMAT]: createRemoveItemHandler(section, '/customformat'),
|
||||||
|
|
||||||
|
[SAVE_CUSTOM_FORMAT]: (getState, payload, dispatch) => {
|
||||||
|
// move the format tags in as a pending change
|
||||||
|
const state = getState();
|
||||||
|
const pendingChanges = state.settings.customFormats.pendingChanges;
|
||||||
|
pendingChanges.specifications = state.settings.customFormatSpecifications.items;
|
||||||
|
dispatch(set({
|
||||||
|
section,
|
||||||
|
pendingChanges
|
||||||
|
}));
|
||||||
|
|
||||||
|
createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// Reducers
|
||||||
|
|
||||||
|
reducers: {
|
||||||
|
[SET_CUSTOM_FORMAT_VALUE]: createSetSettingValueReducer(section),
|
||||||
|
|
||||||
|
[CLONE_CUSTOM_FORMAT]: function(state, { payload }) {
|
||||||
|
const id = payload.id;
|
||||||
|
const newState = getSectionState(state, section);
|
||||||
|
const item = newState.items.find((i) => i.id === id);
|
||||||
|
const pendingChanges = { ...item, id: 0 };
|
||||||
|
delete pendingChanges.id;
|
||||||
|
|
||||||
|
pendingChanges.name = `${pendingChanges.name} - Copy`;
|
||||||
|
newState.pendingChanges = pendingChanges;
|
||||||
|
|
||||||
|
return updateSectionState(state, section, newState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
function formatPreferredWordScore(input, customFormatsLength = 0) {
|
||||||
|
const score = Number(input);
|
||||||
|
|
||||||
|
if (score > 0) {
|
||||||
|
return `+${score}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score < 0) {
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
return customFormatsLength > 0 ? '+0' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default formatPreferredWordScore;
|
@ -0,0 +1,5 @@
|
|||||||
|
function getNextId(items) {
|
||||||
|
return items.reduce((id, x) => Math.max(id, x.id), 1) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getNextId;
|
@ -0,0 +1,115 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FluentValidation;
|
||||||
|
using Lidarr.Api.V1.CustomFormats;
|
||||||
|
using Lidarr.Http;
|
||||||
|
using Lidarr.Http.REST;
|
||||||
|
using Lidarr.Http.REST.Attributes;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Core.CustomFormats;
|
||||||
|
|
||||||
|
namespace Sonarr.Api.V3.CustomFormats
|
||||||
|
{
|
||||||
|
[V1ApiController]
|
||||||
|
public class CustomFormatController : RestController<CustomFormatResource>
|
||||||
|
{
|
||||||
|
private readonly ICustomFormatService _formatService;
|
||||||
|
private readonly List<ICustomFormatSpecification> _specifications;
|
||||||
|
|
||||||
|
public CustomFormatController(ICustomFormatService formatService,
|
||||||
|
List<ICustomFormatSpecification> specifications)
|
||||||
|
{
|
||||||
|
_formatService = formatService;
|
||||||
|
_specifications = specifications;
|
||||||
|
|
||||||
|
SharedValidator.RuleFor(c => c.Name).NotEmpty();
|
||||||
|
SharedValidator.RuleFor(c => c.Name)
|
||||||
|
.Must((v, c) => !_formatService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique.");
|
||||||
|
SharedValidator.RuleFor(c => c.Specifications).NotEmpty();
|
||||||
|
SharedValidator.RuleFor(c => c).Custom((customFormat, context) =>
|
||||||
|
{
|
||||||
|
if (!customFormat.Specifications.Any())
|
||||||
|
{
|
||||||
|
context.AddFailure("Must contain at least one Condition");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customFormat.Specifications.Any(s => s.Name.IsNullOrWhiteSpace()))
|
||||||
|
{
|
||||||
|
context.AddFailure("Condition name(s) cannot be empty or consist of only spaces");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override CustomFormatResource GetResourceById(int id)
|
||||||
|
{
|
||||||
|
return _formatService.GetById(id).ToResource(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RestPostById]
|
||||||
|
[Consumes("application/json")]
|
||||||
|
public ActionResult<CustomFormatResource> Create(CustomFormatResource customFormatResource)
|
||||||
|
{
|
||||||
|
var model = customFormatResource.ToModel(_specifications);
|
||||||
|
return Created(_formatService.Insert(model).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RestPutById]
|
||||||
|
[Consumes("application/json")]
|
||||||
|
public ActionResult<CustomFormatResource> Update(CustomFormatResource resource)
|
||||||
|
{
|
||||||
|
var model = resource.ToModel(_specifications);
|
||||||
|
_formatService.Update(model);
|
||||||
|
|
||||||
|
return Accepted(model.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public List<CustomFormatResource> GetAll()
|
||||||
|
{
|
||||||
|
return _formatService.All().ToResource(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RestDeleteById]
|
||||||
|
public void DeleteFormat(int id)
|
||||||
|
{
|
||||||
|
_formatService.Delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("schema")]
|
||||||
|
public object GetTemplates()
|
||||||
|
{
|
||||||
|
var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList();
|
||||||
|
|
||||||
|
var presets = GetPresets();
|
||||||
|
|
||||||
|
foreach (var item in schema)
|
||||||
|
{
|
||||||
|
item.Presets = presets.Where(x => x.GetType().Name == item.Implementation).Select(x => x.ToSchema()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<ICustomFormatSpecification> GetPresets()
|
||||||
|
{
|
||||||
|
yield return new ReleaseTitleSpecification
|
||||||
|
{
|
||||||
|
Name = "Preferred Words",
|
||||||
|
Value = @"\b(SPARKS|Framestor)\b"
|
||||||
|
};
|
||||||
|
|
||||||
|
var formats = _formatService.All();
|
||||||
|
foreach (var format in formats)
|
||||||
|
{
|
||||||
|
foreach (var condition in format.Specifications)
|
||||||
|
{
|
||||||
|
var preset = condition.Clone();
|
||||||
|
preset.Name = $"{format.Name}: {preset.Name}";
|
||||||
|
yield return preset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Lidarr.Http.ClientSchema;
|
||||||
|
using Lidarr.Http.REST;
|
||||||
|
using NzbDrone.Core.CustomFormats;
|
||||||
|
|
||||||
|
namespace Lidarr.Api.V1.CustomFormats
|
||||||
|
{
|
||||||
|
public class CustomFormatResource : RestResource
|
||||||
|
{
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||||
|
public override int Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public bool? IncludeCustomFormatWhenRenaming { get; set; }
|
||||||
|
public List<CustomFormatSpecificationSchema> Specifications { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CustomFormatResourceMapper
|
||||||
|
{
|
||||||
|
public static CustomFormatResource ToResource(this CustomFormat model, bool includeDetails)
|
||||||
|
{
|
||||||
|
var resource = new CustomFormatResource
|
||||||
|
{
|
||||||
|
Id = model.Id,
|
||||||
|
Name = model.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeDetails)
|
||||||
|
{
|
||||||
|
resource.IncludeCustomFormatWhenRenaming = model.IncludeCustomFormatWhenRenaming;
|
||||||
|
resource.Specifications = model.Specifications.Select(x => x.ToSchema()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CustomFormatResource> ToResource(this IEnumerable<CustomFormat> models, bool includeDetails)
|
||||||
|
{
|
||||||
|
return models.Select(m => m.ToResource(includeDetails)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CustomFormat ToModel(this CustomFormatResource resource, List<ICustomFormatSpecification> specifications)
|
||||||
|
{
|
||||||
|
return new CustomFormat
|
||||||
|
{
|
||||||
|
Id = resource.Id,
|
||||||
|
Name = resource.Name,
|
||||||
|
IncludeCustomFormatWhenRenaming = resource.IncludeCustomFormatWhenRenaming ?? false,
|
||||||
|
Specifications = resource.Specifications?.Select(x => MapSpecification(x, specifications)).ToList() ?? new List<ICustomFormatSpecification>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ICustomFormatSpecification MapSpecification(CustomFormatSpecificationSchema resource, List<ICustomFormatSpecification> specifications)
|
||||||
|
{
|
||||||
|
var matchingSpec =
|
||||||
|
specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation);
|
||||||
|
|
||||||
|
if (matchingSpec is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"{resource.Implementation} is not a valid specification implementation");
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = matchingSpec.GetType();
|
||||||
|
|
||||||
|
var spec = (ICustomFormatSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type);
|
||||||
|
spec.Name = resource.Name;
|
||||||
|
spec.Negate = resource.Negate;
|
||||||
|
spec.Required = resource.Required;
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Lidarr.Http.ClientSchema;
|
||||||
|
using Lidarr.Http.REST;
|
||||||
|
using NzbDrone.Core.CustomFormats;
|
||||||
|
|
||||||
|
namespace Lidarr.Api.V1.CustomFormats
|
||||||
|
{
|
||||||
|
public class CustomFormatSpecificationSchema : RestResource
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Implementation { get; set; }
|
||||||
|
public string ImplementationName { get; set; }
|
||||||
|
public string InfoLink { get; set; }
|
||||||
|
public bool Negate { get; set; }
|
||||||
|
public bool Required { get; set; }
|
||||||
|
public List<Field> Fields { get; set; }
|
||||||
|
public List<CustomFormatSpecificationSchema> Presets { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CustomFormatSpecificationSchemaMapper
|
||||||
|
{
|
||||||
|
public static CustomFormatSpecificationSchema ToSchema(this ICustomFormatSpecification model)
|
||||||
|
{
|
||||||
|
return new CustomFormatSpecificationSchema
|
||||||
|
{
|
||||||
|
Name = model.Name,
|
||||||
|
Implementation = model.GetType().Name,
|
||||||
|
ImplementationName = model.ImplementationName,
|
||||||
|
InfoLink = model.InfoLink,
|
||||||
|
Negate = model.Negate,
|
||||||
|
Required = model.Required,
|
||||||
|
Fields = SchemaBuilder.ToSchema(model)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using NzbDrone.Core.CustomFormats;
|
||||||
|
using NzbDrone.Core.Profiles;
|
||||||
|
using NzbDrone.Core.Test.Framework;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Test.CustomFormats
|
||||||
|
{
|
||||||
|
public class CustomFormatsTestHelpers : CoreTest
|
||||||
|
{
|
||||||
|
private static List<CustomFormat> _customFormats { get; set; }
|
||||||
|
|
||||||
|
public static void GivenCustomFormats(params CustomFormat[] formats)
|
||||||
|
{
|
||||||
|
_customFormats = formats.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ProfileFormatItem> GetSampleFormatItems(params string[] allowed)
|
||||||
|
{
|
||||||
|
var allowedItems = _customFormats.Where(x => allowed.Contains(x.Name)).Select((f, index) => new ProfileFormatItem { Format = f, Score = (int)Math.Pow(2, index) }).ToList();
|
||||||
|
var disallowedItems = _customFormats.Where(x => !allowed.Contains(x.Name)).Select(f => new ProfileFormatItem { Format = f, Score = -1 * (int)Math.Pow(2, allowedItems.Count) });
|
||||||
|
|
||||||
|
return disallowedItems.Concat(allowedItems).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ProfileFormatItem> GetDefaultFormatItems()
|
||||||
|
{
|
||||||
|
return new List<ProfileFormatItem>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,431 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FluentAssertions;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using NzbDrone.Common.Serializer;
|
||||||
|
using NzbDrone.Core.Datastore.Migration;
|
||||||
|
using NzbDrone.Core.Test.Framework;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Test.Datastore.Migration
|
||||||
|
{
|
||||||
|
[TestFixture]
|
||||||
|
public class add_custom_formatsFixture : MigrationTest<add_custom_formats>
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void should_add_cf_from_named_release_profile()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x264",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = false,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var customFormats = db.Query<CustomFormat171>("SELECT \"Id\", \"Name\", \"IncludeCustomFormatWhenRenaming\", \"Specifications\" FROM \"CustomFormats\"");
|
||||||
|
|
||||||
|
customFormats.Should().HaveCount(1);
|
||||||
|
customFormats.First().Name.Should().Be("Unnamed_1");
|
||||||
|
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
|
||||||
|
customFormats.First().Specifications.Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_not_migrate_if_bad_regex_in_release_profile()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "[somestring[",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = true,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var customFormats = db.Query<CustomFormat171>("SELECT \"Id\", \"Name\", \"IncludeCustomFormatWhenRenaming\", \"Specifications\" FROM \"CustomFormats\"");
|
||||||
|
|
||||||
|
customFormats.Should().HaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_set_cf_naming_token_if_set_in_release_profile()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x264",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = true,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var customFormats = db.Query<CustomFormat171>("SELECT \"Id\", \"Name\", \"IncludeCustomFormatWhenRenaming\", \"Specifications\" FROM \"CustomFormats\"");
|
||||||
|
|
||||||
|
customFormats.Should().HaveCount(1);
|
||||||
|
customFormats.First().Name.Should().Be("Unnamed_1");
|
||||||
|
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeTrue();
|
||||||
|
customFormats.First().Specifications.Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_not_remove_release_profile_if_ignored_or_required()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x264",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "some",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = true,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var releaseProfiles = db.Query<ReleaseProfile171>("SELECT \"Id\" FROM \"ReleaseProfiles\"");
|
||||||
|
|
||||||
|
releaseProfiles.Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_remove_release_profile_if_no_ignored_or_required()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x264",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = true,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var releaseProfiles = db.Query<ReleaseProfile171>("SELECT \"Id\" FROM \"ReleaseProfiles\"");
|
||||||
|
|
||||||
|
releaseProfiles.Should().HaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_add_cf_from_unnamed_release_profile()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x264",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = false,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var customFormats = db.Query<CustomFormat171>("SELECT \"Id\", \"Name\", \"IncludeCustomFormatWhenRenaming\", \"Specifications\" FROM \"CustomFormats\"");
|
||||||
|
|
||||||
|
customFormats.Should().HaveCount(1);
|
||||||
|
customFormats.First().Name.Should().Be("Unnamed_1");
|
||||||
|
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
|
||||||
|
customFormats.First().Specifications.Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_add_cfs_from_multiple_unnamed_release_profile()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x264",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = false,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x265",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = false,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var customFormats = db.Query<CustomFormat171>("SELECT \"Id\", \"Name\", \"IncludeCustomFormatWhenRenaming\", \"Specifications\" FROM \"CustomFormats\"");
|
||||||
|
|
||||||
|
customFormats.Should().HaveCount(2);
|
||||||
|
customFormats.First().Name.Should().Be("Unnamed_1");
|
||||||
|
customFormats.Last().Name.Should().Be("Unnamed_2");
|
||||||
|
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
|
||||||
|
customFormats.First().Specifications.Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_add_two_cfs_if_release_profile_has_multiple_terms()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x264",
|
||||||
|
Value = 2
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x265",
|
||||||
|
Value = 5
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = false,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var customFormats = db.Query<CustomFormat171>("SELECT \"Id\", \"Name\", \"IncludeCustomFormatWhenRenaming\", \"Specifications\" FROM \"CustomFormats\"");
|
||||||
|
|
||||||
|
customFormats.Should().HaveCount(2);
|
||||||
|
customFormats.First().Name.Should().Be("Unnamed_1_0");
|
||||||
|
customFormats.Last().Name.Should().Be("Unnamed_1_1");
|
||||||
|
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
|
||||||
|
customFormats.First().Specifications.Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_set_scores_for_enabled_release_profiles()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x264",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = false,
|
||||||
|
Enabled = true,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
c.Insert.IntoTable("QualityProfiles").Row(new
|
||||||
|
{
|
||||||
|
Name = "SDTV",
|
||||||
|
Cutoff = 1,
|
||||||
|
Items = "[ { \"quality\": 1, \"allowed\": true } ]"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var customFormats = db.Query<QualityProfile171>("SELECT \"Id\", \"Name\", \"FormatItems\" FROM \"QualityProfiles\"");
|
||||||
|
|
||||||
|
customFormats.Should().HaveCount(1);
|
||||||
|
customFormats.First().FormatItems.Should().HaveCount(1);
|
||||||
|
customFormats.First().FormatItems.First().Score.Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_set_zero_scores_for_disabled_release_profiles()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("ReleaseProfiles").Row(new
|
||||||
|
{
|
||||||
|
Preferred = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Key = "x264",
|
||||||
|
Value = 2
|
||||||
|
}
|
||||||
|
}.ToJson(),
|
||||||
|
Required = "",
|
||||||
|
Ignored = "",
|
||||||
|
Tags = "[]",
|
||||||
|
IncludePreferredWhenRenaming = false,
|
||||||
|
Enabled = false,
|
||||||
|
IndexerId = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
c.Insert.IntoTable("QualityProfiles").Row(new
|
||||||
|
{
|
||||||
|
Name = "SDTV",
|
||||||
|
Cutoff = 1,
|
||||||
|
Items = "[ { \"quality\": 1, \"allowed\": true } ]"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var customFormats = db.Query<QualityProfile171>("SELECT \"Id\", \"Name\", \"FormatItems\" FROM \"QualityProfiles\"");
|
||||||
|
|
||||||
|
customFormats.Should().HaveCount(1);
|
||||||
|
customFormats.First().FormatItems.Should().HaveCount(1);
|
||||||
|
customFormats.First().FormatItems.First().Score.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_migrate_naming_configs()
|
||||||
|
{
|
||||||
|
var db = WithMigrationTestDb(c =>
|
||||||
|
{
|
||||||
|
c.Insert.IntoTable("NamingConfig").Row(new
|
||||||
|
{
|
||||||
|
ReplaceIllegalCharacters = false,
|
||||||
|
StandardTrackFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Preferred Words } {Quality Full}",
|
||||||
|
MultiDiscTrackFormat = "{Series Title} - {Air-Date} - {Episode Title} {Preferred.Words } {Quality Full}",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var customFormats = db.Query<NamingConfig171>("SELECT \"StandardTrackFormat\", \"MultiDiscTrackFormat\" FROM \"NamingConfig\"");
|
||||||
|
|
||||||
|
customFormats.Should().HaveCount(1);
|
||||||
|
customFormats.First().StandardTrackFormat.Should().Be("{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Custom Formats } {Quality Full}");
|
||||||
|
customFormats.First().MultiDiscTrackFormat.Should().Be("{Series Title} - {Air-Date} - {Episode Title} {Custom.Formats } {Quality Full}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NamingConfig171
|
||||||
|
{
|
||||||
|
public string StandardTrackFormat { get; set; }
|
||||||
|
public string MultiDiscTrackFormat { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ReleaseProfile171
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class QualityProfile171
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public List<FormatItem171> FormatItems { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FormatItem171
|
||||||
|
{
|
||||||
|
public int Format { get; set; }
|
||||||
|
public int Score { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CustomFormat171
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public bool IncludeCustomFormatWhenRenaming { get; set; }
|
||||||
|
public List<CustomFormatSpec171> Specifications { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CustomFormatSpec171
|
||||||
|
{
|
||||||
|
public string Type { get; set; }
|
||||||
|
public CustomFormatReleaseTitleSpec171 Body { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CustomFormatReleaseTitleSpec171
|
||||||
|
{
|
||||||
|
public int Order { get; set; }
|
||||||
|
public string ImplementationName { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Value { get; set; }
|
||||||
|
public bool Required { get; set; }
|
||||||
|
public bool Negate { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue