mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-23 17:14:18 -04:00
Compare commits
65 Commits
differenti
...
v5-build
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0101bdca1 | ||
|
|
539f55deae | ||
|
|
0f90544a35 | ||
|
|
f10b0e51b5 | ||
|
|
b5c54575c2 | ||
|
|
f89ee64edc | ||
|
|
59800d3675 | ||
|
|
ed1b17005f | ||
|
|
ea5231abef | ||
|
|
42206b023f | ||
|
|
bf55bca142 | ||
|
|
496eb6fd37 | ||
|
|
1a5fa185d1 | ||
|
|
518f1799dc | ||
|
|
9f570d4dbf | ||
|
|
dd014993f1 | ||
|
|
61876e795f | ||
|
|
e721a06291 | ||
|
|
37cc66ce66 | ||
|
|
9c196c5fa0 | ||
|
|
7dcf0808dc | ||
|
|
46afe84edc | ||
|
|
0ff3101511 | ||
|
|
17aab235a5 | ||
|
|
f1cfef19b2 | ||
|
|
d89d1b2f8b | ||
|
|
f986062d4c | ||
|
|
e2bc322462 | ||
|
|
8e2263d1a1 | ||
|
|
307135d3f0 | ||
|
|
0b58278e15 | ||
|
|
d8a147d234 | ||
|
|
bba2ab98b6 | ||
|
|
4852afcad7 | ||
|
|
0cb656cdd8 | ||
|
|
4a5b839d93 | ||
|
|
65bae6a7ce | ||
|
|
8b0a1b7756 | ||
|
|
aad8ba0f9b | ||
|
|
ebb0aab2f5 | ||
|
|
e62c687d93 | ||
|
|
ac2ecae874 | ||
|
|
3991eec5e0 | ||
|
|
15e4599d31 | ||
|
|
9f1b0d3a3b | ||
|
|
64c1ef85c4 | ||
|
|
9ce473d9bb | ||
|
|
89f584d1b3 | ||
|
|
405ee7473c | ||
|
|
e9f8023528 | ||
|
|
86c785ffa0 | ||
|
|
64160866c3 | ||
|
|
1e5932d89a | ||
|
|
9276bd7a16 | ||
|
|
8a8ea4eb94 | ||
|
|
6bee95747e | ||
|
|
dc576d0dd3 | ||
|
|
5dfb5de863 | ||
|
|
ac7ac34cc2 | ||
|
|
24173139f0 | ||
|
|
3cd8a2a98b | ||
|
|
e4f1b2c4ec | ||
|
|
442b3b506f | ||
|
|
e8a6ce371d | ||
|
|
d9e5842f8b |
2
.github/actions/build/action.yml
vendored
2
.github/actions/build/action.yml
vendored
@@ -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
|
||||
|
||||
|
||||
15
.github/workflows/api_docs.yml
vendored
15
.github/workflows/api_docs.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -65,7 +65,7 @@ module.exports = (env) => {
|
||||
|
||||
output: {
|
||||
path: distFolder,
|
||||
publicPath: 'auto',
|
||||
publicPath: '/',
|
||||
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
|
||||
sourceMapFilename: '[file].map'
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import Series from 'Series/Series';
|
||||
|
||||
interface AddSeries extends Series {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
export default AddSeries;
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -75,6 +75,11 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) {
|
||||
[selectDispatch]
|
||||
);
|
||||
|
||||
console.info(
|
||||
'\x1b[36m[MarkTest] is selected\x1b[0m',
|
||||
selectState.selectedState[id]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualTableSelectCell
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
25
frontend/src/App/State/AddSeriesAppState.ts
Normal file
25
frontend/src/App/State/AddSeriesAppState.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
14
frontend/src/App/State/LogsAppState.ts
Normal file
14
frontend/src/App/State/LogsAppState.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ function HintedSelectInputOption(props: HintedSelectInputOptionProps) {
|
||||
hint,
|
||||
depth,
|
||||
isSelected = false,
|
||||
isMultiSelect,
|
||||
isMobile,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface CssExports {
|
||||
'isActiveParentLink': string;
|
||||
'item': string;
|
||||
'link': string;
|
||||
'noIcon': string;
|
||||
'status': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -31,7 +31,6 @@ function PageToolbarButton({
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
title={label}
|
||||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.pager {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
} */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,7 +24,6 @@ interface CssExports {
|
||||
'runtime': string;
|
||||
'seriesNavigationButton': string;
|
||||
'seriesNavigationButtons': string;
|
||||
'seriesProgressLabel': string;
|
||||
'sizeOnDisk': string;
|
||||
'statusName': string;
|
||||
'tags': string;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
@@ -92,7 +92,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: REFRESH_SERIES,
|
||||
seriesIds: [seriesId],
|
||||
seriesId,
|
||||
})
|
||||
);
|
||||
}, [seriesId, dispatch]);
|
||||
|
||||
@@ -83,7 +83,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: REFRESH_SERIES,
|
||||
seriesIds: [seriesId],
|
||||
seriesId,
|
||||
})
|
||||
);
|
||||
}, [seriesId, dispatch]);
|
||||
|
||||
@@ -97,7 +97,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: REFRESH_SERIES,
|
||||
seriesIds: [seriesId],
|
||||
seriesId,
|
||||
})
|
||||
);
|
||||
}, [seriesId, dispatch]);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -137,6 +137,8 @@ function DownloadClientOptions({
|
||||
</FormGroup>
|
||||
) : null}
|
||||
</Form>
|
||||
|
||||
<Alert kind={kinds.INFO}>{translate('RemoveDownloadsAlert')}</Alert>
|
||||
</FieldSet>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -36,8 +36,6 @@ export const authenticationMethodOptions = [
|
||||
get value() {
|
||||
return translate('AuthBasic');
|
||||
},
|
||||
isDisabled: true,
|
||||
isHidden: true,
|
||||
},
|
||||
{
|
||||
key: 'forms',
|
||||
|
||||
@@ -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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
.enableAutomaticSearch,
|
||||
.enableInteractiveSearch,
|
||||
.priority,
|
||||
.seasonSearchMaximumSingleEpisodeAge,
|
||||
.implementation {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ interface CssExports {
|
||||
'implementation': string;
|
||||
'name': string;
|
||||
'priority': string;
|
||||
'seasonSearchMaximumSingleEpisodeAge': string;
|
||||
'tags': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
display: flex;
|
||||
color: var(--helpTextColor);
|
||||
|
||||
.identifier {
|
||||
margin-top: 8px;
|
||||
.icon {
|
||||
margin-top: 3px;
|
||||
margin-right: 5px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
interface CssExports {
|
||||
'footNote': string;
|
||||
'groups': string;
|
||||
'identifier': string;
|
||||
'icon': string;
|
||||
'namingSelect': string;
|
||||
'namingSelectContainer': string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
padding: 6px;
|
||||
background-color: var(--popoverBodyBackgroundColor);
|
||||
|
||||
.footNotes {
|
||||
.footNote {
|
||||
padding: 2px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'example': string;
|
||||
'footNotes': string;
|
||||
'footNote': string;
|
||||
'isFullFilename': string;
|
||||
'large': string;
|
||||
'lower': string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
183
frontend/src/Store/Actions/addSeriesActions.js
Normal file
183
frontend/src/Store/Actions/addSeriesActions.js
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { apiRoot, urlBase } from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
const getQueryPath = (path: string) => {
|
||||
return urlBase + apiRoot + path;
|
||||
};
|
||||
|
||||
export default getQueryPath;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user