New: Show Series title and season number after task name when applicable

Closes #6601
This commit is contained in:
Mark McDowall 2024-03-12 22:34:47 -07:00 committed by Mark McDowall
parent 4d4d63921b
commit 6d552f2a60
14 changed files with 400 additions and 459 deletions

View File

@ -13,6 +13,8 @@ export interface CommandBody {
trigger: string;
suppressMessages: boolean;
seriesId?: number;
seriesIds?: number[];
seasonNumber?: number;
}
interface Command extends ModelBase {

View File

@ -0,0 +1,14 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createMultiSeriesSelector(seriesIds: number[]) {
return createSelector(
(state: AppState) => state.series.itemMap,
(state: AppState) => state.series.items,
(itemMap, allSeries) => {
return seriesIds.map((seriesId) => allSeries[itemMap[seriesId]]);
}
);
}
export default createMultiSeriesSelector;

View File

@ -10,15 +10,6 @@
width: 100%;
}
.commandName {
display: inline-block;
min-width: 220px;
}
.userAgent {
color: #b0b0b0;
}
.queued,
.started,
.ended {

View File

@ -2,14 +2,12 @@
// Please do not change this file!
interface CssExports {
'actions': string;
'commandName': string;
'duration': string;
'ended': string;
'queued': string;
'started': string;
'trigger': string;
'triggerContent': string;
'userAgent': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -1,279 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import styles from './QueuedTaskRow.css';
function getStatusIconProps(status, message) {
const title = titleCase(status);
switch (status) {
case 'queued':
return {
name: icons.PENDING,
title
};
case 'started':
return {
name: icons.REFRESH,
isSpinning: true,
title
};
case 'completed':
return {
name: icons.CHECK,
kind: kinds.SUCCESS,
title: message === 'Completed' ? title : `${title}: ${message}`
};
case 'failed':
return {
name: icons.FATAL,
kind: kinds.DANGER,
title: `${title}: ${message}`
};
default:
return {
name: icons.UNKNOWN,
title
};
}
}
function getFormattedDates(props) {
const {
queued,
started,
ended,
showRelativeDates,
shortDateFormat
} = props;
if (showRelativeDates) {
return {
queuedAt: moment(queued).fromNow(),
startedAt: started ? moment(started).fromNow() : '-',
endedAt: ended ? moment(ended).fromNow() : '-'
};
}
return {
queuedAt: formatDate(queued, shortDateFormat),
startedAt: started ? formatDate(started, shortDateFormat) : '-',
endedAt: ended ? formatDate(ended, shortDateFormat) : '-'
};
}
class QueuedTaskRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
...getFormattedDates(props),
isCancelConfirmModalOpen: false
};
this._updateTimeoutId = null;
}
componentDidMount() {
this.setUpdateTimer();
}
componentDidUpdate(prevProps) {
const {
queued,
started,
ended
} = this.props;
if (
queued !== prevProps.queued ||
started !== prevProps.started ||
ended !== prevProps.ended
) {
this.setState(getFormattedDates(this.props));
}
}
componentWillUnmount() {
if (this._updateTimeoutId) {
this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
}
}
//
// Control
setUpdateTimer() {
this._updateTimeoutId = setTimeout(() => {
this.setState(getFormattedDates(this.props));
this.setUpdateTimer();
}, 30000);
}
//
// Listeners
onCancelPress = () => {
this.setState({
isCancelConfirmModalOpen: true
});
};
onAbortCancel = () => {
this.setState({
isCancelConfirmModalOpen: false
});
};
//
// Render
render() {
const {
trigger,
commandName,
queued,
started,
ended,
status,
duration,
message,
clientUserAgent,
longDateFormat,
timeFormat,
onCancelPress
} = this.props;
const {
queuedAt,
startedAt,
endedAt,
isCancelConfirmModalOpen
} = this.state;
let triggerIcon = icons.QUICK;
if (trigger === 'manual') {
triggerIcon = icons.INTERACTIVE;
} else if (trigger === 'scheduled') {
triggerIcon = icons.SCHEDULED;
}
return (
<TableRow>
<TableRowCell className={styles.trigger}>
<span className={styles.triggerContent}>
<Icon
name={triggerIcon}
title={titleCase(trigger)}
/>
<Icon
{...getStatusIconProps(status, message)}
/>
</span>
</TableRowCell>
<TableRowCell>
<span className={styles.commandName}>
{commandName}
</span>
{
clientUserAgent ?
<span className={styles.userAgent} title={translate('TaskUserAgentTooltip')}>
{translate('From')}: {clientUserAgent}
</span> :
null
}
</TableRowCell>
<TableRowCell
className={styles.queued}
title={formatDateTime(queued, longDateFormat, timeFormat)}
>
{queuedAt}
</TableRowCell>
<TableRowCell
className={styles.started}
title={formatDateTime(started, longDateFormat, timeFormat)}
>
{startedAt}
</TableRowCell>
<TableRowCell
className={styles.ended}
title={formatDateTime(ended, longDateFormat, timeFormat)}
>
{endedAt}
</TableRowCell>
<TableRowCell className={styles.duration}>
{formatTimeSpan(duration)}
</TableRowCell>
<TableRowCell
className={styles.actions}
>
{
status === 'queued' &&
<IconButton
title={translate('RemovedFromTaskQueue')}
name={icons.REMOVE}
onPress={this.onCancelPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={isCancelConfirmModalOpen}
kind={kinds.DANGER}
title={translate('Cancel')}
message={translate('CancelPendingTask')}
confirmLabel={translate('YesCancel')}
cancelLabel={translate('NoLeaveIt')}
onConfirm={onCancelPress}
onCancel={this.onAbortCancel}
/>
</TableRow>
);
}
}
QueuedTaskRow.propTypes = {
trigger: PropTypes.string.isRequired,
commandName: PropTypes.string.isRequired,
queued: PropTypes.string.isRequired,
started: PropTypes.string,
ended: PropTypes.string,
status: PropTypes.string.isRequired,
duration: PropTypes.string,
message: PropTypes.string,
clientUserAgent: PropTypes.string,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onCancelPress: PropTypes.func.isRequired
};
export default QueuedTaskRow;

View File

@ -0,0 +1,238 @@
import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { CommandBody } from 'Commands/Command';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds } from 'Helpers/Props';
import { cancelCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import QueuedTaskRowNameCell from './QueuedTaskRowNameCell';
import styles from './QueuedTaskRow.css';
function getStatusIconProps(status: string, message: string | undefined) {
const title = titleCase(status);
switch (status) {
case 'queued':
return {
name: icons.PENDING,
title,
};
case 'started':
return {
name: icons.REFRESH,
isSpinning: true,
title,
};
case 'completed':
return {
name: icons.CHECK,
kind: kinds.SUCCESS,
title: message === 'Completed' ? title : `${title}: ${message}`,
};
case 'failed':
return {
name: icons.FATAL,
kind: kinds.DANGER,
title: `${title}: ${message}`,
};
default:
return {
name: icons.UNKNOWN,
title,
};
}
}
function getFormattedDates(
queued: string,
started: string | undefined,
ended: string | undefined,
showRelativeDates: boolean,
shortDateFormat: string
) {
if (showRelativeDates) {
return {
queuedAt: moment(queued).fromNow(),
startedAt: started ? moment(started).fromNow() : '-',
endedAt: ended ? moment(ended).fromNow() : '-',
};
}
return {
queuedAt: formatDate(queued, shortDateFormat),
startedAt: started ? formatDate(started, shortDateFormat) : '-',
endedAt: ended ? formatDate(ended, shortDateFormat) : '-',
};
}
interface QueuedTimes {
queuedAt: string;
startedAt: string;
endedAt: string;
}
export interface QueuedTaskRowProps {
id: number;
trigger: string;
commandName: string;
queued: string;
started?: string;
ended?: string;
status: string;
duration?: string;
message?: string;
body: CommandBody;
clientUserAgent?: string;
}
export default function QueuedTaskRow(props: QueuedTaskRowProps) {
const {
id,
trigger,
commandName,
queued,
started,
ended,
status,
duration,
message,
body,
clientUserAgent,
} = props;
const dispatch = useDispatch();
const { longDateFormat, shortDateFormat, showRelativeDates, timeFormat } =
useSelector(createUISettingsSelector());
const updateTimeTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const [times, setTimes] = useState<QueuedTimes>(
getFormattedDates(
queued,
started,
ended,
showRelativeDates,
shortDateFormat
)
);
const [
isCancelConfirmModalOpen,
openCancelConfirmModal,
closeCancelConfirmModal,
] = useModalOpenState(false);
const handleCancelPress = useCallback(() => {
dispatch(cancelCommand({ id }));
}, [id, dispatch]);
useEffect(() => {
updateTimeTimeoutId.current = setTimeout(() => {
setTimes(
getFormattedDates(
queued,
started,
ended,
showRelativeDates,
shortDateFormat
)
);
}, 30000);
return () => {
if (updateTimeTimeoutId.current) {
clearTimeout(updateTimeTimeoutId.current);
}
};
}, [queued, started, ended, showRelativeDates, shortDateFormat, setTimes]);
const { queuedAt, startedAt, endedAt } = times;
let triggerIcon = icons.QUICK;
if (trigger === 'manual') {
triggerIcon = icons.INTERACTIVE;
} else if (trigger === 'scheduled') {
triggerIcon = icons.SCHEDULED;
}
return (
<TableRow>
<TableRowCell className={styles.trigger}>
<span className={styles.triggerContent}>
<Icon name={triggerIcon} title={titleCase(trigger)} />
<Icon {...getStatusIconProps(status, message)} />
</span>
</TableRowCell>
<QueuedTaskRowNameCell
commandName={commandName}
body={body}
clientUserAgent={clientUserAgent}
/>
<TableRowCell
className={styles.queued}
title={formatDateTime(queued, longDateFormat, timeFormat)}
>
{queuedAt}
</TableRowCell>
<TableRowCell
className={styles.started}
title={formatDateTime(started, longDateFormat, timeFormat)}
>
{startedAt}
</TableRowCell>
<TableRowCell
className={styles.ended}
title={formatDateTime(ended, longDateFormat, timeFormat)}
>
{endedAt}
</TableRowCell>
<TableRowCell className={styles.duration}>
{formatTimeSpan(duration)}
</TableRowCell>
<TableRowCell className={styles.actions}>
{status === 'queued' && (
<IconButton
title={translate('RemovedFromTaskQueue')}
name={icons.REMOVE}
onPress={openCancelConfirmModal}
/>
)}
</TableRowCell>
<ConfirmModal
isOpen={isCancelConfirmModalOpen}
kind={kinds.DANGER}
title={translate('Cancel')}
message={translate('CancelPendingTask')}
confirmLabel={translate('YesCancel')}
cancelLabel={translate('NoLeaveIt')}
onConfirm={handleCancelPress}
onCancel={closeCancelConfirmModal}
/>
</TableRow>
);
}

View File

@ -1,31 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { cancelCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import QueuedTaskRow from './QueuedTaskRow';
function createMapStateToProps() {
return createSelector(
createUISettingsSelector(),
(uiSettings) => {
return {
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onCancelPress() {
dispatch(cancelCommand({
id: props.id
}));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow);

View File

@ -0,0 +1,8 @@
.commandName {
display: inline-block;
min-width: 220px;
}
.userAgent {
color: #b0b0b0;
}

View File

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

View File

@ -0,0 +1,54 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { CommandBody } from 'Commands/Command';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import createMultiSeriesSelector from 'Store/Selectors/createMultiSeriesSelector';
import translate from 'Utilities/String/translate';
import styles from './QueuedTaskRowNameCell.css';
export interface QueuedTaskRowNameCellProps {
commandName: string;
body: CommandBody;
clientUserAgent?: string;
}
export default function QueuedTaskRowNameCell(
props: QueuedTaskRowNameCellProps
) {
const { commandName, body, clientUserAgent } = props;
const seriesIds = [...(body.seriesIds ?? [])];
if (body.seriesId) {
seriesIds.push(body.seriesId);
}
const series = useSelector(createMultiSeriesSelector(seriesIds));
return (
<TableRowCell>
<span className={styles.commandName}>
{commandName}
{series.length ? (
<span> - {series.map((s) => s.title).join(', ')}</span>
) : null}
{body.seasonNumber ? (
<span>
{' '}
{translate('SeasonNumberToken', {
seasonNumber: body.seasonNumber,
})}
</span>
) : null}
</span>
{clientUserAgent ? (
<span
className={styles.userAgent}
title={translate('TaskUserAgentTooltip')}
>
{translate('From')}: {clientUserAgent}
</span>
) : null}
</TableRowCell>
);
}

View File

@ -1,90 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import translate from 'Utilities/String/translate';
import QueuedTaskRowConnector from './QueuedTaskRowConnector';
const columns = [
{
name: 'trigger',
label: '',
isVisible: true
},
{
name: 'commandName',
label: () => translate('Name'),
isVisible: true
},
{
name: 'queued',
label: () => translate('Queued'),
isVisible: true
},
{
name: 'started',
label: () => translate('Started'),
isVisible: true
},
{
name: 'ended',
label: () => translate('Ended'),
isVisible: true
},
{
name: 'duration',
label: () => translate('Duration'),
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
function QueuedTasks(props) {
const {
isFetching,
isPopulated,
items
} = props;
return (
<FieldSet legend={translate('Queue')}>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
isPopulated &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<QueuedTaskRowConnector
key={item.id}
{...item}
/>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
);
}
QueuedTasks.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired
};
export default QueuedTasks;

View File

@ -0,0 +1,74 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { fetchCommands } from 'Store/Actions/commandActions';
import translate from 'Utilities/String/translate';
import QueuedTaskRow from './QueuedTaskRow';
const columns = [
{
name: 'trigger',
label: '',
isVisible: true,
},
{
name: 'commandName',
label: () => translate('Name'),
isVisible: true,
},
{
name: 'queued',
label: () => translate('Queued'),
isVisible: true,
},
{
name: 'started',
label: () => translate('Started'),
isVisible: true,
},
{
name: 'ended',
label: () => translate('Ended'),
isVisible: true,
},
{
name: 'duration',
label: () => translate('Duration'),
isVisible: true,
},
{
name: 'actions',
isVisible: true,
},
];
export default function QueuedTasks() {
const dispatch = useDispatch();
const { isFetching, isPopulated, items } = useSelector(
(state: AppState) => state.commands
);
useEffect(() => {
dispatch(fetchCommands());
}, [dispatch]);
return (
<FieldSet legend={translate('Queue')}>
{isFetching && !isPopulated && <LoadingIndicator />}
{isPopulated && (
<Table columns={columns}>
<TableBody>
{items.map((item) => {
return <QueuedTaskRow key={item.id} {...item} />;
})}
</TableBody>
</Table>
)}
</FieldSet>
);
}

View File

@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchCommands } from 'Store/Actions/commandActions';
import QueuedTasks from './QueuedTasks';
function createMapStateToProps() {
return createSelector(
(state) => state.commands,
(commands) => {
return commands;
}
);
}
const mapDispatchToProps = {
dispatchFetchCommands: fetchCommands
};
class QueuedTasksConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchCommands();
}
//
// Render
render() {
return (
<QueuedTasks
{...this.props}
/>
);
}
}
QueuedTasksConnector.propTypes = {
dispatchFetchCommands: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector);

View File

@ -2,7 +2,7 @@ import React from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
import QueuedTasksConnector from './Queued/QueuedTasksConnector';
import QueuedTasks from './Queued/QueuedTasks';
import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector';
function Tasks() {
@ -10,7 +10,7 @@ function Tasks() {
<PageContent title={translate('Tasks')}>
<PageContentBody>
<ScheduledTasksConnector />
<QueuedTasksConnector />
<QueuedTasks />
</PageContentBody>
</PageContent>
);