1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-23 17:14:18 -04:00

Compare commits

..

65 Commits

Author SHA1 Message Date
Mark McDowall
d0101bdca1 Don't publish v5 builds yet 2025-02-01 11:58:31 -08:00
Mark McDowall
539f55deae Fix tests for v5 2025-02-01 11:58:31 -08:00
Mark McDowall
0f90544a35 Improve build speed 2025-02-01 11:58:31 -08:00
Mark McDowall
f10b0e51b5 Fix builds with v5 API 2025-01-30 21:15:48 -08:00
Mark McDowall
b5c54575c2 Fix import order after TS 2025-01-29 20:11:50 -08:00
Mark McDowall
f89ee64edc Build and conflict labeler for PRs against v5-develop 2025-01-29 20:11:50 -08:00
Mark McDowall
59800d3675 v5 API docs 2025-01-29 20:11:50 -08:00
Mark McDowall
ed1b17005f Add v5 API 2025-01-26 08:13:54 -08:00
Mark McDowall
ea5231abef Fix signalR 2025-01-25 19:38:34 -08:00
Steel City Phantom
42206b023f Auto-detect building on macOS ARM 2025-01-25 19:38:34 -08:00
Stevie Robinson
bf55bca142 New: Show size in history details
Closes #7594
2025-01-25 19:38:09 -08:00
Bogdan
496eb6fd37 Typing for Interactive Search payload 2025-01-25 19:38:08 -08:00
Bogdan
1a5fa185d1 New: Migrate appdata folder for .NET 8 on OSX 2025-01-25 19:38:08 -08:00
Bogdan
518f1799dc New: Bump to .NET 8 2025-01-25 19:38:08 -08:00
Mark McDowall
9f570d4dbf Convert signalR to TypeScript 2025-01-25 19:38:08 -08:00
Mark McDowall
dd014993f1 Convert Import Series to TypeScript 2025-01-25 19:38:08 -08:00
Mark McDowall
61876e795f Remove pnpm-lock.yaml 2025-01-25 19:38:07 -08:00
Mark McDowall
e721a06291 Remove defaultProps from SeriesIndexFilterMenu 2025-01-25 19:38:07 -08:00
Mark McDowall
37cc66ce66 Convert Add New Series to TypeScript 2025-01-25 19:38:07 -08:00
Mark McDowall
9c196c5fa0 Remove Measure 2025-01-25 19:38:07 -08:00
Mark McDowall
7dcf0808dc Convert Series Details to TypeScript 2025-01-25 19:38:07 -08:00
Mark McDowall
46afe84edc Convert Delete Series Modal to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
0ff3101511 Convert Series History to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
17aab235a5 Fixed Import List CleanLibraryLevel Options 2025-01-25 19:38:06 -08:00
Mark McDowall
f1cfef19b2 Convert Monitoring Options to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
d89d1b2f8b Convert MoveSeriesModal to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
f986062d4c Convert FilterBuilder types to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
e2bc322462 Convert Date utilties to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
8e2263d1a1 Convert TableOptionsWrapper to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
307135d3f0 Convert Series Popovers to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
0b58278e15 Convert Table to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
d8a147d234 Convert Messages to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
bba2ab98b6 Remove withCurrentPage 2025-01-25 19:38:04 -08:00
Mark McDowall
4852afcad7 Convert Missing to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
0cb656cdd8 Convert Cutoff Unmet to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
4a5b839d93 Convert Custom Format settings to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
65bae6a7ce Convert Notifications to TypeScript 2025-01-25 19:38:03 -08:00
Mark McDowall
8b0a1b7756 Convert Download Client settings to TypeScript 2025-01-25 19:38:03 -08:00
Mark McDowall
aad8ba0f9b Improve typings in FormInputGroup 2025-01-25 19:38:03 -08:00
Mark McDowall
ebb0aab2f5 Convert General Settings to TypeScript 2025-01-25 19:38:03 -08:00
Mark McDowall
e62c687d93 Convert ImportLists to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
ac2ecae874 Convert Indexer settings to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
3991eec5e0 Convert Media Management settings to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
15e4599d31 Convert MetadataSource to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
9f1b0d3a3b Upgrade react-dnd and DnD Components to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
64c1ef85c4 New: Quality limits are part of Quality Profile
Closes #613
2025-01-25 19:38:01 -08:00
Mark McDowall
9ce473d9bb Convert Quality Settings to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
89f584d1b3 Convert Tags to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
405ee7473c Convert MetadataSettings to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
e9f8023528 Convert UI Settings to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
86c785ffa0 Convert SettingsToolbar to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
64160866c3 Convert Log FIles to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
1e5932d89a Convert Log Events to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
9276bd7a16 Convert Backup and Restore to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
8a8ea4eb94 Convert Preview Rename to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
6bee95747e Convert SelectSeriesRow to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
dc576d0dd3 Convert TagList components to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
5dfb5de863 Convert Menu components to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
ac7ac34cc2 Convert ProviderFieldFormGroup to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
24173139f0 Remove defaultProps from TypeScript components 2025-01-25 19:37:59 -08:00
Mark McDowall
3cd8a2a98b Convert Filter components to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
e4f1b2c4ec Convert Spinner button components to TypeScript 2025-01-25 19:37:58 -08:00
Mark McDowall
442b3b506f Convert Modal components to TypeScript 2025-01-25 19:37:58 -08:00
Mark McDowall
e8a6ce371d useMeasure instead of Measure in TypeScript components 2025-01-25 19:37:58 -08:00
Mark McDowall
d9e5842f8b Convert Page components to TypeScript 2025-01-25 19:37:58 -08:00
430 changed files with 3836 additions and 11053 deletions

View File

@@ -170,7 +170,7 @@ runs:
framework="${{ inputs.framework }}"
runtime="${{ inputs.runtime }}"
cp scripts/test.sh "_tests/$framework/$runtime/publish"
cp test.sh "_tests/$framework/$runtime/publish"
rm -f _tests/$framework/$runtime/*.log.config

View File

@@ -33,7 +33,7 @@ jobs:
id: setup-dotnet
- name: Create openapi.json
run: ./scripts/docs.sh Linux x64
run: ./docs.sh Linux x64
- name: Commit API Docs Change
continue-on-error: true
@@ -50,16 +50,3 @@ jobs:
else
echo "No changes since last run"
fi
- name: Notify
if: failure()
uses: tsickert/discord-webhook@v6.0.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
username: "GitHub Actions"
avatar-url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
embed-title: "${{ github.workflow }}: Failure"
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
embed-description: |
Failed to update API docs
embed-color: "15158332"

View File

@@ -82,4 +82,4 @@ Thank you to [<img src="https://resources.jetbrains.com/storage/products/company
### Licenses
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2025
- Copyright 2010-2024

View File

@@ -7,9 +7,9 @@ cd /data/test
runTest()
{
bash scripts/test.sh Linux $1
bash test.sh Linux $1
cp TestResult.xml /data/_tests_results/TestResult_$1.xml
}
runTest Integration
runTest Unit
runTest Unit

View File

@@ -23,7 +23,7 @@ rm -rf $outputFolder
rm -rf $testPackageFolder
slnFile=src/Sonarr.sln
outputFile=src/Sonarr.Api.V5/openapi.json
platform=Posix
if [ "$PLATFORM" = "Windows" ]; then
@@ -38,10 +38,7 @@ dotnet clean $slnFile -c Release
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
dotnet new tool-manifest
dotnet tool install --version 8.0.0 Swashbuckle.AspNetCore.Cli
# Remove the openapi.json file so we can check if it was created
rm $outputFile
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
dotnet tool run swagger tofile --output ./src/Sonarr.Api.V5/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v5 &
@@ -49,9 +46,4 @@ sleep 45
kill %1
if [ ! -f $outputFile ]; then
echo "$outputFile not found, check logs for errors"
exit 1
fi
exit 0

View File

@@ -65,7 +65,7 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: 'auto',
publicPath: '/',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
sourceMapFilename: '[file].map'
},

View File

@@ -174,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
}
if (eventType === 'downloadFailed') {
const { message, indexer } = data as DownloadFailedHistory;
const { message } = data as DownloadFailedHistory;
return (
<DescriptionList>
@@ -188,10 +188,6 @@ function HistoryDetails(props: HistoryDetailsProps) {
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{indexer ? (
<DescriptionListItem title={translate('Indexer')} data={indexer} />
) : null}
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}

View File

@@ -61,7 +61,7 @@ function QueueDetails(props: QueueDetailsProps) {
anchor={progressBar!}
title={`${state} - ${progress.toFixed(1)}%`}
body={<div>{title}</div>}
position="bottom-start"
position={tooltipPositions.LEFT}
/>
);
}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AddSeries } from 'App/State/AddSeriesAppState';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
@@ -9,6 +10,7 @@ import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import useDebounce from 'Helpers/Hooks/useDebounce';
import useQueryParams from 'Helpers/Hooks/useQueryParams';
import { icons, kinds } from 'Helpers/Props';
@@ -16,7 +18,6 @@ import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
import { useLookupSeries } from './useAddSeries';
import styles from './AddNewSeries.css';
function AddNewSeries() {
@@ -47,7 +48,12 @@ function AddNewSeries() {
isFetching: isFetchingApi,
error,
data = [],
} = useLookupSeries(query);
} = useApiQuery<AddSeries[]>({
path: `/series/lookup?term=${query}`,
queryOptions: {
enabled: !!query,
},
});
useEffect(() => {
setIsFetching(isFetchingApi);
@@ -97,9 +103,7 @@ function AddNewSeries() {
{!isFetching && !error && !!data.length ? (
<div className={styles.searchResults}>
{data.map((item) => {
return (
<AddNewSeriesSearchResult key={item.tvdbId} series={item} />
);
return <AddNewSeriesSearchResult key={item.tvdbId} {...item} />;
})}
</div>
) : null}

View File

@@ -1,13 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import {
AddSeriesOptions,
setAddSeriesOption,
useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore';
import { useDispatch, useSelector } from 'react-redux';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import { AddSeries } from 'App/State/AddSeriesAppState';
import AppState from 'App/State/AppState';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@@ -21,43 +17,46 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesType } from 'Series/Series';
import SeriesPoster from 'Series/SeriesPoster';
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import useIsWindows from 'System/useIsWindows';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import { useAddSeries } from './useAddSeries';
import styles from './AddNewSeriesModalContent.css';
export interface AddNewSeriesModalContentProps {
series: AddSeries;
initialSeriesType: SeriesType;
export interface AddNewSeriesModalContentProps
extends Pick<
AddSeries,
'tvdbId' | 'title' | 'year' | 'overview' | 'images' | 'folder'
> {
initialSeriesType: string;
onModalClose: () => void;
}
function AddNewSeriesModalContent({
series,
tvdbId,
title,
year,
overview,
images,
folder,
initialSeriesType,
onModalClose,
}: AddNewSeriesModalContentProps) {
const { title, year, overview, images, folder } = series;
const options = useAddSeriesOptions();
const dispatch = useDispatch();
const { isAdding, addError, defaults } = useSelector(
(state: AppState) => state.addSeries
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows();
const {
isPending: isAdding,
error: addError,
mutate: addSeries,
} = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(options, {}, addError);
}, [options, addError]);
return selectSettings(defaults, {}, addError);
}, [defaults, addError]);
const [seriesType, setSeriesType] = useState<SeriesType>(
const [seriesType, setSeriesType] = useState(
initialSeriesType === 'standard'
? settings.seriesType.value
: initialSeriesType
@@ -75,33 +74,35 @@ function AddNewSeriesModalContent({
} = settings;
const handleInputChange = useCallback(
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
setAddSeriesOption(name as keyof AddSeriesOptions, value);
({ name, value }: InputChanged) => {
dispatch(setAddSeriesDefault({ [name]: value }));
},
[]
[dispatch]
);
const handleQualityProfileIdChange = useCallback(
({ value }: InputChanged<string | number>) => {
setAddSeriesOption('qualityProfileId', value as number);
dispatch(setAddSeriesDefault({ qualityProfileId: value }));
},
[]
[dispatch]
);
const handleAddSeriesPress = useCallback(() => {
addSeries({
...series,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value,
});
dispatch(
addSeries({
tvdbId,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value,
})
);
}, [
series,
tvdbId,
seriesType,
rootFolderPath,
monitor,
@@ -110,7 +111,7 @@ function AddNewSeriesModalContent({
searchForMissingEpisodes,
searchForCutoffUnmetEpisodes,
tags,
addSeries,
dispatch,
]);
useEffect(() => {

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import { AddSeries } from 'App/State/AddSeriesAppState';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
@@ -16,27 +16,24 @@ import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
interface AddNewSeriesSearchResultProps {
series: AddSeries;
}
function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
const {
tvdbId,
titleSlug,
title,
year,
network,
originalLanguage,
genres = [],
status,
statistics = {} as Statistics,
ratings,
overview,
seriesType,
images,
} = series;
type AddNewSeriesSearchResultProps = AddSeries;
function AddNewSeriesSearchResult({
tvdbId,
titleSlug,
title,
year,
network,
originalLanguage,
genres = [],
status,
statistics = {} as Statistics,
ratings,
folder,
overview,
seriesType,
images,
}: AddNewSeriesSearchResultProps) {
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const { isSmallScreen } = useSelector(createDimensionsSelector());
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
@@ -171,8 +168,13 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
<AddNewSeriesModal
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
series={series}
tvdbId={tvdbId}
title={title}
year={year}
overview={overview}
folder={folder}
initialSeriesType={seriesType}
images={images}
onModalClose={handleAddSeriesModalClose}
/>
</div>

View File

@@ -1,43 +0,0 @@
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Series from 'Series/Series';
import { updateItem } from 'Store/Actions/baseActions';
type AddSeriesPayload = AddSeries & AddSeriesOptions;
export const useLookupSeries = (query: string) => {
return useApiQuery<AddSeries[]>({
path: '/series/lookup',
queryParams: {
term: query,
},
queryOptions: {
enabled: !!query,
// Disable refetch on window focus to prevent refetching when the user switch tabs
refetchOnWindowFocus: false,
},
});
};
export const useAddSeries = () => {
const dispatch = useDispatch();
const onAddSuccess = useCallback(
(data: Series) => {
dispatch(updateItem({ section: 'series', ...data }));
},
[dispatch]
);
return useApiMutation<Series, AddSeriesPayload>({
path: '/series',
method: 'POST',
mutationOptions: {
onSuccess: onAddSuccess,
},
});
};

View File

@@ -1,7 +0,0 @@
import Series from 'Series/Series';
interface AddSeries extends Series {
folder: string;
}
export default AddSeries;

View File

@@ -1,10 +1,6 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import {
setAddSeriesOption,
useAddSeriesOption,
} from 'AddSeries/addSeriesOptionsStore';
import { SelectProvider } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
@@ -12,6 +8,7 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { kinds } from 'Helpers/Props';
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import { clearImportSeries } from 'Store/Actions/importSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import translate from 'Utilities/String/translate';
@@ -51,7 +48,9 @@ function ImportSeries() {
(state: AppState) => state.settings.qualityProfiles.items
);
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
const defaultQualityProfileId = useSelector(
(state: AppState) => state.addSeries.defaults.qualityProfileId
);
const scrollerRef = useRef<HTMLDivElement>(null);
@@ -77,7 +76,9 @@ function ImportSeries() {
!defaultQualityProfileId ||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
) {
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
dispatch(
setAddSeriesDefault({ qualityProfileId: qualityProfiles[0].id })
);
}
}, [defaultQualityProfileId, qualityProfiles, dispatch]);

View File

@@ -1,10 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
AddSeriesOptions,
setAddSeriesOption,
useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import CheckInput from 'Components/Form/CheckInput';
@@ -17,6 +12,7 @@ import PageContentFooter from 'Components/Page/PageContentFooter';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesMonitor, SeriesType } from 'Series/Series';
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import {
cancelLookupSeries,
importSeries,
@@ -37,7 +33,7 @@ function ImportSeriesFooter() {
qualityProfileId: defaultQualityProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder,
} = useAddSeriesOptions();
} = useSelector((state: AppState) => state.addSeries.defaults);
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
(state: AppState) => state.importSeries
@@ -114,7 +110,7 @@ function ImportSeriesFooter() {
]);
const handleInputChange = useCallback(
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
({ name, value }: InputChanged) => {
if (name === 'monitor') {
setMonitor(value as SeriesMonitor);
} else if (name === 'qualityProfileId') {
@@ -125,7 +121,7 @@ function ImportSeriesFooter() {
setSeasonFolder(value as boolean);
}
setAddSeriesOption(name as keyof AddSeriesOptions, value);
dispatch(setAddSeriesDefault({ [name]: value }));
selectedIds.forEach((id) => {
dispatch(

View File

@@ -75,6 +75,11 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) {
[selectDispatch]
);
console.info(
'\x1b[36m[MarkTest] is selected\x1b[0m',
selectState.selectedState[id]
);
return (
<>
<VirtualTableSelectCell

View File

@@ -1,7 +1,6 @@
import React, { RefObject, useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
@@ -60,8 +59,9 @@ function ImportSeriesTable({
}: ImportSeriesTableProps) {
const dispatch = useDispatch();
const { monitor, qualityProfileId, seriesType, seasonFolder } =
useAddSeriesOptions();
const { monitor, qualityProfileId, seriesType, seasonFolder } = useSelector(
(state: AppState) => state.addSeries.defaults
);
const items = useSelector((state: AppState) => state.importSeries.items);
const { isSmallScreen } = useSelector(createDimensionsSelector());

View File

@@ -1,13 +1,5 @@
import {
autoUpdate,
flip,
FloatingPortal,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormInputButton from 'Components/Form/FormInputButton';
@@ -15,6 +7,7 @@ import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Portal from 'Components/Portal';
import { icons, kinds } from 'Helpers/Props';
import {
queueLookupSeries,
@@ -54,6 +47,9 @@ function ImportSeriesSelectSeries({
// @ts-expect-error - ignoring this for now
} = useSelector(createImportSeriesItemSelector(id, { id }));
const buttonId = useId();
const contentId = useId();
const updater = useRef<(() => void) | null>(null);
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
const [term, setTerm] = useState('');
@@ -61,6 +57,37 @@ function ImportSeriesSelectSeries({
const errorMessage = getErrorMessage(error);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const button = document.getElementById(buttonId);
const content = document.getElementById(contentId);
const eventTarget = event.target as HTMLElement;
if (!button || !eventTarget.isConnected) {
return;
}
if (
!button.contains(eventTarget) &&
content &&
!content.contains(eventTarget) &&
isOpen
) {
setIsOpen(false);
window.removeEventListener('click', handleWindowClick);
}
},
[isOpen, buttonId, contentId, setIsOpen]
);
const addListener = useCallback(() => {
window.addEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const removeListener = useCallback(() => {
window.removeEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const handlePress = useCallback(() => {
setIsOpen((prevIsOpen) => !prevIsOpen);
}, []);
@@ -120,139 +147,157 @@ function ImportSeriesSelectSeries({
[id, items, dispatch, onInputChange]
);
useEffect(() => {
if (updater.current) {
updater.current();
}
});
useEffect(() => {
if (isOpen) {
addListener();
} else {
removeListener();
}
return removeListener;
}, [isOpen, addListener, removeListener]);
useEffect(() => {
setTerm(itemTerm);
}, [itemTerm]);
const { refs, context, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
],
open: isOpen,
placement: 'bottom',
whileElementsMounted: autoUpdate,
onOpenChange: setIsOpen,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
]);
return (
<>
<div ref={refs.setReference} {...getReferenceProps()}>
<Link className={styles.button} component="div" onPress={handlePress}>
{isLookingUpSeries && isQueued && !isPopulated ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={buttonId}>
<Link
// ref={ref}
className={styles.button}
component="div"
onPress={handlePress}
>
{isLookingUpSeries && isQueued && !isPopulated ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isPopulated && selectedSeries && isExistingSeries ? (
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
) : null}
{isPopulated && selectedSeries && isExistingSeries ? (
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
) : null}
{isPopulated && selectedSeries ? (
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/>
) : null}
{isPopulated && selectedSeries ? (
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/>
) : null}
{isPopulated && !selectedSeries ? (
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('NoMatchFound')}
</div>
) : null}
{!isFetching && !!error ? (
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('SearchFailedError')}
</div>
) : null}
<div className={styles.dropdownArrowContainer}>
<Icon name={icons.CARET_DOWN} />
</div>
</Link>
</div>
{isOpen ? (
<FloatingPortal id="portal-root">
<div
ref={refs.setFloating}
className={styles.contentContainer}
style={floatingStyles}
{...getFloatingProps()}
>
{isOpen ? (
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={term}
onChange={handleSearchInputChange}
{isPopulated && !selectedSeries ? (
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={handleRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
{translate('NoMatchFound')}
</div>
) : null}
<div className={styles.results}>
{items.map((item) => {
return (
<ImportSeriesSearchResult
key={item.tvdbId}
tvdbId={item.tvdbId}
title={item.title}
year={item.year}
network={item.network}
onPress={handleSeriesSelect}
/>
);
})}
{!isFetching && !!error ? (
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('SearchFailedError')}
</div>
) : null}
<div className={styles.dropdownArrowContainer}>
<Icon name={icons.CARET_DOWN} />
</div>
) : null}
</Link>
</div>
</FloatingPortal>
) : null}
</>
)}
</Reference>
<Portal>
<Popper
placement="bottom"
modifiers={{
preventOverflow: {
boundariesElement: 'viewport',
},
}}
>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={ref}
id={contentId}
className={styles.contentContainer}
style={style}
>
{isOpen ? (
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={term}
onChange={handleSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={handleRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{items.map((item) => {
return (
<ImportSeriesSearchResult
key={item.tvdbId}
tvdbId={item.tvdbId}
title={item.title}
year={item.year}
network={item.network}
onPress={handleSeriesSelect}
/>
);
})}
</div>
</div>
) : null}
</div>
);
}}
</Popper>
</Portal>
</Manager>
);
}

View File

@@ -1,49 +0,0 @@
import { createPersist } from 'Helpers/createPersist';
import { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeriesOptions {
rootFolderPath: string;
monitor: SeriesMonitor;
qualityProfileId: number;
seriesType: SeriesType;
seasonFolder: boolean;
searchForMissingEpisodes: boolean;
searchForCutoffUnmetEpisodes: boolean;
tags: number[];
}
const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
'add_series_options',
() => {
return {
rootFolderPath: '',
monitor: 'all',
qualityProfileId: 0,
seriesType: 'standard',
seasonFolder: true,
searchForMissingEpisodes: false,
searchForCutoffUnmetEpisodes: false,
tags: [],
};
}
);
export const useAddSeriesOptions = () => {
return addSeriesOptionsStore((state) => state);
};
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K
) => {
return addSeriesOptionsStore((state) => state[key]);
};
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K,
value: AddSeriesOptions[K]
) => {
addSeriesOptionsStore.setState((state) => ({
...state,
[key]: value,
}));
};

View File

@@ -11,7 +11,6 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import { fetchUpdates } from 'Store/Actions/systemActions';
import UpdateChanges from 'System/Updates/UpdateChanges';
import useUpdates from 'System/Updates/useUpdates';
import Update from 'typings/Update';
import translate from 'Utilities/String/translate';
import AppState from './State/AppState';
@@ -66,12 +65,14 @@ interface AppUpdatedModalContentProps {
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const dispatch = useDispatch();
const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isFetched, error, data } = useUpdates();
const { isPopulated, error, items } = useSelector(
(state: AppState) => state.system.updates
);
const previousVersion = usePrevious(version);
const { onModalClose } = props;
const update = mergeUpdates(data, version, prevVersion);
const update = mergeUpdates(items, version, prevVersion);
const handleSeeChangesPress = useCallback(() => {
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
@@ -99,7 +100,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
/>
</div>
{isFetched && !error && !!update ? (
{isPopulated && !error && !!update ? (
<div>
{update.changes ? (
<div className={styles.maintenance}>
@@ -125,7 +126,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
</div>
) : null}
{!isFetched && !error ? <LoadingIndicator /> : null}
{!isPopulated && !error ? <LoadingIndicator /> : null}
</ModalBody>
<ModalFooter>

View File

@@ -1,9 +1,20 @@
import { useCallback, useEffect } from 'react';
import useTheme from 'Helpers/Hooks/useTheme';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
import AppState from './State/AppState';
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
(theme) => {
return theme;
}
);
}
function ApplyTheme() {
const theme = useTheme();
const theme = useSelector(createThemeSelector());
const updateCSSVariables = useCallback(() => {
Object.entries(themes[theme]).forEach(([key, value]) => {

View File

@@ -0,0 +1,25 @@
import AppSectionState, { Error } from 'App/State/AppSectionState';
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeries extends Series {
folder: string;
}
interface AddSeriesAppState extends AppSectionState<AddSeries> {
isAdding: boolean;
isAdded: boolean;
addError: Error | undefined;
defaults: {
rootFolderPath: string;
monitor: SeriesMonitor;
qualityProfileId: number;
seriesType: SeriesType;
seasonFolder: boolean;
tags: number[];
searchForMissingEpisodes: boolean;
searchForCutoffUnmetEpisodes: boolean;
};
}
export default AddSeriesAppState;

View File

@@ -1,6 +1,7 @@
import ModelBase from 'App/ModelBase';
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
import AddSeriesAppState from './AddSeriesAppState';
import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
@@ -49,6 +50,7 @@ export interface PropertyFilter {
export interface Filter {
key: string;
label: string | (() => string);
type: string;
filters: PropertyFilter[];
}
@@ -81,6 +83,7 @@ export interface AppSectionState {
}
interface AppState {
addSeries: AddSeriesAppState;
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;

View File

@@ -0,0 +1,14 @@
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState';
import LogEvent from 'typings/LogEvent';
interface LogsAppState
extends AppSectionState<LogEvent>,
AppSectionFilterState<LogEvent>,
PagedAppSectionState,
TableAppSectionState {}
export default LogsAppState;

View File

@@ -3,23 +3,28 @@ import Health from 'typings/Health';
import LogFile from 'typings/LogFile';
import SystemStatus from 'typings/SystemStatus';
import Task from 'typings/Task';
import Update from 'typings/Update';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
import BackupAppState from './BackupAppState';
import LogsAppState from './LogsAppState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type TaskAppState = AppSectionState<Task>;
export type LogFilesAppState = AppSectionState<LogFile>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
backups: BackupAppState;
diskSpace: DiskSpaceAppState;
health: HealthAppState;
logFiles: LogFilesAppState;
logs: LogsAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updateLogFiles: LogFilesAppState;
updates: UpdateAppState;
}
export default SystemAppState;

View File

@@ -1,4 +1,3 @@
import { autoUpdate, flip, size, useFloating } from '@floating-ui/react-dom';
import classNames from 'classnames';
import React, {
FocusEvent,
@@ -20,6 +19,8 @@ import Autosuggest, {
RenderInputComponentProps,
RenderSuggestionsContainerParams,
} from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { InputChanged } from 'typings/inputs';
import styles from './AutoSuggestInput.css';
@@ -36,6 +37,7 @@ interface AutoSuggestInputProps<T>
hasError?: boolean;
hasWarning?: boolean;
enforceMaxHeight?: boolean;
minHeight?: number;
maxHeight?: number;
renderInputComponent?: (
inputProps: RenderInputComponentProps,
@@ -68,6 +70,7 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
enforceMaxHeight = true,
hasError,
hasWarning,
minHeight = 50,
maxHeight = 200,
getSuggestionValue,
renderSuggestion,
@@ -86,59 +89,95 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
const updater = useRef<(() => void) | null>(null);
const previousSuggestions = usePrevious(suggestions);
const { refs, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
});
},
}),
],
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
});
const handleComputeMaxHeight = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const { top, bottom, width } = data.offsets.reference;
if (enforceMaxHeight) {
data.styles.maxHeight = maxHeight;
} else {
const windowHeight = window.innerHeight;
if (/^botton/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
}
data.styles.width = width;
return data;
},
[enforceMaxHeight, maxHeight]
);
const createRenderInputComponent = useCallback(
(inputProps: RenderInputComponentProps) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, refs.setReference);
}
return (
<div ref={refs.setReference}>
<input {...inputProps} />
</div>
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input {...inputProps} />
</div>
);
}}
</Reference>
);
},
[refs.setReference, renderInputComponent]
[renderInputComponent]
);
const renderSuggestionsContainer = useCallback(
({ containerProps, children }: RenderSuggestionsContainerParams) => {
return (
<div
ref={refs.setFloating}
style={floatingStyles}
className={children ? styles.suggestionsContainerOpen : undefined}
>
<div
{...containerProps}
style={{
maxHeight: enforceMaxHeight ? maxHeight : undefined,
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
flip: {
padding: minHeight,
},
}}
>
{children}
</div>
</div>
{({ ref: popperRef, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={
children ? styles.suggestionsContainerOpen : undefined
}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight,
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
},
[enforceMaxHeight, floatingStyles, maxHeight, refs.setFloating]
[minHeight, handleComputeMaxHeight]
);
const handleInputKeyDown = useCallback(
@@ -197,21 +236,23 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
}, [suggestions, previousSuggestions]);
return (
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}

View File

@@ -1,38 +1,36 @@
import {
autoUpdate,
flip,
FloatingPortal,
size,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import classNames from 'classnames';
import React, {
ElementType,
KeyboardEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import Portal from 'Components/Portal';
import Scroller from 'Components/Scroller/Scroller';
import useMeasure from 'Helpers/Hooks/useMeasure';
import { icons } from 'Helpers/Props';
import ArrayElement from 'typings/Helpers/ArrayElement';
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import getUniqueElementId from 'Utilities/getUniqueElementId';
import TextInput from '../TextInput';
import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 30;
function isArrowKey(keyCode: number) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@@ -164,6 +162,10 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
onOpen,
} = props;
const [measureRef, { width }] = useMeasure();
const updater = useRef<(() => void) | null>(null);
const buttonId = useMemo(() => getUniqueElementId(), []);
const optionsId = useMemo(() => getUniqueElementId(), []);
const [selectedIndex, setSelectedIndex] = useState(
getSelectedIndex(value, values)
);
@@ -173,32 +175,6 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
const isMultiSelect = Array.isArray(value);
const selectedOption = getSelectedOption(selectedIndex, values);
const { refs, context, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
'min-width': `${rects.reference.width}px`,
});
},
}),
],
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
]);
const selectedValue = useMemo(() => {
if (values.length) {
return value;
@@ -213,6 +189,52 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
return '';
}, [value, values, isMultiSelect]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleComputeMaxHeight = useCallback((data: any) => {
const { top, bottom } = data.offsets.reference;
const windowHeight = window.innerHeight;
if (/^bottom/.test(data.placement)) {
data.styles.maxHeight =
windowHeight - bottom - MINIMUM_DISTANCE_FROM_EDGE;
} else {
data.styles.maxHeight = top - MINIMUM_DISTANCE_FROM_EDGE;
}
return data;
}, []);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const button = document.getElementById(buttonId);
const options = document.getElementById(optionsId);
const eventTarget = event.target as HTMLElement;
if (!button || !eventTarget.isConnected || isMobile) {
return;
}
if (
!button.contains(eventTarget) &&
options &&
!options.contains(eventTarget) &&
isOpen
) {
setIsOpen(false);
window.removeEventListener('click', handleWindowClick);
}
},
[isMobile, isOpen, buttonId, optionsId, setIsOpen]
);
const addListener = useCallback(() => {
window.addEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const removeListener = useCallback(() => {
window.removeEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const handlePress = useCallback(() => {
if (!isOpen && onOpen) {
onOpen();
@@ -276,9 +298,10 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
const handleFocus = useCallback(() => {
if (isOpen) {
removeListener();
setIsOpen(false);
}
}, [isOpen, setIsOpen]);
}, [isOpen, setIsOpen, removeListener]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
@@ -372,119 +395,172 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
[onChange]
);
useEffect(() => {
if (updater.current) {
updater.current();
}
});
useEffect(() => {
if (isOpen) {
addListener();
} else {
removeListener();
}
return removeListener;
}, [isOpen, addListener, removeListener]);
return (
<>
<div ref={refs.setReference} {...getReferenceProps()}>
{isEditable && typeof value === 'string' ? (
<div className={styles.editableContainer}>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleEditChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
)}
onPress={handlePress}
>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</Link>
</div>
) : (
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPress={handlePress}
>
<SelectedValueComponent
values={values}
{...selectedValueOptions}
selectedValue={selectedValue}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : selectedValue}
</SelectedValueComponent>
<div
className={
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
}
>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</div>
</Link>
)}
</div>
{isOpen ? (
<FloatingPortal id="portal-root">
<div
ref={refs.setFloating}
className={styles.optionsContainer}
style={floatingStyles}
{...getFloatingProps()}
>
{isOpen && !isMobile ? (
<Scroller className={styles.options}>
{values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected =
v.parentKey !== undefined &&
Array.isArray(value) &&
value.includes(v.parentKey);
const { key, ...other } = v;
return (
<OptionComponent
key={v.key}
id={v.key}
depth={depth}
isSelected={isSelectedItem(index, value, values)}
isDisabled={parentSelected}
isMultiSelect={isMultiSelect}
{...valueOptions}
{...other}
isMobile={false}
onSelect={handleSelect}
<div>
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={buttonId}>
<div ref={measureRef}>
{isEditable && typeof value === 'string' ? (
<div className={styles.editableContainer}>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleEditChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
)}
onPress={handlePress}
>
{v.value}
</OptionComponent>
);
})}
</Scroller>
) : null}
</div>
</FloatingPortal>
) : null}
{isFetching ? (
<LoadingIndicator
className={styles.loading}
size={20}
/>
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</Link>
</div>
) : (
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPress={handlePress}
>
<SelectedValueComponent
values={values}
{...selectedValueOptions}
selectedValue={selectedValue}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : selectedValue}
</SelectedValueComponent>
<div
className={
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
}
>
{isFetching ? (
<LoadingIndicator
className={styles.loading}
size={20}
/>
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</div>
</Link>
)}
</div>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
}}
>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={ref}
id={optionsId}
className={styles.optionsContainer}
style={{
...style,
minWidth: width,
}}
>
{isOpen && !isMobile ? (
<Scroller
className={styles.options}
style={{
maxHeight: style.maxHeight,
}}
>
{values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected =
v.parentKey !== undefined &&
Array.isArray(value) &&
value.includes(v.parentKey);
const { key, ...other } = v;
return (
<OptionComponent
key={v.key}
id={v.key}
depth={depth}
isSelected={isSelectedItem(index, value, values)}
isDisabled={parentSelected}
isMultiSelect={isMultiSelect}
{...valueOptions}
{...other}
isMobile={false}
onSelect={handleSelect}
>
{v.value}
</OptionComponent>
);
})}
</Scroller>
) : null}
</div>
);
}}
</Popper>
</Portal>
</Manager>
{isMobile ? (
<Modal
@@ -539,7 +615,7 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
</ModalBody>
</Modal>
) : null}
</>
</div>
);
}

View File

@@ -19,6 +19,7 @@ function HintedSelectInputOption(props: HintedSelectInputOptionProps) {
hint,
depth,
isSelected = false,
isMultiSelect,
isMobile,
...otherProps
} = props;

View File

@@ -1,22 +1,41 @@
import {
autoUpdate,
flip,
FloatingPortal,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import React, {
ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { Manager, Popper, PopperProps, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import styles from './Menu.css';
const sharedPopperOptions = {
modifiers: {
preventOverflow: {
padding: 0,
},
flip: {
padding: 0,
},
},
};
const popperOptions: {
right: Partial<PopperProps>;
left: Partial<PopperProps>;
} = {
right: {
...sharedPopperOptions,
placement: 'bottom-end',
},
left: {
...sharedPopperOptions,
placement: 'bottom-start',
},
};
interface MenuProps {
className?: string;
children: React.ReactNode;
@@ -30,7 +49,9 @@ function Menu({
alignMenu = 'left',
enforceMaxHeight = true,
}: MenuProps) {
const updater = useRef<(() => void) | null>(null);
const menuButtonId = useId();
const menuContentId = useId();
const [maxHeight, setMaxHeight] = useState(0);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -49,24 +70,45 @@ function Menu({
setMaxHeight(height);
}, [menuButtonId]);
const handleMenuButtonPress = useCallback(() => {
setIsMenuOpen((isOpen) => !isOpen);
}, []);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const menuButton = document.getElementById(menuButtonId);
const childrenArray = React.Children.toArray(children);
const button = React.cloneElement(childrenArray[0] as ReactElement, {
onPress: handleMenuButtonPress,
});
if (!menuButton) {
return;
}
const handleFloaterPress = useCallback((_event: MouseEvent) => {
// TODO: Menu items should handle closing when they are clicked.
// This is handled before the menu item click event is handled, so wait 100ms before closing.
setTimeout(() => {
setIsMenuOpen(false);
}, 100);
if (!menuButton.contains(event.target as Node)) {
setIsMenuOpen(false);
}
},
[menuButtonId]
);
return true;
}, []);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const menuButton = document.getElementById(menuButtonId);
const menuContent = document.getElementById(menuContentId);
if (!menuButton || !menuContent) {
return;
}
if (event.targetTouches.length !== 1) {
return;
}
const target = event.targetTouches[0].target;
if (
!menuButton.contains(target as Node) &&
!menuContent.contains(target as Node)
) {
setIsMenuOpen(false);
}
},
[menuButtonId, menuContentId]
);
const handleWindowResize = useCallback(() => {
updateMaxHeight();
@@ -78,15 +120,32 @@ function Menu({
}
}, [isMenuOpen, updateMaxHeight]);
const handleMenuButtonPress = useCallback(() => {
setIsMenuOpen((isOpen) => !isOpen);
}, []);
const childrenArray = React.Children.toArray(children);
const button = React.cloneElement(childrenArray[0] as ReactElement, {
onPress: handleMenuButtonPress,
});
useEffect(() => {
if (enforceMaxHeight) {
updateMaxHeight();
}
}, [enforceMaxHeight, updateMaxHeight]);
useEffect(() => {
if (updater.current && isMenuOpen) {
updater.current();
}
}, [isMenuOpen]);
useEffect(() => {
// Listen to resize events on the window and scroll events
// on all elements to ensure the menu is the best size possible.
// Listen for click events on the window to support closing the
// menu on clicks outside.
if (!isMenuOpen) {
return;
@@ -94,65 +153,52 @@ function Menu({
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleWindowScroll, { capture: true });
window.addEventListener('click', handleWindowClick);
window.addEventListener('touchstart', handleTouchStart);
return () => {
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleWindowScroll, {
capture: true,
});
window.removeEventListener('click', handleWindowClick);
window.removeEventListener('touchstart', handleTouchStart);
};
}, [isMenuOpen, handleWindowResize, handleWindowScroll]);
const { refs, context, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
// offset({ mainAxis: 10 }),
shift(),
],
open: isMenuOpen,
placement: alignMenu === 'left' ? 'bottom-start' : 'bottom-end',
whileElementsMounted: autoUpdate,
onOpenChange: setIsMenuOpen,
});
const click = useClick(context);
const dismiss = useDismiss(context, {
outsidePress: handleFloaterPress,
});
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
}, [
isMenuOpen,
handleWindowResize,
handleWindowScroll,
handleWindowClick,
handleTouchStart,
]);
return (
<>
<div
ref={refs.setReference}
{...getReferenceProps()}
id={menuButtonId}
className={className}
>
{button}
</div>
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={menuButtonId} className={className}>
{button}
</div>
)}
</Reference>
{isMenuOpen ? (
<FloatingPortal id="portal-root">
{React.cloneElement(childrenArray[1] as ReactElement, {
forwardedRef: refs.setFloating,
style: {
maxHeight,
...floatingStyles,
},
isOpen: isMenuOpen,
...getFloatingProps(),
})}
</FloatingPortal>
) : null}
</>
<Portal>
<Popper {...popperOptions[alignMenu]}>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return React.cloneElement(childrenArray[1] as ReactElement, {
forwardedRef: ref,
style: {
...style,
maxHeight,
},
isOpen: isMenuOpen,
});
}}
</Popper>
</Portal>
</Manager>
);
}

View File

@@ -88,6 +88,13 @@
}
@media only screen and (max-width: $breakpointMedium) {
.modal.small,
.modal.medium {
width: 90%;
}
}
@media only screen and (max-width: $breakpointSmall) {
.modalContainer {
position: fixed;
}

View File

@@ -24,7 +24,6 @@
composes: link;
padding: 10px 24px;
padding-left: 35px;
}
.isActiveLink {
@@ -42,6 +41,10 @@
text-align: center;
}
.noIcon {
margin-left: 25px;
}
.status {
float: right;
}

View File

@@ -8,6 +8,7 @@ interface CssExports {
'isActiveParentLink': string;
'item': string;
'link': string;
'noIcon': string;
'status': string;
}
export const cssExports: CssExports;

View File

@@ -54,7 +54,9 @@ function PageSidebarItem({
</span>
)}
{typeof title === 'function' ? title() : title}
<span className={isChildItem ? styles.noIcon : undefined}>
{typeof title === 'function' ? title() : title}
</span>
{!!StatusComponent && (
<span className={styles.status}>

View File

@@ -22,14 +22,11 @@
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
height: 24px;
}
.label {
padding: 0 3px;
max-width: 100%;
max-height: 100%;
color: var(--toolbarLabelColor);
font-size: $extraSmallFontSize;
line-height: calc($extraSmallFontSize + 1px);

View File

@@ -31,7 +31,6 @@ function PageToolbarButton({
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled || isSpinning}
title={label}
{...otherProps}
>
<Icon

View File

@@ -4,7 +4,7 @@
line-height: 1.52857143;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.cell {
white-space: nowrap;
}

View File

@@ -7,7 +7,7 @@
white-space: nowrap;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.cell {
white-space: nowrap;
}

View File

@@ -8,7 +8,7 @@ interface Column {
name: string;
label: string | PropertyFunction<string> | React.ReactNode;
className?: string;
columnLabel?: string | PropertyFunction<string>;
columnLabel?: string;
isSortable?: boolean;
fixedSortDirection?: SortDirection;
isVisible: boolean;

View File

@@ -10,7 +10,7 @@
border-collapse: collapse;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.tableContainer {
min-width: 100%;
width: fit-content;

View File

@@ -9,7 +9,7 @@
margin-left: 10px;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.headerCell {
white-space: nowrap;
}

View File

@@ -60,7 +60,7 @@
height: 25px;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.pager {
flex-wrap: wrap;
}

View File

@@ -14,10 +14,10 @@ interface TablePagerProps {
totalPages?: number;
totalRecords?: number;
isFetching?: boolean;
onFirstPagePress?: () => void;
onPreviousPagePress?: () => void;
onNextPagePress?: () => void;
onLastPagePress?: () => void;
onFirstPagePress: () => void;
onPreviousPagePress: () => void;
onNextPagePress: () => void;
onLastPagePress: () => void;
onPageSelect: (page: number) => void;
}
@@ -26,6 +26,10 @@ function TablePager({
totalPages,
totalRecords = 0,
isFetching,
onFirstPagePress,
onPreviousPagePress,
onNextPagePress,
onLastPagePress,
onPageSelect,
}: TablePagerProps) {
const [isShowingPageSelect, setIsShowingPageSelect] = useState(false);
@@ -60,34 +64,6 @@ function TablePager({
setIsShowingPageSelect(false);
}, []);
const handleFirstPagePress = useCallback(() => {
onPageSelect(1);
}, [onPageSelect]);
const onPreviousPagePress = useCallback(() => {
if (!page) {
return;
}
onPageSelect(page - 1);
}, [onPageSelect, page]);
const onNextPagePress = useCallback(() => {
if (!page) {
return;
}
onPageSelect(page + 1);
}, [onPageSelect, page]);
const onLastPagePress = useCallback(() => {
if (!totalPages) {
return;
}
onPageSelect(totalPages);
}, [onPageSelect, totalPages]);
if (!page) {
return null;
}
@@ -108,7 +84,7 @@ function TablePager({
isFirstPage && styles.disabledPageButton
)}
isDisabled={isFirstPage}
onPress={handleFirstPagePress}
onPress={onFirstPagePress}
>
<Icon name={icons.PAGE_FIRST} />
</Link>

View File

@@ -9,7 +9,7 @@
margin-left: 10px;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.headerCell {
white-space: nowrap;
}

View File

@@ -1,5 +1,6 @@
.tooltipContainer {
z-index: $popperZIndex;
margin: 10px;
}
.tooltip {
@@ -17,24 +18,174 @@
}
}
.arrow,
.arrow::after {
position: absolute;
display: block;
width: 0;
height: 0;
border-width: 11px;
border-style: solid;
border-color: transparent;
}
.arrowDisabled {
display: none;
}
.arrow::after {
border-width: 10px;
content: '';
}
.top {
bottom: -11px;
margin-left: -11px;
border-bottom-width: 0;
&::after {
bottom: 1px;
margin-left: -10px;
border-bottom-width: 0;
content: ' ';
&.default {
border-top-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-top-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-top-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-top-color: var(--popoverArrowBorderInverseColor);
}
}
.right {
left: -11px;
margin-top: -11px;
border-left-width: 0;
&::after {
bottom: -10px;
left: 1px;
border-left-width: 0;
content: ' ';
&.default {
border-right-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-right-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-right-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-right-color: var(--popoverArrowBorderInverseColor);
}
}
.bottom {
top: -11px;
margin-left: -11px;
border-top-width: 0;
&::after {
top: 1px;
margin-left: -10px;
border-top-width: 0;
content: ' ';
&.default {
border-bottom-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-bottom-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-bottom-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-bottom-color: var(--popoverArrowBorderInverseColor);
}
}
.left {
right: -11px;
margin-top: -11px;
border-right-width: 0;
&::after {
right: 1px;
bottom: -10px;
border-right-width: 0;
content: ' ';
&.default {
border-left-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-left-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-left-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-left-color: var(--popoverArrowBorderInverseColor);
}
}
.body {
padding: 5px;
}
.verticalContainer {
max-height: 300px;
}
.horizontalContainer {
max-width: calc($breakpointExtraSmall - 20px);
}
@media only screen and (min-width: $breakpointExtraSmall) {
.tooltip {
.horizontalContainer {
max-width: calc($breakpointSmall * 0.8);
}
}
@media only screen and (min-width: $breakpointSmall) {
.tooltip {
.horizontalContainer {
max-width: calc($breakpointMedium * 0.8);
}
}
@media only screen and (min-width: $breakpointMedium) {
.tooltip {
.horizontalContainer {
max-width: calc($breakpointLarge * 0.8);
}
}
/* @media only screen and (max-width: $breakpointLarge) {
.horizontalContainer {
max-width: calc($breakpointLarge * 0.8);
}
} */

View File

@@ -1,11 +1,19 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'arrow': string;
'arrowDisabled': string;
'body': string;
'bottom': string;
'default': string;
'horizontalContainer': string;
'inverse': string;
'left': string;
'right': string;
'tooltip': string;
'tooltipContainer': string;
'top': string;
'verticalContainer': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,25 +1,17 @@
import {
arrow,
autoUpdate,
flip,
FloatingArrow,
FloatingPortal,
offset,
Placement,
safePolygon,
shift,
useClick,
useDismiss,
useFloating,
useHover,
useInteractions,
} from '@floating-ui/react';
import classNames from 'classnames';
import React, { useRef, useState } from 'react';
import { useThemeColor } from 'Helpers/Hooks/useTheme';
import { kinds } from 'Helpers/Props';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import { kinds, tooltipPositions } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { isMobile } from 'Utilities/browser';
import dimensions from 'Styles/Variables/dimensions';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import styles from './Tooltip.css';
export interface TooltipProps {
@@ -27,8 +19,8 @@ export interface TooltipProps {
bodyClassName?: string;
anchor: React.ReactNode;
tooltip: string | React.ReactNode;
kind?: Extract<Kind, 'default' | 'inverse'>;
position?: Placement;
kind?: Extract<Kind, keyof typeof styles>;
position?: (typeof tooltipPositions.all)[number];
canFlip?: boolean;
}
function Tooltip(props: TooltipProps) {
@@ -38,76 +30,196 @@ function Tooltip(props: TooltipProps) {
anchor,
tooltip,
kind = kinds.DEFAULT,
position,
canFlip = true,
position = tooltipPositions.TOP,
canFlip = false,
} = props;
const arrowColor = useThemeColor(
kind === 'inverse'
? 'popoverArrowBorderInverseColor'
: 'popoverArrowBorderColor'
);
const closeTimeout = useRef<ReturnType<typeof setTimeout>>();
const updater = useRef<(() => void) | null>(null);
const [isOpen, setIsOpen] = useState(false);
const arrowRef = useRef(null);
const handleClick = useCallback(() => {
if (!isMobileUtil()) {
return;
}
const { refs, context, floatingStyles } = useFloating({
middleware: [
arrow({
element: arrowRef,
}),
flip({
crossAxis: canFlip,
mainAxis: canFlip,
}),
offset({ mainAxis: 10 }),
shift(),
],
open: isOpen,
placement: position,
whileElementsMounted: autoUpdate,
onOpenChange: setIsOpen,
setIsOpen((isOpen) => {
return !isOpen;
});
}, [setIsOpen]);
const handleMouseEnterAnchor = useCallback(() => {
// Mobile will fire mouse enter and click events rapidly,
// this causes the tooltip not to open on the first press.
// Ignore the mouse enter event on mobile.
if (isMobileUtil()) {
return;
}
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const handleMouseEnterTooltip = useCallback(() => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const handleMouseLeave = useCallback(() => {
// Still listen for mouse leave on mobile to allow clicks outside to close the tooltip.
clearTimeout(closeTimeout.current);
closeTimeout.current = setTimeout(() => {
setIsOpen(false);
}, 100);
}, [setIsOpen]);
const maxWidth = useMemo(() => {
const windowWidth = window.innerWidth;
if (windowWidth >= parseInt(dimensions.breakpointLarge)) {
return 800;
} else if (windowWidth >= parseInt(dimensions.breakpointMedium)) {
return 650;
} else if (windowWidth >= parseInt(dimensions.breakpointSmall)) {
return 500;
}
return 450;
}, []);
const computeMaxSize = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const { top, right, bottom, left } = data.offsets.reference;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (/^top/.test(data.placement)) {
data.styles.maxHeight = top - 20;
} else if (/^bottom/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom - 20;
} else if (/^right/.test(data.placement)) {
data.styles.maxWidth = Math.min(maxWidth, windowWidth - right - 20);
data.styles.maxHeight = top - 20;
} else {
data.styles.maxWidth = Math.min(maxWidth, left - 20);
data.styles.maxHeight = top - 20;
}
return data;
},
[maxWidth]
);
useEffect(() => {
if (updater.current && isOpen) {
updater.current();
}
});
const click = useClick(context, {
enabled: isMobile(),
});
const dismiss = useDismiss(context);
const hover = useHover(context, {
handleClose: safePolygon(),
});
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
hover,
]);
useEffect(() => {
return () => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
};
}, []);
return (
<>
<span
ref={refs.setReference}
{...getReferenceProps()}
className={className}
>
{anchor}
</span>
{isOpen ? (
<FloatingPortal id="portal-root">
<div
ref={refs.setFloating}
className={styles.tooltipContainer}
style={floatingStyles}
{...getFloatingProps()}
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
className={className}
onClick={handleClick}
onMouseEnter={handleMouseEnterAnchor}
onMouseLeave={handleMouseLeave}
>
<FloatingArrow ref={arrowRef} context={context} fill={arrowColor} />
<div className={classNames(styles.tooltip, styles[kind])}>
<div className={bodyClassName}>{tooltip}</div>
</div>
</div>
</FloatingPortal>
) : null}
</>
{anchor}
</span>
)}
</Reference>
<Portal>
<Popper
// @ts-expect-error - PopperJS types are not in sync with our position types.
placement={position}
// Disable events to improve performance when many tooltips
// are shown (Quality Definitions for example).
eventsEnabled={false}
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: computeMaxSize,
},
preventOverflow: {
// Fixes positioning for tooltips in the queue
// and likely others.
escapeWithReference: false,
},
flip: {
enabled: canFlip,
},
}}
>
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
updater.current = scheduleUpdate;
const popperPlacement = placement
? placement.split('-')[0]
: position;
const vertical =
popperPlacement === 'top' || popperPlacement === 'bottom';
return (
<div
ref={ref}
className={classNames(
styles.tooltipContainer,
vertical
? styles.verticalContainer
: styles.horizontalContainer
)}
style={style}
onMouseEnter={handleMouseEnterTooltip}
onMouseLeave={handleMouseLeave}
>
<div
ref={arrowProps.ref}
className={
isOpen
? classNames(
styles.arrow,
styles[kind],
// @ts-expect-error - is a string that may not exist in styles
styles[popperPlacement]
)
: styles.arrowDisabled
}
style={arrowProps.style}
/>
{isOpen ? (
<div className={classNames(styles.tooltip, styles[kind])}>
<div className={bodyClassName}>{tooltip}</div>
</div>
) : null}
</div>
);
}}
</Popper>
</Portal>
</Manager>
);
}

View File

@@ -11,7 +11,7 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileName';
import {
deleteEpisodeFile,
fetchEpisodeFile,
@@ -128,7 +128,7 @@ function EpisodeSummary(props: EpisodeSummaryProps) {
<span className={styles.infoTitle}>{translate('QualityProfile')}</span>
<Label kind={kinds.PRIMARY} size={sizes.MEDIUM}>
<QualityProfileName qualityProfileId={qualityProfileId} />
<QualityProfileNameConnector qualityProfileId={qualityProfileId} />
</Label>
</div>

View File

@@ -2,7 +2,6 @@ import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import MediaInfoProps from 'typings/MediaInfo';
import formatBitrate from 'Utilities/Number/formatBitrate';
import getEntries from 'Utilities/Object/getEntries';
function MediaInfo(props: MediaInfoProps) {
@@ -17,19 +16,9 @@ function MediaInfo(props: MediaInfoProps) {
return null;
}
if (key === 'audioBitrate' || key === 'videoBitrate') {
return (
<DescriptionListItem
key={key}
title={title}
data={
<span title={value.toString()}>{formatBitrate(value)}</span>
}
/>
);
}
return <DescriptionListItem key={key} title={title} data={value} />;
return (
<DescriptionListItem key={key} title={title} data={props[key]} />
);
})}
</DescriptionList>
);

View File

@@ -1,34 +0,0 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useMemo } from 'react';
import { Error } from 'App/State/AppSectionState';
import fetchJson, {
apiRoot,
FetchJsonOptions,
} from 'Utilities/Fetch/fetchJson';
interface MutationOptions<T, TData>
extends Omit<FetchJsonOptions<TData>, 'method'> {
method: 'POST' | 'PUT' | 'DELETE';
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
}
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
const requestOptions = useMemo(() => {
return {
...options,
path: apiRoot + options.path,
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
},
};
}, [options]);
return useMutation<T, Error, TData>({
...options.mutationOptions,
mutationFn: async (data: TData) =>
fetchJson<T, TData>({ ...requestOptions, body: data }),
});
}
export default useApiMutation;

View File

@@ -1,26 +1,46 @@
import { UndefinedInitialDataOptions, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import fetchJson, {
ApiError,
FetchJsonOptions,
} from 'Utilities/Fetch/fetchJson';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
export interface QueryOptions<T> extends FetchJsonOptions<unknown> {
queryParams?: QueryParams;
interface ApiErrorResponse {
message: string;
details: string;
}
export class ApiError extends Error {
public statusCode: number;
public statusText: string;
public statusBody?: ApiErrorResponse;
public constructor(
path: string,
statusCode: number,
statusText: string,
statusBody?: ApiErrorResponse
) {
super(`Request Error: (${statusCode}) ${path}`);
this.statusCode = statusCode;
this.statusText = statusText;
this.statusBody = statusBody;
Object.setPrototypeOf(this, new.target.prototype);
}
}
interface QueryOptions<T> {
path: string;
headers?: HeadersInit;
queryOptions?:
| Omit<UndefinedInitialDataOptions<T, ApiError>, 'queryKey' | 'queryFn'>
| undefined;
}
const useApiQuery = <T>(options: QueryOptions<T>) => {
const requestOptions = useMemo(() => {
const { path: path, queryOptions, queryParams, ...otherOptions } = options;
const apiRoot = '/api/v5'; // window.Sonarr.apiRoot;
function useApiQuery<T>(options: QueryOptions<T>) {
const { path, headers } = useMemo(() => {
return {
...otherOptions,
path: getQueryPath(path) + getQueryString(queryParams),
path: apiRoot + options.path,
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
@@ -30,10 +50,29 @@ const useApiQuery = <T>(options: QueryOptions<T>) => {
return useQuery({
...options.queryOptions,
queryKey: [requestOptions.path],
queryFn: async ({ signal }) =>
fetchJson<T, unknown>({ ...requestOptions, signal }),
queryKey: [path, headers],
queryFn: async ({ signal }) => {
const response = await fetch(path, {
headers,
signal,
});
if (!response.ok) {
// eslint-disable-next-line init-declarations
let body;
try {
body = (await response.json()) as ApiErrorResponse;
} catch {
throw new ApiError(path, response.status, response.statusText);
}
throw new ApiError(path, response.status, response.statusText, body);
}
return response.json() as T;
},
});
};
}
export default useApiQuery;

View File

@@ -1,36 +0,0 @@
import { useEffect } from 'react';
import { useHistory } from 'react-router';
import { create } from 'zustand';
interface PageStore {
events: number;
}
const pageStore = create<PageStore>(() => ({
events: 1,
}));
const usePage = (kind: keyof PageStore) => {
const { action } = useHistory();
const goToPage = (page: number) => {
pageStore.setState({ [kind]: page });
};
useEffect(() => {
if (action === 'POP') {
pageStore.setState({ [kind]: 1 });
}
}, [action, kind]);
return {
page: pageStore((state) => state[kind]),
goToPage,
};
};
export default usePage;
export const resetPage = (kind: keyof PageStore) => {
pageStore.setState({ [kind]: 1 });
};

View File

@@ -1,81 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { PropertyFilter } from 'App/State/AppState';
import { SortDirection } from 'Helpers/Props/sortDirections';
import fetchJson from 'Utilities/Fetch/fetchJson';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import getQueryString from 'Utilities/Fetch/getQueryString';
import { QueryOptions } from './useApiQuery';
interface PagedQueryOptions<T> extends QueryOptions<PagedQueryResponse<T>> {
page: number;
pageSize: number;
sortKey?: string;
sortDirection?: SortDirection;
filters?: PropertyFilter[];
}
interface PagedQueryResponse<T> {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
totalPages: number;
records: T[];
}
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
const requestOptions = useMemo(() => {
const {
path,
page,
pageSize,
sortKey,
sortDirection,
filters,
queryParams,
queryOptions,
...otherOptions
} = options;
return {
...otherOptions,
path:
getQueryPath(path) +
getQueryString({
...queryParams,
page,
pageSize,
sortKey,
sortDirection,
filters,
}),
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
},
};
}, [options]);
return useQuery({
...options.queryOptions,
queryKey: [requestOptions.path],
queryFn: async ({ signal }) => {
const response = await fetchJson<PagedQueryResponse<T>, unknown>({
...requestOptions,
signal,
});
return {
...response,
totalPages: Math.max(
Math.ceil(response.totalRecords / options.pageSize),
1
),
};
},
});
};
export default usePagedApiQuery;

View File

@@ -1,27 +0,0 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import themes from 'Styles/Themes';
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
(theme) => {
return theme;
}
);
}
const useTheme = () => {
return useSelector(createThemeSelector());
};
export default useTheme;
export const useThemeColor = (color: string) => {
const theme = useTheme();
const themeVariables = themes[theme];
// @ts-expect-error - themeVariables is a string indexable type
return themeVariables[color];
};

View File

@@ -1,74 +0,0 @@
import { create, type StateCreator } from 'zustand';
import { persist, type PersistOptions } from 'zustand/middleware';
import Column from 'Components/Table/Column';
export const createPersist = <T>(
name: string,
state: StateCreator<T>,
options: Omit<PersistOptions<T>, 'name' | 'storage'> = {}
) => {
const instanceName =
window.Sonarr.instanceName.toLowerCase().replace(/ /g, '_') ?? 'sonarr';
const finalName = `${instanceName}_${name}`;
return create(
persist<T>(state, {
...options,
name: finalName,
})
);
};
export const mergeColumns = <T extends { columns: Column[] }>(
persistedState: unknown,
currentState: T
) => {
const currentColumns = currentState.columns;
const persistedColumns = (persistedState as T).columns;
const columns: Column[] = [];
// Add persisted columns in the same order they're currently in
// as long as they haven't been removed.
persistedColumns.forEach((persistedColumn) => {
const column = currentColumns.find((i) => i.name === persistedColumn.name);
if (column) {
const newColumn: Partial<Column> = {};
// We can't use a spread operator or Object.assign to clone the column
// or any accessors are lost and can break translations.
for (const prop of Object.keys(column)) {
const attributes = Object.getOwnPropertyDescriptor(column, prop);
if (!attributes) {
return;
}
Object.defineProperty(newColumn, prop, attributes);
}
newColumn.isVisible = persistedColumn.isVisible;
columns.push(newColumn as Column);
}
});
// Add any columns added to the app in the initial position.
currentColumns.forEach((currentColumn, index) => {
const persistedColumnIndex = persistedColumns.findIndex(
(i) => i.name === currentColumn.name
);
const column = Object.assign({}, currentColumn);
if (persistedColumnIndex === -1) {
columns.splice(index, 0, column);
}
});
return {
...(persistedState as T),
columns,
};
};

View File

@@ -50,9 +50,7 @@ function DeleteSeriesModalContent({
dispatch(
deleteSeries({ id: seriesId, deleteFiles, addImportListExclusion })
);
onModalClose();
}, [seriesId, addImportListExclusion, deleteFiles, dispatch, onModalClose]);
}, [seriesId, addImportListExclusion, deleteFiles, dispatch]);
const handleDeleteOptionChange = useCallback(
({ name, value }: CheckInputChanged) => {

View File

@@ -153,13 +153,6 @@
padding: 20px;
}
.seriesProgressLabel {
composes: label from '~Components/Label.css';
margin: 0;
font-size: 17px;
}
@media only screen and (max-width: $breakpointSmall) {
.contentContainer {
padding: 20px 0;

View File

@@ -24,7 +24,6 @@ interface CssExports {
'runtime': string;
'seriesNavigationButton': string;
'seriesNavigationButtons': string;
'seriesProgressLabel': string;
'sizeOnDisk': string;
'statusName': string;
'tags': string;

View File

@@ -41,8 +41,7 @@ import { Image, Statistics } from 'Series/Series';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
import useSeries from 'Series/useSeries';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileName';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import {
@@ -71,7 +70,6 @@ import toggleSelected from 'Utilities/Table/toggleSelected';
import SeriesAlternateTitles from './SeriesAlternateTitles';
import SeriesDetailsLinks from './SeriesDetailsLinks';
import SeriesDetailsSeason from './SeriesDetailsSeason';
import SeriesProgressLabel from './SeriesProgressLabel';
import SeriesTags from './SeriesTags';
import styles from './SeriesDetails.css';
@@ -126,6 +124,40 @@ function createEpisodeFilesSelector() {
);
}
function createSeriesSelector(seriesId: number) {
return createSelector(createAllSeriesSelector(), (allSeries) => {
const sortedSeries = [...allSeries].sort(sortByProp('sortTitle'));
const seriesIndex = sortedSeries.findIndex(
(series) => series.id === seriesId
);
if (seriesIndex === -1) {
return {
series: undefined,
nextSeries: undefined,
previousSeries: undefined,
};
}
const series = sortedSeries[seriesIndex];
const nextSeries = sortedSeries[seriesIndex + 1] ?? sortedSeries[0];
const previousSeries =
sortedSeries[seriesIndex - 1] ?? sortedSeries[sortedSeries.length - 1];
return {
series,
nextSeries: {
title: nextSeries.title,
titleSlug: nextSeries.titleSlug,
},
previousSeries: {
title: previousSeries.title,
titleSlug: previousSeries.titleSlug,
},
};
});
}
interface ExpandedState {
allExpanded: boolean;
allCollapsed: boolean;
@@ -138,10 +170,9 @@ interface SeriesDetailsProps {
function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const dispatch = useDispatch();
const series = useSeries(seriesId);
const allSeries = useSelector(createAllSeriesSelector());
const { series, nextSeries, previousSeries } = useSelector(
createSeriesSelector(seriesId)
);
const {
isEpisodesFetching,
isEpisodesPopulated,
@@ -157,23 +188,22 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
} = useSelector(createEpisodeFilesSelector());
const commands = useSelector(createCommandsSelector());
const isSaving = useSelector((state: AppState) => state.series.isSaving);
const { isRefreshing, isRenaming, isSearching } = useMemo(() => {
const isSeriesRefreshing = isCommandExecuting(
findCommand(commands, {
name: commandNames.REFRESH_SERIES,
seriesId,
})
);
const seriesRefreshingCommand = findCommand(commands, {
name: commandNames.REFRESH_SERIES,
});
const isSeriesRefreshingCommandExecuting = isCommandExecuting(
seriesRefreshingCommand
);
const allSeriesRefreshing =
isSeriesRefreshingCommandExecuting &&
!seriesRefreshingCommand?.body.seriesIds?.length;
const isSeriesRefreshing =
isSeriesRefreshingCommandExecuting &&
seriesRefreshingCommand?.body.seriesIds?.includes(seriesId);
isCommandExecuting(seriesRefreshingCommand) &&
!seriesRefreshingCommand?.body.seriesId;
const isSearchingExecuting = isCommandExecuting(
findCommand(commands, {
@@ -204,35 +234,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
};
}, [seriesId, commands]);
const { nextSeries, previousSeries } = useMemo(() => {
const sortedSeries = [...allSeries].sort(sortByProp('sortTitle'));
const seriesIndex = sortedSeries.findIndex(
(series) => series.id === seriesId
);
if (seriesIndex === -1) {
return {
nextSeries: undefined,
previousSeries: undefined,
};
}
const nextSeries = sortedSeries[seriesIndex + 1] ?? sortedSeries[0];
const previousSeries =
sortedSeries[seriesIndex - 1] ?? sortedSeries[sortedSeries.length - 1];
return {
nextSeries: {
title: nextSeries.title,
titleSlug: nextSeries.titleSlug,
},
previousSeries: {
title: previousSeries.title,
titleSlug: previousSeries.titleSlug,
},
};
}, [seriesId, allSeries]);
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
const [isManageEpisodesOpen, setIsManageEpisodesOpen] = useState(false);
const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false);
@@ -395,7 +396,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
}, [populate]);
useEffect(() => {
registerPagePopulator(populate, ['seriesUpdated']);
registerPagePopulator(populate);
return () => {
unregisterPagePopulator(populate);
@@ -436,15 +437,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
genres,
tags,
year,
isSaving = false,
} = series;
const {
episodeCount = 0,
episodeFileCount = 0,
sizeOnDisk = 0,
lastAired,
} = statistics;
const { episodeFileCount = 0, sizeOnDisk = 0, lastAired } = statistics;
const statusDetails = getSeriesStatusDetails(status);
const runningYears =
@@ -607,29 +602,25 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
</div>
<div className={styles.seriesNavigationButtons}>
{previousSeries ? (
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: previousSeries.title,
})}
to={`/series/${previousSeries.titleSlug}`}
/>
) : null}
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: previousSeries.title,
})}
to={`/series/${previousSeries.titleSlug}`}
/>
{nextSeries ? (
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: nextSeries.title,
})}
to={`/series/${nextSeries.titleSlug}`}
/>
) : null}
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: nextSeries.title,
})}
to={`/series/${nextSeries.titleSlug}`}
/>
</div>
</div>
@@ -688,7 +679,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
<div>
<Icon name={icons.PROFILE} size={17} />
<span className={styles.qualityProfileName}>
<QualityProfileName qualityProfileId={qualityProfileId} />
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
</span>
</div>
</Label>
@@ -786,14 +779,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
position={tooltipPositions.BOTTOM}
/>
) : null}
<SeriesProgressLabel
className={styles.seriesProgressLabel}
seriesId={seriesId}
monitored={monitored}
episodeCount={episodeCount}
episodeFileCount={episodeFileCount}
/>
</div>
<div ref={overviewRef} className={styles.overview}>

View File

@@ -19,11 +19,7 @@ function SeriesDetailsPage() {
const previousIndex = usePrevious(seriesIndex);
useEffect(() => {
if (
seriesIndex === -1 &&
previousIndex !== -1 &&
previousIndex !== undefined
) {
if (seriesIndex === -1 && previousIndex !== -1) {
history.push(`${window.Sonarr.urlBase}/`);
}
}, [seriesIndex, previousIndex, history]);

View File

@@ -1,70 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
function getEpisodeCountKind(
monitored: boolean,
episodeFileCount: number,
episodeCount: number,
isDownloading: boolean
) {
if (isDownloading) {
return kinds.PURPLE;
}
if (episodeFileCount === episodeCount && episodeCount > 0) {
return kinds.SUCCESS;
}
if (!monitored) {
return kinds.WARNING;
}
return kinds.DANGER;
}
interface SeriesProgressLabelProps {
className: string;
seriesId: number;
monitored: boolean;
episodeCount: number;
episodeFileCount: number;
}
function SeriesProgressLabel({
className,
seriesId,
monitored,
episodeCount,
episodeFileCount,
}: SeriesProgressLabelProps) {
const queueDetails: SeriesQueueDetails = useSelector(
createSeriesQueueItemsDetailsSelector(seriesId)
);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const text = newDownloads
? `${episodeFileCount} + ${newDownloads} / ${episodeCount}`
: `${episodeFileCount} / ${episodeCount}`;
return (
<Label
className={className}
kind={getEpisodeCountKind(
monitored,
episodeFileCount,
episodeCount,
queueDetails.count > 0
)}
size={sizes.LARGE}
>
<span>{text}</span>
</Label>
);
}
export default SeriesProgressLabel;

View File

@@ -92,7 +92,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
dispatch(
executeCommand({
name: REFRESH_SERIES,
seriesIds: [seriesId],
seriesId,
})
);
}, [seriesId, dispatch]);

View File

@@ -83,7 +83,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
dispatch(
executeCommand({
name: REFRESH_SERIES,
seriesIds: [seriesId],
seriesId,
})
);
}, [seriesId, dispatch]);

View File

@@ -97,7 +97,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
dispatch(
executeCommand({
name: REFRESH_SERIES,
seriesIds: [seriesId],
seriesId,
})
);
}, [seriesId, dispatch]);

View File

@@ -15,8 +15,7 @@ function createSeriesIndexItemSelector(seriesId: number) {
(series: Series, qualityProfile, executingCommands: Command[]) => {
const isRefreshingSeries = executingCommands.some((command) => {
return (
command.name === REFRESH_SERIES &&
command.body.seriesIds?.includes(series.id)
command.name === REFRESH_SERIES && command.body.seriesId === seriesId
);
});

View File

@@ -10,11 +10,11 @@ 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 Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
bulkDeleteCustomFormats,
bulkEditCustomFormats,
@@ -34,7 +34,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageCustomFormatsModalRow
>['onSelectedChange'];
const COLUMNS: Column[] = [
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
@@ -56,6 +56,8 @@ const COLUMNS: Column[] = [
interface ManageCustomFormatsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageCustomFormatsModalContent(

View File

@@ -67,6 +67,7 @@ function EditDownloadClientModalContent({
implementationName,
name,
enable,
protocol,
priority,
removeCompletedDownloads,
removeFailedDownloads,
@@ -217,17 +218,19 @@ function EditDownloadClientModalContent({
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemoveFailed')}</FormLabel>
{protocol.value === 'torrent' ? null : (
<FormGroup>
<FormLabel>{translate('RemoveFailed')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeFailedDownloads"
helpText={translate('RemoveFailedDownloadsHelpText')}
{...removeFailedDownloads}
onChange={handleInputChange}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.CHECK}
name="removeFailedDownloads"
helpText={translate('RemoveFailedDownloadsHelpText')}
{...removeFailedDownloads}
onChange={handleInputChange}
/>
</FormGroup>
)}
</FieldSet>
</Form>
) : null}

View File

@@ -10,11 +10,11 @@ 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 Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
bulkDeleteDownloadClients,
bulkEditDownloadClients,
@@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageDownloadClientsModalRow
>['onSelectedChange'];
const COLUMNS: Column[] = [
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
@@ -82,6 +82,8 @@ const COLUMNS: Column[] = [
interface ManageDownloadClientsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageDownloadClientsModalContent(

View File

@@ -137,6 +137,8 @@ function DownloadClientOptions({
</FormGroup>
) : null}
</Form>
<Alert kind={kinds.INFO}>{translate('RemoveDownloadsAlert')}</Alert>
</FieldSet>
</div>
) : null}

View File

@@ -36,8 +36,6 @@ export const authenticationMethodOptions = [
get value() {
return translate('AuthBasic');
},
isDisabled: true,
isHidden: true,
},
{
key: 'forms',

View File

@@ -1,17 +0,0 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { UpdateMechanism } from 'typings/Settings/General';
interface UpdateSettings {
branch: string;
updateAutomatically: boolean;
updateMechanism: UpdateMechanism;
updateScriptPath: string;
}
const useUpdateSettings = () => {
return useApiQuery<UpdateSettings>({
path: '/settings/update',
});
};
export default useUpdateSettings;

View File

@@ -17,7 +17,6 @@ interface SavePayload {
enableAutomaticSearch?: boolean;
enableInteractiveSearch?: boolean;
priority?: number;
seasonSearchMaximumSingleEpisodeAge?: number;
}
interface ManageIndexersEditModalContentProps {
@@ -60,10 +59,6 @@ function ManageIndexersEditModalContent(
const [enableInteractiveSearch, setEnableInteractiveSearch] =
useState(NO_CHANGE);
const [priority, setPriority] = useState<null | number>(null);
const [
seasonSearchMaximumSingleEpisodeAge,
setSeasonSearchMaximumSingleEpisodeAge,
] = useState<null | number>(null);
const save = useCallback(() => {
let hasChanges = false;
@@ -89,12 +84,6 @@ function ManageIndexersEditModalContent(
payload.priority = priority as number;
}
if (seasonSearchMaximumSingleEpisodeAge !== null) {
hasChanges = true;
payload.seasonSearchMaximumSingleEpisodeAge =
seasonSearchMaximumSingleEpisodeAge as number;
}
if (hasChanges) {
onSavePress(payload);
}
@@ -105,7 +94,6 @@ function ManageIndexersEditModalContent(
enableAutomaticSearch,
enableInteractiveSearch,
priority,
seasonSearchMaximumSingleEpisodeAge,
onSavePress,
onModalClose,
]);
@@ -124,9 +112,6 @@ function ManageIndexersEditModalContent(
case 'priority':
setPriority(value as number);
break;
case 'seasonSearchMaximumSingleEpisodeAge':
setSeasonSearchMaximumSingleEpisodeAge(value as number);
break;
default:
console.warn(`EditIndexersModalContent Unknown Input: '${name}'`);
}
@@ -187,20 +172,6 @@ function ManageIndexersEditModalContent(
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MaximumSingleEpisodeAge')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="seasonSearchMaximumSingleEpisodeAge"
helpText={translate('MaximumSingleEpisodeAgeHelpText')}
value={seasonSearchMaximumSingleEpisodeAge}
min={0}
unit="days"
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>

View File

@@ -10,11 +10,11 @@ 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 Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
bulkDeleteIndexers,
bulkEditIndexers,
@@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageIndexersModalRow
>['onSelectedChange'];
const COLUMNS: Column[] = [
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
@@ -72,12 +72,6 @@ const COLUMNS: Column[] = [
isSortable: true,
isVisible: true,
},
{
name: 'seasonSearchMaximumSingleEpisodeAge',
label: () => translate('MaximumSingleEpisodeAge'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: () => translate('Tags'),
@@ -88,6 +82,8 @@ const COLUMNS: Column[] = [
interface ManageIndexersModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {

View File

@@ -4,9 +4,8 @@
.enableAutomaticSearch,
.enableInteractiveSearch,
.priority,
.seasonSearchMaximumSingleEpisodeAge,
.implementation {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}
}

View File

@@ -7,7 +7,6 @@ interface CssExports {
'implementation': string;
'name': string;
'priority': string;
'seasonSearchMaximumSingleEpisodeAge': string;
'tags': string;
}
export const cssExports: CssExports;

View File

@@ -17,7 +17,6 @@ interface ManageIndexersModalRowProps {
enableAutomaticSearch: boolean;
enableInteractiveSearch: boolean;
priority: number;
seasonSearchMaximumSingleEpisodeAge: number;
implementation: string;
tags: number[];
columns: Column[];
@@ -34,7 +33,6 @@ function ManageIndexersModalRow(props: ManageIndexersModalRowProps) {
enableAutomaticSearch,
enableInteractiveSearch,
priority,
seasonSearchMaximumSingleEpisodeAge,
implementation,
tags,
onSelectedChange,
@@ -92,10 +90,6 @@ function ManageIndexersModalRow(props: ManageIndexersModalRowProps) {
<TableRowCell className={styles.priority}>{priority}</TableRowCell>
<TableRowCell className={styles.seasonSearchMaximumSingleEpisodeAge}>
{seasonSearchMaximumSingleEpisodeAge}
</TableRowCell>
<TableRowCell className={styles.tags}>
<SeriesTagList tags={tags} />
</TableRowCell>

View File

@@ -21,8 +21,8 @@
display: flex;
color: var(--helpTextColor);
.identifier {
margin-top: 8px;
.icon {
margin-top: 3px;
margin-right: 5px;
padding: 2px;
}

View File

@@ -3,7 +3,7 @@
interface CssExports {
'footNote': string;
'groups': string;
'identifier': string;
'icon': string;
'namingSelect': string;
'namingSelectContainer': string;
}

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react';
import FieldSet from 'Components/FieldSet';
import SelectInput from 'Components/Form/SelectInput';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import Modal from 'Components/Modal/Modal';
@@ -9,7 +10,7 @@ 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 { sizes } from 'Helpers/Props';
import { icons, sizes } from 'Helpers/Props';
import NamingConfig from 'typings/Settings/NamingConfig';
import translate from 'Utilities/String/translate';
import NamingOption from './NamingOption';
@@ -122,63 +123,63 @@ const fileNameAnimeTokens = [
];
const seriesTokens = [
{ token: '{Series Title}', example: "The Series Title's!", footNotes: '1' },
{ token: '{Series Title}', example: "The Series Title's!", footNote: true },
{
token: '{Series CleanTitle}',
example: "The Series Title's!",
footNotes: '1',
footNote: true,
},
{
token: '{Series TitleYear}',
example: "The Series Title's! (2010)",
footNotes: '1',
footNote: true,
},
{
token: '{Series CleanTitleYear}',
example: "The Series Title's! 2010",
footNotes: '1',
footNote: true,
},
{
token: '{Series TitleWithoutYear}',
example: "The Series Title's!",
footNotes: '1',
footNote: true,
},
{
token: '{Series CleanTitleWithoutYear}',
example: "The Series Title's!",
footNotes: '1',
footNote: true,
},
{
token: '{Series TitleThe}',
example: "Series Title's!, The",
footNotes: '1',
footNote: true,
},
{
token: '{Series CleanTitleThe}',
example: "Series Title's!, The",
footNotes: '1',
footNote: true,
},
{
token: '{Series TitleTheYear}',
example: "Series Title's!, The (2010)",
footNotes: '1',
footNote: true,
},
{
token: '{Series CleanTitleTheYear}',
example: "Series Title's!, The 2010",
footNotes: '1',
footNote: true,
},
{
token: '{Series TitleTheWithoutYear}',
example: "Series Title's!, The",
footNotes: '1',
footNote: true,
},
{
token: '{Series CleanTitleTheWithoutYear}',
example: "Series Title's!, The",
footNotes: '1',
footNote: true,
},
{ token: '{Series TitleFirstCharacter}', example: 'S', footNotes: '1' },
{ token: '{Series TitleFirstCharacter}', example: 'S', footNote: true },
{ token: '{Series Year}', example: '2010' },
];
@@ -211,8 +212,8 @@ const absoluteTokens = [
];
const episodeTitleTokens = [
{ token: '{Episode Title}', example: "Episode's Title", footNotes: '1' },
{ token: '{Episode CleanTitle}', example: 'Episodes Title', footNotes: '1' },
{ token: '{Episode Title}', example: "Episode's Title", footNote: true },
{ token: '{Episode CleanTitle}', example: 'Episodes Title', footNote: true },
];
const qualityTokens = [
@@ -222,21 +223,12 @@ const qualityTokens = [
const mediaInfoTokens = [
{ token: '{MediaInfo Simple}', example: 'x264 DTS' },
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNotes: '1' },
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNote: true },
{ token: '{MediaInfo AudioCodec}', example: 'DTS' },
{ token: '{MediaInfo AudioChannels}', example: '5.1' },
{
token: '{MediaInfo AudioLanguages}',
example: '[EN+DE]',
footNotes: '1,2',
},
{
token: '{MediaInfo AudioLanguagesAll}',
example: '[EN]',
footNotes: '1',
},
{ token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNotes: '1' },
{ token: '{MediaInfo AudioLanguages}', example: '[EN+DE]', footNote: true },
{ token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNote: true },
{ token: '{MediaInfo VideoCodec}', example: 'x264' },
{ token: '{MediaInfo VideoBitDepth}', example: '10' },
@@ -245,7 +237,7 @@ const mediaInfoTokens = [
];
const otherTokens = [
{ token: '{Release Group}', example: 'Rls Grp', footNotes: '1' },
{ token: '{Release Group}', example: 'Rls Grp', footNote: true },
{ token: '{Custom Formats}', example: 'iNTERNAL' },
{ token: '{Custom Format:FormatName}', example: 'AMZN' },
];
@@ -433,12 +425,12 @@ function NamingModal(props: NamingModalProps) {
<FieldSet legend={translate('Series')}>
<div className={styles.groups}>
{seriesTokens.map(({ token, example, footNotes }) => (
{seriesTokens.map(({ token, example, footNote }) => (
<NamingOption
key={token}
token={token}
example={example}
footNotes={footNotes}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
@@ -447,7 +439,7 @@ function NamingModal(props: NamingModalProps) {
</div>
<div className={styles.footNote}>
<sup className={styles.identifier}>1</sup>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('SeriesFootNote')} />
</div>
</FieldSet>
@@ -539,12 +531,12 @@ function NamingModal(props: NamingModalProps) {
<div>
<FieldSet legend={translate('EpisodeTitle')}>
<div className={styles.groups}>
{episodeTitleTokens.map(({ token, example, footNotes }) => (
{episodeTitleTokens.map(({ token, example, footNote }) => (
<NamingOption
key={token}
token={token}
example={example}
footNotes={footNotes}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
@@ -552,7 +544,7 @@ function NamingModal(props: NamingModalProps) {
))}
</div>
<div className={styles.footNote}>
<sup className={styles.identifier}>1</sup>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('EpisodeTitleFootNote')} />
</div>
</FieldSet>
@@ -574,12 +566,12 @@ function NamingModal(props: NamingModalProps) {
<FieldSet legend={translate('MediaInfo')}>
<div className={styles.groups}>
{mediaInfoTokens.map(({ token, example, footNotes }) => (
{mediaInfoTokens.map(({ token, example, footNote }) => (
<NamingOption
key={token}
token={token}
example={example}
footNotes={footNotes}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
@@ -588,24 +580,19 @@ function NamingModal(props: NamingModalProps) {
</div>
<div className={styles.footNote}>
<sup className={styles.identifier}>1</sup>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MediaInfoFootNote')} />
</div>
<div className={styles.footNote}>
<sup className={styles.identifier}>2</sup>
<InlineMarkdown data={translate('MediaInfoFootNote2')} />
</div>
</FieldSet>
<FieldSet legend={translate('Other')}>
<div className={styles.groups}>
{otherTokens.map(({ token, example, footNotes }) => (
{otherTokens.map(({ token, example, footNote }) => (
<NamingOption
key={token}
token={token}
example={example}
footNotes={footNotes}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
@@ -627,7 +614,7 @@ function NamingModal(props: NamingModalProps) {
</div>
<div className={styles.footNote}>
<sup className={styles.identifier}>1</sup>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
</div>
</FieldSet>

View File

@@ -39,7 +39,7 @@
padding: 6px;
background-color: var(--popoverBodyBackgroundColor);
.footNotes {
.footNote {
padding: 2px;
color: #aaa;
}

View File

@@ -2,7 +2,7 @@
// Please do not change this file!
interface CssExports {
'example': string;
'footNotes': string;
'footNote': string;
'isFullFilename': string;
'large': string;
'lower': string;

View File

@@ -1,6 +1,8 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import { Size } from 'Helpers/Props/sizes';
import TokenCase from './TokenCase';
import TokenSeparator from './TokenSeparator';
@@ -12,7 +14,7 @@ interface NamingOptionProps {
example: string;
tokenCase: TokenCase;
isFullFilename?: boolean;
footNotes?: string;
footNote?: boolean;
size?: Extract<Size, keyof typeof styles>;
onPress: ({
isFullFilename,
@@ -30,7 +32,7 @@ function NamingOption(props: NamingOptionProps) {
example,
tokenCase,
isFullFilename = false,
footNotes,
footNote = false,
size = 'small',
onPress,
} = props;
@@ -64,10 +66,8 @@ function NamingOption(props: NamingOptionProps) {
<div className={styles.example}>
{example.replace(/ /g, tokenSeparator)}
{footNotes ? (
<div className={styles.footNotes}>
<sup>{footNotes}</sup>
</div>
{footNote ? (
<Icon className={styles.footNote} name={icons.FOOTNOTE} />
) : null}
</div>
</Link>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
@@ -13,7 +13,6 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes } from 'Helpers/Props';
import {
saveMetadata,
@@ -42,8 +41,6 @@ function EditMetadataModalContent({
(state: AppState) => state.settings.metadata
);
const wasSaving = usePrevious(isSaving);
const { settings, ...otherSettings } = useMemo(() => {
const item = items.find((item) => item.id === id)!;
@@ -72,12 +69,6 @@ function EditMetadataModalContent({
dispatch(saveMetadata({ id }));
}, [id, dispatch]);
useEffect(() => {
if (wasSaving && !isSaving && !saveError) {
onModalClose();
}
}, [isSaving, wasSaving, saveError, onModalClose]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>

View File

@@ -0,0 +1,183 @@
import _ from 'lodash';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getNewSeries from 'Utilities/Series/getNewSeries';
import monitorOptions from 'Utilities/Series/monitorOptions';
import * as seriesTypes from 'Utilities/Series/seriesTypes';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import { set, update, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
//
// Variables
export const section = 'addSeries';
let abortCurrentRequest = null;
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
isAdding: false,
isAdded: false,
addError: null,
items: [],
defaults: {
rootFolderPath: '',
monitor: monitorOptions[0].key,
qualityProfileId: 0,
seriesType: seriesTypes.STANDARD,
seasonFolder: true,
searchForMissingEpisodes: false,
searchForCutoffUnmetEpisodes: false,
tags: []
}
};
export const persistState = [
'addSeries.defaults'
];
//
// Actions Types
export const LOOKUP_SERIES = 'addSeries/lookupSeries';
export const ADD_SERIES = 'addSeries/addSeries';
export const SET_ADD_SERIES_VALUE = 'addSeries/setAddSeriesValue';
export const CLEAR_ADD_SERIES = 'addSeries/clearAddSeries';
export const SET_ADD_SERIES_DEFAULT = 'addSeries/setAddSeriesDefault';
//
// Action Creators
export const lookupSeries = createThunk(LOOKUP_SERIES);
export const addSeries = createThunk(ADD_SERIES);
export const clearAddSeries = createAction(CLEAR_ADD_SERIES);
export const setAddSeriesDefault = createAction(SET_ADD_SERIES_DEFAULT);
export const setAddSeriesValue = createAction(SET_ADD_SERIES_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Action Handlers
export const actionHandlers = handleThunks({
[LOOKUP_SERIES]: function(getState, payload, dispatch) {
dispatch(set({ section, isFetching: true }));
if (abortCurrentRequest) {
abortCurrentRequest();
}
const { request, abortRequest } = createAjaxRequest({
url: '/series/lookup',
data: {
term: payload.term
}
});
abortCurrentRequest = abortRequest;
request.done((data) => {
dispatch(batchActions([
update({ section, data }),
set({
section,
isFetching: false,
isPopulated: true,
error: null
})
]));
});
request.fail((xhr) => {
dispatch(set({
section,
isFetching: false,
isPopulated: false,
error: xhr.aborted ? null : xhr
}));
});
},
[ADD_SERIES]: function(getState, payload, dispatch) {
dispatch(set({ section, isAdding: true }));
const tvdbId = payload.tvdbId;
const items = getState().addSeries.items;
const newSeries = getNewSeries(_.cloneDeep(_.find(items, { tvdbId })), payload);
const promise = createAjaxRequest({
url: '/series',
method: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify(newSeries)
}).request;
promise.done((data) => {
dispatch(batchActions([
updateItem({ section: 'series', ...data }),
set({
section,
isAdding: false,
isAdded: true,
addError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isAdding: false,
isAdded: false,
addError: xhr
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[SET_ADD_SERIES_VALUE]: createSetSettingValueReducer(section),
[SET_ADD_SERIES_DEFAULT]: function(state, { payload }) {
const newState = getSectionState(state, section);
newState.defaults = {
...newState.defaults,
...payload
};
return updateSectionState(state, section, newState);
},
[CLEAR_ADD_SERIES]: function(state) {
const {
defaults,
...otherDefaultState
} = defaultState;
return Object.assign({}, state, otherDefaultState);
}
}, defaultState, section);

View File

@@ -1,3 +1,4 @@
import * as addSeries from './addSeriesActions';
import * as app from './appActions';
import * as blocklist from './blocklistActions';
import * as calendar from './calendarActions';
@@ -28,6 +29,7 @@ import * as tags from './tagActions';
import * as wanted from './wantedActions';
export default [
addSeries,
app,
blocklist,
calendar,

View File

@@ -1,12 +1,18 @@
import { createAction } from 'redux-actions';
import { filterTypes, sortDirections } from 'Helpers/Props';
import { setAppValue } from 'Store/Actions/appActions';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import { pingServer } from './appActions';
import { set } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
@@ -64,6 +70,95 @@ export const defaultState = {
items: []
},
logs: {
isFetching: false,
isPopulated: false,
pageSize: 50,
sortKey: 'time',
sortDirection: sortDirections.DESCENDING,
error: null,
items: [],
columns: [
{
name: 'level',
columnLabel: () => translate('Level'),
isSortable: false,
isVisible: true,
isModifiable: false
},
{
name: 'time',
label: () => translate('Time'),
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'logger',
label: () => translate('Component'),
isSortable: false,
isVisible: true,
isModifiable: false
},
{
name: 'message',
label: () => translate('Message'),
isVisible: true,
isModifiable: false
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: () => translate('All'),
filters: []
},
{
key: 'info',
label: () => translate('Info'),
filters: [
{
key: 'level',
value: 'info',
type: filterTypes.EQUAL
}
]
},
{
key: 'warn',
label: () => translate('Warn'),
filters: [
{
key: 'level',
value: 'warn',
type: filterTypes.EQUAL
}
]
},
{
key: 'error',
label: () => translate('Error'),
filters: [
{
key: 'level',
value: 'error',
type: filterTypes.EQUAL
}
]
}
]
},
logFiles: {
isFetching: false,
isPopulated: false,
@@ -79,6 +174,13 @@ export const defaultState = {
}
};
export const persistState = [
'system.logs.pageSize',
'system.logs.sortKey',
'system.logs.sortDirection',
'system.logs.selectedFilterKey'
];
//
// Actions Types
@@ -96,6 +198,17 @@ export const DELETE_BACKUP = 'system/backups/deleteBackup';
export const FETCH_UPDATES = 'system/updates/fetchUpdates';
export const FETCH_LOGS = 'system/logs/fetchLogs';
export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage';
export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage';
export const GOTO_NEXT_LOGS_PAGE = 'system/logs/gotoLogsNextPage';
export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage';
export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage';
export const SET_LOGS_SORT = 'system/logs/setLogsSort';
export const SET_LOGS_FILTER = 'system/logs/setLogsFilter';
export const SET_LOGS_TABLE_OPTION = 'system/logs/setLogsTableOption';
export const CLEAR_LOGS_TABLE = 'system/logs/clearLogsTable';
export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles';
export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles';
@@ -119,6 +232,17 @@ export const deleteBackup = createThunk(DELETE_BACKUP);
export const fetchUpdates = createThunk(FETCH_UPDATES);
export const fetchLogs = createThunk(FETCH_LOGS);
export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE);
export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE);
export const gotoLogsNextPage = createThunk(GOTO_NEXT_LOGS_PAGE);
export const gotoLogsLastPage = createThunk(GOTO_LAST_LOGS_PAGE);
export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE);
export const setLogsSort = createThunk(SET_LOGS_SORT);
export const setLogsFilter = createThunk(SET_LOGS_FILTER);
export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION);
export const clearLogsTable = createAction(CLEAR_LOGS_TABLE);
export const fetchLogFiles = createThunk(FETCH_LOG_FILES);
export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES);
@@ -204,6 +328,22 @@ export const actionHandlers = handleThunks({
[FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'),
[FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'),
...createServerSideCollectionHandlers(
'system.logs',
'/log',
fetchLogs,
{
[serverSideCollectionHandlers.FETCH]: FETCH_LOGS,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_LOGS_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_LOGS_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_LOGS_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_LOGS_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_LOGS_PAGE,
[serverSideCollectionHandlers.SORT]: SET_LOGS_SORT,
[serverSideCollectionHandlers.FILTER]: SET_LOGS_FILTER
}
),
[RESTART]: function(getState, payload, dispatch) {
const promise = createAjaxRequest({
url: '/system/restart',
@@ -238,6 +378,17 @@ export const reducers = createHandleActions({
restoreError: null
}
};
}
},
[SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs'),
[CLEAR_LOGS_TABLE]: createClearReducer(section, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
})
}, defaultState, section);

View File

@@ -7,9 +7,10 @@ function createImportSeriesItemSelector(id: string) {
return createSelector(
(_state: AppState, connectorInput: { id: string }) =>
connectorInput ? connectorInput.id : id,
(state: AppState) => state.addSeries,
(state: AppState) => state.importSeries,
createAllSeriesSelector(),
(connectorId, importSeries, series) => {
(connectorId, addSeries, importSeries, series) => {
const finalId = id || connectorId;
const item =
@@ -25,6 +26,10 @@ function createImportSeriesItemSelector(id: string) {
});
return {
defaultMonitor: addSeries.defaults.monitor,
defaultQualityProfileId: addSeries.defaults.qualityProfileId,
defaultSeriesType: addSeries.defaults.seriesType,
defaultSeasonFolder: addSeries.defaults.seasonFolder,
...item,
isExistingSeries,
};

View File

@@ -17,7 +17,7 @@ interface ValidationFailures {
warnings: ValidationWarning[];
}
function getValidationFailures(saveError?: Error | null): ValidationFailures {
function getValidationFailures(saveError?: Error): ValidationFailures {
if (!saveError || saveError.status !== 400) {
return {
errors: [],
@@ -77,7 +77,7 @@ export interface ModelBaseSetting {
function selectSettings<T extends ModelBaseSetting>(
item: T,
pendingChanges?: Partial<ModelBaseSetting>,
saveError?: Error | null
saveError?: Error
) {
const { errors, warnings } = getValidationFailures(saveError);

View File

@@ -1,5 +1,6 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -13,71 +14,106 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import { align, icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import {
fetchLogs,
gotoLogsFirstPage,
gotoLogsPage,
setLogsFilter,
setLogsSort,
setLogsTableOption,
} from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { TableOptionsChangePayload } from 'typings/Table';
import translate from 'Utilities/String/translate';
import {
setEventOption,
setEventOptions,
useEventOptions,
} from './eventOptionsStore';
import LogsTableRow from './LogsTableRow';
import useEvents, { useFilters } from './useEvents';
function LogsTable() {
const dispatch = useDispatch();
const { data, error, isFetching, isFetched, isLoading, page, goToPage } =
useEvents();
const requestCurrentPage = useCurrentPage();
const { records = [], totalPages = 0, totalRecords } = data ?? {};
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useEventOptions();
const filters = useFilters();
const {
isFetching,
isPopulated,
error,
items,
columns,
page,
pageSize,
totalPages,
totalRecords,
sortKey,
sortDirection,
filters,
selectedFilterKey,
} = useSelector((state: AppState) => state.system.logs);
const isClearLogExecuting = useSelector(
createCommandExecutingSelector(commandNames.CLEAR_LOGS)
);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoLogsPage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
setEventOption('selectedFilterKey', selectedFilterKey);
dispatch(setLogsFilter({ selectedFilterKey }));
},
[]
[dispatch]
);
const handleSortPress = useCallback((sortKey: string) => {
setEventOption('sortKey', sortKey);
}, []);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setLogsSort({ sortKey }));
},
[dispatch]
);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
setEventOptions(payload);
dispatch(setLogsTableOption(payload));
if (payload.pageSize) {
goToPage(1);
dispatch(gotoLogsFirstPage({ page: 1 }));
}
},
[goToPage]
[dispatch]
);
const handleRefreshPress = useCallback(() => {
goToPage(1);
}, [goToPage]);
dispatch(gotoLogsFirstPage());
}, [dispatch]);
const handleClearLogsPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.CLEAR_LOGS,
commandFinished: () => {
goToPage(1);
dispatch(gotoLogsFirstPage());
},
})
);
}, [dispatch, goToPage]);
}, [dispatch]);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchLogs());
} else {
dispatch(gotoLogsFirstPage({ page: 1 }));
}
}, [requestCurrentPage, dispatch]);
return (
<PageContent title={translate('Logs')}>
@@ -123,13 +159,13 @@ function LogsTable() {
</PageToolbar>
<PageContentBody>
{isLoading ? <LoadingIndicator /> : null}
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isFetched && !error && !records.length ? (
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoEventsFound')}</Alert>
) : null}
{isFetched && !error && records.length ? (
{isPopulated && !error && items.length ? (
<div>
<Table
columns={columns}
@@ -140,7 +176,7 @@ function LogsTable() {
onSortPress={handleSortPress}
>
<TableBody>
{records.map((item) => {
{items.map((item) => {
return (
<LogsTableRow key={item.id} columns={columns} {...item} />
);
@@ -153,7 +189,11 @@ function LogsTable() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onPageSelect={goToPage}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}

View File

@@ -14,7 +14,7 @@ interface LogsTableDetailsModalProps {
isOpen: boolean;
message: string;
exception?: string;
onModalClose: () => void;
onModalClose: (...args: unknown[]) => unknown;
}
function LogsTableDetailsModal({
@@ -38,7 +38,7 @@ function LogsTableDetailsModal({
{message}
</Scroller>
{exception ? (
{!!exception && (
<div>
<div>{translate('Exception')}</div>
<Scroller
@@ -48,7 +48,7 @@ function LogsTableDetailsModal({
{exception}
</Scroller>
</div>
) : null}
)}
</ModalBody>
<ModalFooter>

View File

@@ -54,42 +54,40 @@ function LogsTableRow({
}, []);
return (
<>
<TableRowButton onPress={handlePress}>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'level') {
return (
<TableRowCell key={name} className={styles.level}>
<Icon className={styles[level]} name={iconName} title={level} />
</TableRowCell>
);
}
if (name === 'time') {
return <RelativeDateCell key={name} date={time} />;
}
if (name === 'logger') {
return <TableRowCell key={name}>{logger}</TableRowCell>;
}
if (name === 'message') {
return <TableRowCell key={name}>{message}</TableRowCell>;
}
if (name === 'actions') {
return <TableRowCell key={name} className={styles.actions} />;
}
<TableRowButton onPress={handlePress}>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
})}
</TableRowButton>
}
if (name === 'level') {
return (
<TableRowCell key={name} className={styles.level}>
<Icon className={styles[level]} name={iconName} title={level} />
</TableRowCell>
);
}
if (name === 'time') {
return <RelativeDateCell key={name} date={time} />;
}
if (name === 'logger') {
return <TableRowCell key={name}>{logger}</TableRowCell>;
}
if (name === 'message') {
return <TableRowCell key={name}>{message}</TableRowCell>;
}
if (name === 'actions') {
return <TableRowCell key={name} className={styles.actions} />;
}
return null;
})}
<LogsTableDetailsModal
isOpen={isDetailsModalOpen}
@@ -97,7 +95,7 @@ function LogsTableRow({
exception={exception}
onModalClose={handleDetailsModalClose}
/>
</>
</TableRowButton>
);
}

View File

@@ -1,85 +0,0 @@
import Column from 'Components/Table/Column';
import { createPersist, mergeColumns } from 'Helpers/createPersist';
import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate';
export interface EventOptions {
pageSize: number;
selectedFilterKey: string | number;
sortKey: string;
sortDirection: SortDirection;
columns: Column[];
}
const eventOptionsStore = createPersist<EventOptions>(
'event_options',
() => {
return {
pageSize: 50,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'level',
label: '',
columnLabel: () => translate('Level'),
isSortable: false,
isVisible: true,
isModifiable: false,
},
{
name: 'time',
label: () => translate('Time'),
isSortable: true,
isVisible: true,
isModifiable: false,
},
{
name: 'logger',
label: () => translate('Component'),
isSortable: false,
isVisible: true,
isModifiable: false,
},
{
name: 'message',
label: () => translate('Message'),
isVisible: true,
isModifiable: false,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
};
},
{
merge: mergeColumns,
}
);
export const useEventOptions = () => {
return eventOptionsStore((state) => state);
};
export const setEventOptions = (options: Partial<EventOptions>) => {
eventOptionsStore.setState((state) => ({
...state,
...options,
}));
};
export const setEventOption = <K extends keyof EventOptions>(
key: K,
value: EventOptions[K]
) => {
eventOptionsStore.setState((state) => ({
...state,
[key]: value,
}));
};

View File

@@ -1,92 +0,0 @@
import { keepPreviousData } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { Filter } from 'App/State/AppState';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import LogEvent from 'typings/LogEvent';
import translate from 'Utilities/String/translate';
import { useEventOptions } from './eventOptionsStore';
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
{
key: 'info',
label: () => translate('Info'),
filters: [
{
key: 'level',
value: 'info',
type: 'equal',
},
],
},
{
key: 'warn',
label: () => translate('Warn'),
filters: [
{
key: 'level',
value: 'warn',
type: 'equal',
},
],
},
{
key: 'error',
label: () => translate('Error'),
filters: [
{
key: 'level',
value: 'error',
type: 'equal',
},
],
},
];
const useEvents = () => {
const { page, goToPage } = usePage('events');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useEventOptions();
const filters = useMemo(() => {
return FILTERS.find((f) => f.key === selectedFilterKey)?.filters;
}, [selectedFilterKey]);
const { refetch, ...query } = usePagedApiQuery<LogEvent>({
path: '/log',
page,
pageSize,
filters,
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
const handleGoToPage = useCallback(
(page: number) => {
goToPage(page);
refetch();
},
[goToPage, refetch]
);
return {
...query,
goToPage: handleGoToPage,
page,
refetch,
};
};
export default useEvents;
export const useFilters = () => {
return FILTERS;
};

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
@@ -12,7 +13,6 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons, kinds } from 'Helpers/Props';
import useUpdateSettings from 'Settings/General/useUpdateSettings';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
import { fetchUpdates } from 'Store/Actions/systemActions';
@@ -24,11 +24,32 @@ import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import translate from 'Utilities/String/translate';
import UpdateChanges from './UpdateChanges';
import useUpdates from './useUpdates';
import styles from './Updates.css';
const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i;
function createUpdatesSelector() {
return createSelector(
(state: AppState) => state.system.updates,
(state: AppState) => state.settings.general,
(updates, generalSettings) => {
const { error: updatesError, items } = updates;
const isFetching = updates.isFetching || generalSettings.isFetching;
const isPopulated = updates.isPopulated && generalSettings.isPopulated;
return {
isFetching,
isPopulated,
updatesError,
generalSettingsError: generalSettings.error,
items,
updateMechanism: generalSettings.item.updateMechanism,
};
}
);
}
function Updates() {
const currentVersion = useSelector((state: AppState) => state.app.version);
const { packageUpdateMechanismMessage } = useSelector(
@@ -42,26 +63,19 @@ function Updates() {
);
const {
data: updates,
isFetched: isUpdatesFetched,
isLoading: isLoadingUpdates,
error: updatesError,
} = useUpdates();
const {
data: updateSettings,
isFetched: isSettingsFetched,
isLoading: isLoadingSettings,
error: settingsError,
} = useUpdateSettings();
isFetching,
isPopulated,
updatesError,
generalSettingsError,
items,
updateMechanism,
} = useSelector(createUpdatesSelector());
const dispatch = useDispatch();
const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false);
const isFetching = isLoadingUpdates || isLoadingSettings;
const isPopulated = isUpdatesFetched && isSettingsFetched;
const updateMechanism = updateSettings?.updateMechanism ?? 'builtIn';
const hasError = !!(updatesError || settingsError);
const hasUpdates = isPopulated && !hasError && updates.length > 0;
const noUpdates = isPopulated && !hasError && !updates.length;
const hasError = !!(updatesError || generalSettingsError);
const hasUpdates = isPopulated && !hasError && items.length > 0;
const noUpdates = isPopulated && !hasError && !items.length;
const externalUpdaterPrefix = translate('UpdateAppDirectlyLoadError');
const externalUpdaterMessages: Partial<Record<UpdateMechanism, string>> = {
@@ -75,18 +89,18 @@ function Updates() {
currentVersion.match(VERSION_REGEX)?.[0] ?? '0'
);
const latestVersion = updates[0]?.version;
const latestVersion = items[0]?.version;
const latestMajorVersion = parseInt(
latestVersion?.match(VERSION_REGEX)?.[0] ?? '0'
);
return {
isMajorUpdate: latestMajorVersion > majorVersion,
hasUpdateToInstall: updates.some(
hasUpdateToInstall: items.some(
(update) => update.installable && update.latest
),
};
}, [currentVersion, updates]);
}, [currentVersion, items]);
const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
@@ -177,7 +191,7 @@ function Updates() {
{hasUpdates && (
<div>
{updates.map((update) => {
{items.map((update) => {
return (
<div key={update.version} className={styles.update}>
<div className={styles.info}>
@@ -254,7 +268,7 @@ function Updates() {
</Alert>
) : null}
{settingsError ? (
{generalSettingsError ? (
<Alert kind={kinds.DANGER}>
{translate('FailedToFetchSettings')}
</Alert>

View File

@@ -1,15 +0,0 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Update from 'typings/Update';
const useUpdates = () => {
const result = useApiQuery<Update[]>({
path: '/update',
});
return {
...result,
data: result.data ?? [],
};
};
export default useUpdates;

View File

@@ -1,24 +0,0 @@
const anySignal = (
...signals: (AbortSignal | null | undefined)[]
): AbortSignal => {
const controller = new AbortController();
for (const signal of signals.filter(Boolean) as AbortSignal[]) {
if (signal.aborted) {
// Break early if one of the signals is already aborted.
controller.abort();
break;
}
// Listen for abort events on the provided signals and abort the controller.
// Automatically removes listeners when the controller is aborted.
signal.addEventListener('abort', () => controller.abort(signal.reason), {
signal: controller.signal,
});
}
return controller.signal;
};
export default anySignal;

View File

@@ -1,87 +0,0 @@
import anySignal from './anySignal';
export class ApiError extends Error {
public statusCode: number;
public statusText: string;
public statusBody?: ApiErrorResponse;
public constructor(
path: string,
statusCode: number,
statusText: string,
statusBody?: ApiErrorResponse
) {
super(`Request Error: (${statusCode}) ${path}`);
this.statusCode = statusCode;
this.statusText = statusText;
this.statusBody = statusBody;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export interface ApiErrorResponse {
message: string;
details: string;
}
export interface FetchJsonOptions<TData> extends Omit<RequestInit, 'body'> {
path: string;
headers?: HeadersInit;
body?: TData;
timeout?: number;
}
export const urlBase = window.Sonarr.urlBase;
export const apiRoot = '/api/v5'; // window.Sonarr.apiRoot;
async function fetchJson<T, TData>({
body,
path,
signal,
timeout,
...options
}: FetchJsonOptions<TData>): Promise<T> {
const abortController = new AbortController();
let timeoutID: ReturnType<typeof setTimeout> | null = null;
if (timeout) {
timeoutID = setTimeout(() => {
abortController.abort();
}, timeout);
}
const response = await fetch(path, {
...options,
body: body ? JSON.stringify(body) : undefined,
headers: {
...options.headers,
Accept: 'application/json',
'Content-Type': 'application/json',
},
signal: anySignal(abortController.signal, signal),
});
if (timeoutID) {
clearTimeout(timeoutID);
}
if (!response.ok) {
// eslint-disable-next-line init-declarations
let body;
try {
body = (await response.json()) as ApiErrorResponse;
} catch {
throw new ApiError(path, response.status, response.statusText);
}
throw new ApiError(path, response.status, response.statusText, body);
}
return response.json() as T;
}
export default fetchJson;

View File

@@ -1,7 +0,0 @@
import { apiRoot, urlBase } from 'Utilities/Fetch/fetchJson';
const getQueryPath = (path: string) => {
return urlBase + apiRoot + path;
};
export default getQueryPath;

View File

@@ -1,37 +0,0 @@
import { PropertyFilter } from 'App/State/AppState';
export interface QueryParams {
[key: string]: string | number | boolean | PropertyFilter[] | undefined;
}
const getQueryString = (queryParams?: QueryParams) => {
if (!queryParams) {
return '';
}
const filteredParams = Object.keys(queryParams).reduce<
Record<string, string>
>((acc, key) => {
const value = queryParams[key];
if (value == null) {
return acc;
}
if (Array.isArray(value)) {
value.forEach((filter) => {
acc[filter.key] = String(filter.value);
});
} else {
acc[key] = String(value);
}
return acc;
}, {});
const paramsString = new URLSearchParams(filteredParams).toString();
return `?${paramsString}`;
};
export default getQueryString;

View File

@@ -1,19 +0,0 @@
import { filesize } from 'filesize';
function formatBitrate(input: string | number) {
const size = Number(input);
if (isNaN(size)) {
return '';
}
const { value, symbol } = filesize(size, {
base: 10,
round: 1,
output: 'object',
});
return `${value} ${symbol}/s`;
}
export default formatBitrate;

View File

@@ -1,5 +1,5 @@
import { Error } from 'App/State/AppSectionState';
import { ApiError } from 'Utilities/Fetch/fetchJson';
import { ApiError } from 'Helpers/Hooks/useApiQuery';
function getErrorMessage(
error: Error | ApiError | undefined,

Some files were not shown because too many files have changed in this diff Show More