mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-05 13:20:20 -05:00
Compare commits
170 Commits
v4.0.15.29
...
differenti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d46dd3ea | ||
|
|
b103005aa2 | ||
|
|
41b5118938 | ||
|
|
c84699ed5d | ||
|
|
bdd975da0f | ||
|
|
08d1bcb351 | ||
|
|
5fb632eb46 | ||
|
|
da29de4cfe | ||
|
|
83b2c9e97a | ||
|
|
095126bfe8 | ||
|
|
6aee9c7fd5 | ||
|
|
20c2d59e9a | ||
|
|
7ee90fb05d | ||
|
|
1a8ba51260 | ||
|
|
2e66cd2a1e | ||
|
|
6115236d38 | ||
|
|
7ff8c9e18d | ||
|
|
f0e320f3aa | ||
|
|
9f29b06ca4 | ||
|
|
08c0c5aa30 | ||
|
|
64956d7be7 | ||
|
|
b598795262 | ||
|
|
9756a3df38 | ||
|
|
94f64435f5 | ||
|
|
a324052deb | ||
|
|
e08c9d5501 | ||
|
|
1449941471 | ||
|
|
28c4f0cef2 | ||
|
|
e199710c15 | ||
|
|
e3a048790d | ||
|
|
6dc47755ec | ||
|
|
01f7783519 | ||
|
|
e9f59188b1 | ||
|
|
a6e6b7518d | ||
|
|
38cd63ec04 | ||
|
|
71f1593fd9 | ||
|
|
3d951f6db8 | ||
|
|
0f16837b59 | ||
|
|
f0d0eb9a7a | ||
|
|
608fc29086 | ||
|
|
8acd154206 | ||
|
|
6f451b2206 | ||
|
|
3cb6f866cc | ||
|
|
649ed04f8a | ||
|
|
fef00dccf8 | ||
|
|
cbc5127a14 | ||
|
|
38746fc95b | ||
|
|
24ce10006e | ||
|
|
c034282f45 | ||
|
|
b2214fd912 | ||
|
|
4db4388236 | ||
|
|
093ee5b88d | ||
|
|
f58dfc5605 | ||
|
|
1c9a0232ad | ||
|
|
c86822b114 | ||
|
|
5342416659 | ||
|
|
5d7c94f8e9 | ||
|
|
6d8c3f15b3 | ||
|
|
efef9f88ff | ||
|
|
8748cdb1bf | ||
|
|
7a9b6d3262 | ||
|
|
c902735927 | ||
|
|
156d306334 | ||
|
|
d09e7893b3 | ||
|
|
3e2e8e9388 | ||
|
|
99fc61e636 | ||
|
|
271979d637 | ||
|
|
b572a6f759 | ||
|
|
950330b091 | ||
|
|
8940ef8b81 | ||
|
|
91bdf06214 | ||
|
|
5678f98344 | ||
|
|
05d57aa913 | ||
|
|
b22d598ebf | ||
|
|
e22dd15443 | ||
|
|
1fa532dd3e | ||
|
|
350dea10dd | ||
|
|
d3777dd43c | ||
|
|
a72288a14e | ||
|
|
e506eb6d03 | ||
|
|
591b569bdd | ||
|
|
094df71301 | ||
|
|
31e02bdead | ||
|
|
b122ee9670 | ||
|
|
0f9e063e21 | ||
|
|
609e964794 | ||
|
|
5cebec6ae4 | ||
|
|
33da537a63 | ||
|
|
11c945c2dc | ||
|
|
1b0a20535c | ||
|
|
1734fcaa8f | ||
|
|
b99e06acc0 | ||
|
|
5d16169e52 | ||
|
|
a2670a5804 | ||
|
|
9e5ebdc624 | ||
|
|
e62aa5e041 | ||
|
|
80a8176c58 | ||
|
|
39f1c669b8 | ||
|
|
5208f5e966 | ||
|
|
bb0ad312f1 | ||
|
|
413d6b996b | ||
|
|
79474f26e9 | ||
|
|
18bbb8bbd1 | ||
|
|
374c6d13f6 | ||
|
|
92f0aa4e8f | ||
|
|
7345c06003 | ||
|
|
ff08b914f3 | ||
|
|
e40c8f3e3e | ||
|
|
2fd1fea4cb | ||
|
|
a72bc164a9 | ||
|
|
d9a86bcb31 | ||
|
|
553c4aeae1 | ||
|
|
5a47f34ef9 | ||
|
|
15b070119d | ||
|
|
7b133bd80d | ||
|
|
ce6536f8ab | ||
|
|
c6eb6c3cd8 | ||
|
|
f4f3fdfb0b | ||
|
|
6b92627004 | ||
|
|
c3f9cd12af | ||
|
|
6f871a1bfb | ||
|
|
ab1f8bdbd9 | ||
|
|
b72b1d5e5c | ||
|
|
87c840974b | ||
|
|
ee46e6378a | ||
|
|
7644cec376 | ||
|
|
d3153685ac | ||
|
|
7035bb2944 | ||
|
|
6dc16f3ddd | ||
|
|
0e1474579a | ||
|
|
7b4bd50f18 | ||
|
|
4849d1da10 | ||
|
|
8482f3da1a | ||
|
|
782af002d6 | ||
|
|
8f6d9f3bf4 | ||
|
|
699120a8fd | ||
|
|
0fdeb05663 | ||
|
|
7c64911b6b | ||
|
|
3035521b93 | ||
|
|
45c53bea86 | ||
|
|
4c6d6b726e | ||
|
|
1765feac03 | ||
|
|
92db4769be | ||
|
|
6838f068bc | ||
|
|
b218461678 | ||
|
|
10e3a237ef | ||
|
|
6e008a8e85 | ||
|
|
27f81117ed | ||
|
|
839658a698 | ||
|
|
1bc1b080d1 | ||
|
|
572bdc979c | ||
|
|
cde0a31ff0 | ||
|
|
60529f0bac | ||
|
|
5ed7780ed7 | ||
|
|
89c8a10e0d | ||
|
|
fd09ca6e71 | ||
|
|
95929dd9c2 | ||
|
|
23bc6a157c | ||
|
|
756e985b66 | ||
|
|
a2fd23c84d | ||
|
|
32ce09648c | ||
|
|
20e1a8d116 | ||
|
|
12a1ef0387 | ||
|
|
2935d148a8 | ||
|
|
a90c13e86f | ||
|
|
9a7ddd751e | ||
|
|
a1d4bb5399 | ||
|
|
9d0acba000 | ||
|
|
ee1a0a1f71 | ||
|
|
f35a27449d |
@@ -2,11 +2,11 @@
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
|
||||
{
|
||||
"name": "Sonarr",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "16",
|
||||
"version": "20",
|
||||
"nvmVersion": "latest"
|
||||
}
|
||||
},
|
||||
|
||||
188
.github/actions/build/action.yml
vendored
Normal file
188
.github/actions/build/action.yml
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
name: Build Backend
|
||||
description: Builds the backend and packages it
|
||||
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch name for this build"
|
||||
required: true
|
||||
version:
|
||||
description: "Version number to build"
|
||||
required: true
|
||||
framework:
|
||||
description: ".net framework used for the build"
|
||||
required: true
|
||||
runtime:
|
||||
description: "Run time to build for"
|
||||
required: true
|
||||
package_tests:
|
||||
description: "True if tests should be packaged for later testing steps"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Setup Environment Variables
|
||||
id: variables
|
||||
shell: bash
|
||||
run: |
|
||||
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
|
||||
|
||||
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "SONARR_VERSION=${{ inputs.version }}" >> "$GITHUB_ENV"
|
||||
echo "BRANCH=${{ inputs.branch }}" >> "$GITHUB_ENV"
|
||||
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
echo "NUGET_PACKAGES=D:\nuget\packages" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Enable Extra Platforms In SDK
|
||||
if: ${{ inputs.runtime == 'freebsd-x64' }}
|
||||
shell: bash
|
||||
run: |
|
||||
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
|
||||
|
||||
if grep -q freebsd-x64 "$BUNDLEDVERSIONS"; then
|
||||
echo "Extra platforms already enabled"
|
||||
else
|
||||
echo "Enabling extra platform support"
|
||||
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
|
||||
fi
|
||||
|
||||
if grep -qv freebsd-x64 src/Directory.Build.props; then
|
||||
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
|
||||
fi
|
||||
|
||||
- name: Update Version Number
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$SONARR_VERSION" != "" ]; then
|
||||
echo "Updating version info to: $SONARR_VERSION"
|
||||
sed -i'' -e "s/<AssemblyVersion>[0-9.*]\+<\/AssemblyVersion>/<AssemblyVersion>$SONARR_VERSION<\/AssemblyVersion>/g" src/Directory.Build.props
|
||||
sed -i'' -e "s/<AssemblyConfiguration>[\$()A-Za-z-]\+<\/AssemblyConfiguration>/<AssemblyConfiguration>${BRANCH}<\/AssemblyConfiguration>/g" src/Directory.Build.props
|
||||
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$SONARR_VERSION<\/string>/g" distribution/macOS/Sonarr.app/Contents/Info.plist
|
||||
fi
|
||||
|
||||
- name: Build Backend
|
||||
shell: bash
|
||||
run: |
|
||||
runtime="${{ inputs.runtime }}"
|
||||
platform=Windows
|
||||
slnFile=src/Sonarr.sln
|
||||
targetingWindows=false
|
||||
|
||||
IFS='-' read -ra SPLIT <<< "$runtime"
|
||||
|
||||
if [ "${SPLIT[0]}" == "win" ]; then
|
||||
platform=Windows
|
||||
targetingWindows=true
|
||||
else
|
||||
platform=Posix
|
||||
fi
|
||||
|
||||
rm -rf _output
|
||||
rm -rf _tests
|
||||
|
||||
echo "Building Sonarr for $runtime, Platform: $platform"
|
||||
|
||||
dotnet msbuild -restore $slnFile -p:SelfContained=True -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$runtime -p:EnableWindowsTargeting=true -t:PublishAllRids
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
framework="${{ inputs.framework }}"
|
||||
runtime="${{ inputs.runtime }}"
|
||||
|
||||
IFS='-' read -ra SPLIT <<< "$runtime"
|
||||
|
||||
case "${SPLIT[0]}" in
|
||||
linux|freebsd*)
|
||||
folder=_artifacts/$runtime/$framework/Sonarr
|
||||
|
||||
echo "Packaging files"
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r _output/$framework/$runtime/publish/* $folder
|
||||
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
||||
cp LICENSE.md $folder
|
||||
|
||||
echo "Removing Service helpers"
|
||||
rm -f $folder/ServiceUninstall.*
|
||||
rm -f $folder/ServiceInstall.*
|
||||
|
||||
echo "Removing Sonarr.Windows"
|
||||
rm $folder/Sonarr.Windows.*
|
||||
|
||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||
;;
|
||||
win)
|
||||
folder=_artifacts/$runtime/$framework/Sonarr
|
||||
|
||||
echo "Packaging files"
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r _output/$framework/$runtime/publish/* $folder
|
||||
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
||||
cp LICENSE.md $folder
|
||||
cp -r _output/$framework-windows/$runtime/publish/* $folder
|
||||
|
||||
echo "Removing Sonarr.Mono"
|
||||
rm -f $folder/Sonarr.Mono.*
|
||||
rm -f $folder/Mono.Posix.NETStandard.*
|
||||
rm -f $folder/libMonoPosixHelper.*
|
||||
|
||||
echo "Adding Sonarr.Windows to UpdatePackage"
|
||||
cp $folder/Sonarr.Windows.* $folder/Sonarr.Update
|
||||
|
||||
;;
|
||||
osx)
|
||||
folder=_artifacts/$runtime/$framework/Sonarr
|
||||
|
||||
echo "Packaging files"
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r _output/$framework/$runtime/publish/* $folder
|
||||
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
||||
cp LICENSE.md $folder
|
||||
|
||||
echo "Removing Service helpers"
|
||||
rm -f $folder/ServiceUninstall.*
|
||||
rm -f $folder/ServiceInstall.*
|
||||
|
||||
echo "Removing Sonarr.Windows"
|
||||
rm $folder/Sonarr.Windows.*
|
||||
|
||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Package Tests
|
||||
if: ${{ inputs.package_tests }}
|
||||
shell: bash
|
||||
run: |
|
||||
framework="${{ inputs.framework }}"
|
||||
runtime="${{ inputs.runtime }}"
|
||||
|
||||
cp scripts/test.sh "_tests/$framework/$runtime/publish"
|
||||
|
||||
rm -f _tests/$framework/$runtime/*.log.config
|
||||
|
||||
- name: Upload Test Artifacts
|
||||
if: ${{ inputs.package_tests }}
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ inputs.framework }}
|
||||
runtime: ${{ inputs.runtime }}
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ inputs.runtime }}
|
||||
path: _artifacts/**/*
|
||||
26
.github/actions/package/action.yml
vendored
26
.github/actions/package/action.yml
vendored
@@ -2,34 +2,34 @@ name: Package
|
||||
description: Packages binaries for deployment
|
||||
|
||||
inputs:
|
||||
platform:
|
||||
description: 'Binary platform'
|
||||
runtime:
|
||||
description: "Binary runtime"
|
||||
required: true
|
||||
framework:
|
||||
description: '.net framework'
|
||||
framework:
|
||||
description: ".net framework"
|
||||
required: true
|
||||
artifact:
|
||||
description: 'Binary artifact'
|
||||
description: "Binary artifact"
|
||||
required: true
|
||||
branch:
|
||||
description: 'Git branch used for this build'
|
||||
description: "Git branch used for this build"
|
||||
required: true
|
||||
major_version:
|
||||
description: 'Sonarr major version'
|
||||
description: "Sonarr major version"
|
||||
required: true
|
||||
version:
|
||||
description: 'Sonarr version'
|
||||
description: "Sonarr version"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
path: _output
|
||||
|
||||
|
||||
- name: Download UI Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -49,7 +49,7 @@ runs:
|
||||
run: $GITHUB_ACTION_PATH/package.sh
|
||||
|
||||
- name: Create Windows Installer (x64)
|
||||
if: ${{ inputs.platform == 'windows' }}
|
||||
if: ${{ inputs.runtime == 'win-x64' }}
|
||||
working-directory: distribution/windows/setup
|
||||
shell: cmd
|
||||
run: |
|
||||
@@ -58,7 +58,7 @@ runs:
|
||||
build.bat
|
||||
|
||||
- name: Create Windows Installer (x86)
|
||||
if: ${{ inputs.platform == 'windows' }}
|
||||
if: ${{ inputs.runtime == 'win-x86' }}
|
||||
working-directory: distribution/windows/setup
|
||||
shell: cmd
|
||||
run: |
|
||||
@@ -69,7 +69,7 @@ runs:
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release_${{ inputs.platform }}
|
||||
name: release-${{ inputs.runtime }}
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
|
||||
2
.github/actions/package/package.sh
vendored
2
.github/actions/package/package.sh
vendored
@@ -3,7 +3,7 @@
|
||||
outputFolder=_output
|
||||
artifactsFolder=_artifacts
|
||||
uiFolder="$outputFolder/UI"
|
||||
framework="${FRAMEWORK:=net6.0}"
|
||||
framework="${FRAMEWORK:=net8.0}"
|
||||
|
||||
rm -rf $artifactsFolder
|
||||
mkdir $artifactsFolder
|
||||
|
||||
23
.github/workflows/api_docs.yml
vendored
23
.github/workflows/api_docs.yml
vendored
@@ -1,12 +1,12 @@
|
||||
name: 'API Docs'
|
||||
name: "API Docs"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
- cron: "0 0 * * 1"
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- v5-develop
|
||||
paths:
|
||||
- ".github/workflows/api_docs.yml"
|
||||
- "docs.sh"
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
id: setup-dotnet
|
||||
|
||||
- name: Create openapi.json
|
||||
run: ./docs.sh Linux
|
||||
run: ./scripts/docs.sh Linux x64
|
||||
|
||||
- name: Commit API Docs Change
|
||||
continue-on-error: true
|
||||
@@ -46,7 +46,20 @@ jobs:
|
||||
then
|
||||
git commit -am 'Automated API Docs update' -m "ignore-downstream"
|
||||
git push -f --set-upstream origin api-docs
|
||||
curl -X POST -H "Authorization: Bearer ${{ secrets.OPENAPI_PAT }}" -H "Accept: application/vnd.github+json" https://api.github.com/repos/sonarr/sonarr/pulls -d '{"head":"api-docs","base":"develop","title":"Update API docs"}'
|
||||
curl -X POST -H "Authorization: Bearer ${{ secrets.OPENAPI_PAT }}" -H "Accept: application/vnd.github+json" https://api.github.com/repos/sonarr/sonarr/pulls -d '{"head":"api-docs","base":"v5-develop","title":"Update API docs"}'
|
||||
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"
|
||||
|
||||
@@ -3,13 +3,13 @@ name: Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
- v5-develop
|
||||
- v5-main
|
||||
paths-ignore:
|
||||
- "src/Sonarr.Api.*/openapi.json"
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- v5-develop
|
||||
paths-ignore:
|
||||
- "src/NzbDrone.Core/Localization/Core/**"
|
||||
- "src/Sonarr.Api.*/openapi.json"
|
||||
@@ -19,91 +19,79 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FRAMEWORK: net6.0
|
||||
FRAMEWORK: net8.0
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.13
|
||||
SONARR_MAJOR_VERSION: 5
|
||||
VERSION: 5.0.0
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: windows-latest
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
framework: ${{ steps.variables.outputs.framework }}
|
||||
major_version: ${{ steps.variables.outputs.major_version }}
|
||||
version: ${{ steps.variables.outputs.version }}
|
||||
branch: ${{ steps.variables.outputs.branch }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Setup Environment Variables
|
||||
id: variables
|
||||
shell: bash
|
||||
run: |
|
||||
# Add 800 to the build number because GitHub won't let us pick an arbitrary starting point
|
||||
SONARR_VERSION="${{ env.VERSION }}.$((${{ github.run_number }}+800))"
|
||||
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
|
||||
|
||||
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "SONARR_VERSION=$SONARR_VERSION" >> "$GITHUB_ENV"
|
||||
echo "BRANCH=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
|
||||
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${{ env.VERSION }}.$((${{ github.run_number }}))" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Enable Extra Platforms In SDK
|
||||
shell: bash
|
||||
run: ./build.sh --enable-extra-platforms-in-sdk
|
||||
backend:
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: freebsd-x64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-arm
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-arm64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-musl-arm64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-musl-x64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-x64
|
||||
package_tests: true
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-arm64
|
||||
package_tests: true
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-x64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: win-x64
|
||||
package_tests: true
|
||||
os: ubuntu-latest
|
||||
- runtime: win-x86
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
|
||||
- name: Build Backend
|
||||
shell: bash
|
||||
run: ./build.sh --backend --enable-extra-platforms --packages
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Test Artifacts
|
||||
|
||||
- name: Publish win-x64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
- name: Build
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: win-x64
|
||||
|
||||
- name: Publish linux-x64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: linux-x64
|
||||
|
||||
- name: Publish osx-arm64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: osx-arm64
|
||||
|
||||
# Build Artifacts (grouped by OS)
|
||||
|
||||
- name: Publish FreeBSD Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_freebsd
|
||||
path: _artifacts/freebsd-*/**/*
|
||||
- name: Publish Linux Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_linux
|
||||
path: _artifacts/linux-*/**/*
|
||||
- name: Publish macOS Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_macos
|
||||
path: _artifacts/osx-*/**/*
|
||||
- name: Publish Windows Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_windows
|
||||
path: _artifacts/win-*/**/*
|
||||
branch: ${{ needs.prepare.outputs.branch }}
|
||||
version: ${{ needs.prepare.outputs.version }}
|
||||
framework: ${{ needs.prepare.outputs.framework }}
|
||||
runtime: ${{ matrix.runtime }}
|
||||
package_tests: ${{ matrix.package_tests }}
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -178,7 +166,7 @@ jobs:
|
||||
use_postgres: true
|
||||
|
||||
integration_test:
|
||||
needs: backend
|
||||
needs: [prepare, backend]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -187,18 +175,18 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
artifact: tests-linux-x64
|
||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
|
||||
binary_artifact: build_linux
|
||||
binary_path: linux-x64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||
binary_artifact: build-linux-x64
|
||||
binary_path: linux-x64/${{ needs.prepare.outputs.framework }}/Sonarr
|
||||
- os: macos-latest
|
||||
artifact: tests-osx-arm64
|
||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
|
||||
binary_artifact: build_macos
|
||||
binary_path: osx-arm64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||
binary_artifact: build-osx-arm64
|
||||
binary_path: osx-arm64/${{ needs.prepare.outputs.framework }}/Sonarr
|
||||
- os: windows-latest
|
||||
artifact: tests-win-x64
|
||||
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest
|
||||
binary_artifact: build_windows
|
||||
binary_path: win-x64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||
binary_artifact: build-win-x64
|
||||
binary_path: win-x64/${{ needs.prepare.outputs.framework }}/Sonarr
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
@@ -216,20 +204,29 @@ jobs:
|
||||
binary_path: ${{ matrix.binary_path }}
|
||||
|
||||
deploy:
|
||||
if: ${{ github.ref_name == 'develop' || github.ref_name == 'main' }}
|
||||
needs: [backend, frontend, unit_test, unit_test_postgres, integration_test]
|
||||
if: ${{ github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final' }}
|
||||
needs:
|
||||
[
|
||||
prepare,
|
||||
backend,
|
||||
frontend,
|
||||
unit_test,
|
||||
unit_test_postgres,
|
||||
integration_test,
|
||||
]
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/deploy.yml
|
||||
with:
|
||||
framework: ${{ needs.backend.outputs.framework }}
|
||||
framework: ${{ needs.prepare.outputs.framework }}
|
||||
branch: ${{ github.ref_name }}
|
||||
major_version: ${{ needs.backend.outputs.major_version }}
|
||||
version: ${{ needs.backend.outputs.version }}
|
||||
major_version: ${{ needs.prepare.outputs.major_version }}
|
||||
version: ${{ needs.prepare.outputs.version }}
|
||||
|
||||
notify:
|
||||
name: Discord Notification
|
||||
needs:
|
||||
[
|
||||
prepare,
|
||||
backend,
|
||||
frontend,
|
||||
unit_test,
|
||||
@@ -237,7 +234,7 @@ jobs:
|
||||
integration_test,
|
||||
deploy,
|
||||
]
|
||||
if: ${{ !cancelled() && (github.ref_name == 'develop' || github.ref_name == 'main') }}
|
||||
if: ${{ !cancelled() && (github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final') }}
|
||||
env:
|
||||
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -253,5 +250,5 @@ jobs:
|
||||
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
embed-description: |
|
||||
**Branch** ${{ github.ref }}
|
||||
**Build** ${{ needs.backend.outputs.version }}
|
||||
**Build** ${{ needs.prepare.outputs.version }}
|
||||
embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }}
|
||||
6
.github/workflows/conflict_labeler.yml
vendored
6
.github/workflows/conflict_labeler.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- develop
|
||||
- v5-develop
|
||||
types: [synchronize]
|
||||
|
||||
jobs:
|
||||
@@ -21,6 +22,5 @@ jobs:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@v3
|
||||
with:
|
||||
dirtyLabel: 'merge-conflict'
|
||||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||
|
||||
dirtyLabel: "merge-conflict"
|
||||
repoToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
241
.github/workflows/deploy.yml
vendored
241
.github/workflows/deploy.yml
vendored
@@ -3,20 +3,20 @@ name: Deploy
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
framework:
|
||||
description: '.net framework'
|
||||
framework:
|
||||
description: ".net framework"
|
||||
type: string
|
||||
required: true
|
||||
branch:
|
||||
description: 'Git branch used for this build'
|
||||
description: "Git branch used for this build"
|
||||
type: string
|
||||
required: true
|
||||
major_version:
|
||||
description: 'Sonarr major version'
|
||||
description: "Sonarr major version"
|
||||
type: string
|
||||
required: true
|
||||
version:
|
||||
description: 'Sonarr version'
|
||||
description: "Sonarr version"
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
@@ -27,31 +27,42 @@ jobs:
|
||||
package:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [freebsd, linux, macos, windows]
|
||||
include:
|
||||
- platform: freebsd
|
||||
- runtime: freebsd-x64
|
||||
os: ubuntu-latest
|
||||
- platform: linux
|
||||
- runtime: linux-arm
|
||||
os: ubuntu-latest
|
||||
- platform: macos
|
||||
- runtime: linux-arm64
|
||||
os: ubuntu-latest
|
||||
- platform: windows
|
||||
- runtime: linux-musl-arm64
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-musl-x64
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-x64
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-arm64
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-x64
|
||||
os: ubuntu-latest
|
||||
- runtime: win-x64
|
||||
os: windows-latest
|
||||
- runtime: win-x86
|
||||
os: windows-latest
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Package
|
||||
uses: ./.github/actions/package
|
||||
with:
|
||||
framework: ${{ inputs.framework }}
|
||||
platform: ${{ matrix.platform }}
|
||||
artifact: build_${{ matrix.platform }}
|
||||
branch: ${{ inputs.branch }}
|
||||
major_version: ${{ inputs.major_version }}
|
||||
version: ${{ inputs.version }}
|
||||
- name: Package
|
||||
uses: ./.github/actions/package
|
||||
with:
|
||||
framework: ${{ inputs.framework }}
|
||||
runtime: ${{ matrix.runtime }}
|
||||
artifact: build-${{ matrix.runtime }}
|
||||
branch: ${{ inputs.branch }}
|
||||
major_version: ${{ inputs.major_version }}
|
||||
version: ${{ inputs.version }}
|
||||
|
||||
release:
|
||||
needs: package
|
||||
@@ -59,102 +70,102 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: _artifacts
|
||||
pattern: release_*
|
||||
merge-multiple: true
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: _artifacts
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Get Previous Release
|
||||
id: previous-release
|
||||
uses: cardinalby/git-get-release-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
latest: true
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
- name: Get Previous Release
|
||||
id: previous-release
|
||||
uses: cardinalby/git-get-release-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
latest: true
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
|
||||
- name: Generate Release Notes
|
||||
id: generate-release-notes
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: 'v${{ inputs.version }}',
|
||||
target_commitish: '${{ github.sha }}',
|
||||
previous_tag_name: '${{ steps.previous-release.outputs.tag_name }}',
|
||||
})
|
||||
return data.body
|
||||
- name: Generate Release Notes
|
||||
id: generate-release-notes
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: 'v${{ inputs.version }}',
|
||||
target_commitish: '${{ github.sha }}',
|
||||
previous_tag_name: '${{ steps.previous-release.outputs.tag_name }}',
|
||||
})
|
||||
return data.body
|
||||
|
||||
- name: Create release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: _artifacts/Sonarr.*
|
||||
commit: ${{ github.sha }}
|
||||
generateReleaseNotes: false
|
||||
body: ${{ steps.generate-release-notes.outputs.result }}
|
||||
name: ${{ inputs.version }}
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
skipIfReleaseExists: true
|
||||
tag: v${{ inputs.version }}
|
||||
- name: Create release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: _artifacts/Sonarr.*
|
||||
commit: ${{ github.sha }}
|
||||
generateReleaseNotes: false
|
||||
body: ${{ steps.generate-release-notes.outputs.result }}
|
||||
name: ${{ inputs.version }}
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
skipIfReleaseExists: true
|
||||
tag: v${{ inputs.version }}
|
||||
|
||||
- name: Publish to Services
|
||||
shell: bash
|
||||
working-directory: _artifacts
|
||||
run: |
|
||||
branch=${{ inputs.branch }}
|
||||
version=${{ inputs.version }}
|
||||
lastCommit=${{ github.sha }}
|
||||
|
||||
hashes="["
|
||||
|
||||
addHash() {
|
||||
path=$1
|
||||
os=$2
|
||||
arch=$3
|
||||
type=$4
|
||||
|
||||
local hash=$(sha256sum *.$version.$path | awk '{ print $1; }')
|
||||
echo "{ \""Os\"": \""$os\"", \""Arch\"": \""$arch\"", \""Type\"": \""$type\"", \""Hash\"": \""$hash\"" }"
|
||||
}
|
||||
|
||||
hashes="$hashes $(addHash "linux-arm.tar.gz" "linux" "arm" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-arm64.tar.gz" "linux" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-x64.tar.gz" "linux" "x64" "archive")"
|
||||
# hashes="$hashes, $(addHash "linux-x86.tar.gz" "linux" "x86" "archive")"
|
||||
|
||||
# hashes="$hashes, $(addHash "linux-musl-arm.tar.gz" "linuxmusl" "arm" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-musl-arm64.tar.gz" "linuxmusl" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-musl-x64.tar.gz" "linuxmusl" "x64" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "osx-arm64.tar.gz" "osx" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "osx-x64.tar.gz" "osx" "x64" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "osx-arm64-app.zip" "osx" "arm64" "installer")"
|
||||
hashes="$hashes, $(addHash "osx-x64-app.zip" "osx" "x64" "installer")"
|
||||
|
||||
hashes="$hashes, $(addHash "win-x64.zip" "windows" "x64" "archive")"
|
||||
hashes="$hashes, $(addHash "win-x86.zip" "windows" "x86" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "win-x64-installer.exe" "windows" "x64" "installer")"
|
||||
hashes="$hashes, $(addHash "win-x86-installer.exe" "windows" "x86" "installer")"
|
||||
|
||||
hashes="$hashes, $(addHash "freebsd-x64.tar.gz" "freebsd" "x64" "archive")"
|
||||
|
||||
hashes="$hashes ]"
|
||||
|
||||
json="{\""branch\"":\""$branch\"", \""version\"":\""$version\"", \""lastCommit\"":\""$lastCommit\"", \""hashes\"":$hashes, \""gitHubRelease\"":true}"
|
||||
url="https://services.sonarr.tv/v1/update"
|
||||
|
||||
echo "Publishing update $version ($branch) to: $url"
|
||||
echo "$json"
|
||||
|
||||
curl -H "Content-Type: application/json" -H "X-Api-Key: ${{ secrets.SERVICES_API_KEY }}" -X POST -d "$json" --fail-with-body $url
|
||||
- name: Publish to Services
|
||||
shell: bash
|
||||
working-directory: _artifacts
|
||||
run: |
|
||||
branch=${{ inputs.branch }}
|
||||
version=${{ inputs.version }}
|
||||
lastCommit=${{ github.sha }}
|
||||
|
||||
hashes="["
|
||||
|
||||
addHash() {
|
||||
path=$1
|
||||
os=$2
|
||||
arch=$3
|
||||
type=$4
|
||||
|
||||
local hash=$(sha256sum *.$version.$path | awk '{ print $1; }')
|
||||
echo "{ \""Os\"": \""$os\"", \""Arch\"": \""$arch\"", \""Type\"": \""$type\"", \""Hash\"": \""$hash\"" }"
|
||||
}
|
||||
|
||||
hashes="$hashes $(addHash "linux-arm.tar.gz" "linux" "arm" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-arm64.tar.gz" "linux" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-x64.tar.gz" "linux" "x64" "archive")"
|
||||
# hashes="$hashes, $(addHash "linux-x86.tar.gz" "linux" "x86" "archive")"
|
||||
|
||||
# hashes="$hashes, $(addHash "linux-musl-arm.tar.gz" "linuxmusl" "arm" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-musl-arm64.tar.gz" "linuxmusl" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-musl-x64.tar.gz" "linuxmusl" "x64" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "osx-arm64.tar.gz" "osx" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "osx-x64.tar.gz" "osx" "x64" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "osx-arm64-app.zip" "osx" "arm64" "installer")"
|
||||
hashes="$hashes, $(addHash "osx-x64-app.zip" "osx" "x64" "installer")"
|
||||
|
||||
hashes="$hashes, $(addHash "win-x64.zip" "windows" "x64" "archive")"
|
||||
hashes="$hashes, $(addHash "win-x86.zip" "windows" "x86" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "win-x64-installer.exe" "windows" "x64" "installer")"
|
||||
hashes="$hashes, $(addHash "win-x86-installer.exe" "windows" "x86" "installer")"
|
||||
|
||||
hashes="$hashes, $(addHash "freebsd-x64.tar.gz" "freebsd" "x64" "archive")"
|
||||
|
||||
hashes="$hashes ]"
|
||||
|
||||
json="{\""branch\"":\""$branch\"", \""version\"":\""$version\"", \""lastCommit\"":\""$lastCommit\"", \""hashes\"":$hashes, \""gitHubRelease\"":true}"
|
||||
url="https://services.sonarr.tv/v1/update"
|
||||
|
||||
echo "Publishing update $version ($branch) to: $url"
|
||||
echo "$json"
|
||||
|
||||
curl -H "Content-Type: application/json" -H "X-Api-Key: ${{ secrets.SERVICES_API_KEY }}" -X POST -d "$json" --fail-with-body $url
|
||||
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -10,7 +10,7 @@
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build dotnet",
|
||||
// If you have changed target frameworks, make sure to update the program path.
|
||||
"program": "${workspaceFolder}/_output/net6.0/Sonarr",
|
||||
"program": "${workspaceFolder}/_output/net8.0/Sonarr",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
# How to Contribute #
|
||||
# How to Contribute
|
||||
|
||||
We're always looking for people to help make Sonarr even better, there are a number of ways to contribute.
|
||||
|
||||
## Documentation ##
|
||||
## Documentation
|
||||
|
||||
Setup guides, [FAQ](https://wiki.servarr.com/sonarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/sonarr) the better.
|
||||
|
||||
## Development ##
|
||||
## Development
|
||||
|
||||
### Tools required ###
|
||||
- Visual Studio 2019 or higher (https://www.visualstudio.com/vs/). The community version is free and works (https://www.visualstudio.com/downloads/).
|
||||
### Tools required
|
||||
|
||||
- Visual Studio 2019 or higher (https://www.visualstudio.com/vs/). The community version is free and works (https://www.visualstudio.com/downloads/).
|
||||
- HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 10.X.X or higher)
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
|
||||
### Getting started ###
|
||||
### Getting started
|
||||
|
||||
1. Fork Sonarr
|
||||
2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
|
||||
2. Clone the repository into your development machine. [_info_](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
|
||||
3. Install the required Node Packages `yarn install`
|
||||
4. Start webpack to monitor your dev environment for any frontend changes that need post processing using `yarn start` command.
|
||||
5. Build the project in Visual Studio, Setting startup project to `Sonarr.Console` and framework to `x86`
|
||||
6. Debug the project in Visual Studio
|
||||
7. Open http://localhost:8989
|
||||
|
||||
### Contributing Code ###
|
||||
### Contributing Code
|
||||
|
||||
- If you're adding a new, already requested feature, please comment on [Github Issues](https://github.com/Sonarr/Sonarr/issues "Github Issues") so work is not duplicated (If you want to add something not already on there, please talk to us first)
|
||||
- Rebase from Sonarr's `develop` branch, don't merge
|
||||
- Rebase from Sonarr's `v5-develop` branch, don't merge
|
||||
- Make meaningful commits, or squash them
|
||||
- Feel free to make a pull request before work is complete, this will let us see where its at and make comments/suggest improvements
|
||||
- Reach out to us on our [forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/), [discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr) if you have any questions
|
||||
@@ -35,8 +38,9 @@ Setup guides, [FAQ](https://wiki.servarr.com/sonarr/faq), the more information w
|
||||
- One feature/bug fix per pull request to keep things clean and easy to understand
|
||||
- Use 4 spaces instead of tabs, this should be the default for VS 2019 and WebStorm
|
||||
|
||||
### Pull Requesting ###
|
||||
- Only make pull requests to develop (currently `develop`), never `main`, if you make a PR to master we'll comment on it and close it
|
||||
### Pull Requesting
|
||||
|
||||
- Only make pull requests to the default branch (currently `v5-develop`), never `main`, if you make a PR to main we'll comment on it and close it
|
||||
- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability
|
||||
- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it
|
||||
- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed)
|
||||
|
||||
@@ -12,7 +12,7 @@ Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS fee
|
||||
- [Download/Installation](https://sonarr.tv/#downloads-v3)
|
||||
- [FAQ](https://wiki.servarr.com/sonarr/faq)
|
||||
- [Wiki](https://wiki.servarr.com/Sonarr)
|
||||
- [v4 Beta API Documentation](https://sonarr.tv/docs/api)
|
||||
- [API Documentation](https://sonarr.tv/docs/api)
|
||||
- [Donate](https://sonarr.tv/donate)
|
||||
|
||||
## Support
|
||||
@@ -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-2024
|
||||
- Copyright 2010-2025
|
||||
|
||||
455
build.sh
455
build.sh
@@ -1,455 +0,0 @@
|
||||
#! /usr/bin/env bash
|
||||
set -e
|
||||
|
||||
outputFolder='_output'
|
||||
testPackageFolder='_tests'
|
||||
artifactsFolder="_artifacts";
|
||||
framework="${FRAMEWORK:=net6.0}"
|
||||
|
||||
ProgressStart()
|
||||
{
|
||||
echo "::group::$1"
|
||||
echo "Start '$1'"
|
||||
}
|
||||
|
||||
ProgressEnd()
|
||||
{
|
||||
echo "Finish '$1'"
|
||||
echo "::endgroup::"
|
||||
}
|
||||
|
||||
UpdateVersionNumber()
|
||||
{
|
||||
if [ "$SONARR_VERSION" != "" ]; then
|
||||
echo "Updating version info to: $SONARR_VERSION"
|
||||
sed -i'' -e "s/<AssemblyVersion>[0-9.*]\+<\/AssemblyVersion>/<AssemblyVersion>$SONARR_VERSION<\/AssemblyVersion>/g" src/Directory.Build.props
|
||||
sed -i'' -e "s/<AssemblyConfiguration>[\$()A-Za-z-]\+<\/AssemblyConfiguration>/<AssemblyConfiguration>${BRANCH}<\/AssemblyConfiguration>/g" src/Directory.Build.props
|
||||
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$SONARR_VERSION<\/string>/g" distribution/macOS/Sonarr.app/Contents/Info.plist
|
||||
fi
|
||||
}
|
||||
|
||||
EnableExtraPlatformsInSDK()
|
||||
{
|
||||
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
|
||||
if grep -q freebsd-x64 "$BUNDLEDVERSIONS"; then
|
||||
echo "Extra platforms already enabled"
|
||||
else
|
||||
echo "Enabling extra platform support"
|
||||
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
|
||||
fi
|
||||
}
|
||||
|
||||
EnableExtraPlatforms()
|
||||
{
|
||||
if grep -qv freebsd-x64 src/Directory.Build.props; then
|
||||
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
|
||||
fi
|
||||
}
|
||||
|
||||
LintUI()
|
||||
{
|
||||
ProgressStart 'ESLint'
|
||||
yarn lint
|
||||
ProgressEnd 'ESLint'
|
||||
|
||||
ProgressStart 'Stylelint'
|
||||
yarn stylelint
|
||||
ProgressEnd 'Stylelint'
|
||||
}
|
||||
|
||||
Build()
|
||||
{
|
||||
ProgressStart 'Build'
|
||||
|
||||
rm -rf $outputFolder
|
||||
rm -rf $testPackageFolder
|
||||
|
||||
slnFile=src/Sonarr.sln
|
||||
|
||||
if [ $os = "windows" ]; then
|
||||
platform=Windows
|
||||
else
|
||||
platform=Posix
|
||||
fi
|
||||
|
||||
dotnet clean $slnFile -c Debug
|
||||
dotnet clean $slnFile -c Release
|
||||
|
||||
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||
then
|
||||
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -t:PublishAllRids
|
||||
else
|
||||
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$RID -t:PublishAllRids
|
||||
fi
|
||||
|
||||
ProgressEnd 'Build'
|
||||
}
|
||||
|
||||
YarnInstall()
|
||||
{
|
||||
ProgressStart 'yarn install'
|
||||
yarn install --frozen-lockfile --network-timeout 120000
|
||||
ProgressEnd 'yarn install'
|
||||
}
|
||||
|
||||
RunWebpack()
|
||||
{
|
||||
ProgressStart 'Running webpack'
|
||||
yarn run build --env production
|
||||
ProgressEnd 'Running webpack'
|
||||
}
|
||||
|
||||
PackageFiles()
|
||||
{
|
||||
local folder="$1"
|
||||
local framework="$2"
|
||||
local runtime="$3"
|
||||
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r $outputFolder/$framework/$runtime/publish/* $folder
|
||||
cp -r $outputFolder/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
||||
|
||||
if [ "$FRONTEND" = "YES" ];
|
||||
then
|
||||
cp -r $outputFolder/UI $folder
|
||||
fi
|
||||
|
||||
echo "Adding LICENSE"
|
||||
cp LICENSE.md $folder
|
||||
}
|
||||
|
||||
PackageLinux()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating $runtime Package for $framework"
|
||||
|
||||
local folder=$artifactsFolder/$runtime/$framework/Sonarr
|
||||
|
||||
PackageFiles "$folder" "$framework" "$runtime"
|
||||
|
||||
echo "Removing Service helpers"
|
||||
rm -f $folder/ServiceUninstall.*
|
||||
rm -f $folder/ServiceInstall.*
|
||||
|
||||
echo "Removing Sonarr.Windows"
|
||||
rm $folder/Sonarr.Windows.*
|
||||
|
||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||
if [ "$framework" = "$framework" ]; then
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||
fi
|
||||
|
||||
ProgressEnd "Creating $runtime Package for $framework"
|
||||
}
|
||||
|
||||
PackageMacOS()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating $runtime Package for $framework"
|
||||
|
||||
local folder=$artifactsFolder/$runtime/$framework/Sonarr
|
||||
|
||||
PackageFiles "$folder" "$framework" "$runtime"
|
||||
|
||||
echo "Removing Service helpers"
|
||||
rm -f $folder/ServiceUninstall.*
|
||||
rm -f $folder/ServiceInstall.*
|
||||
|
||||
echo "Removing Sonarr.Windows"
|
||||
rm $folder/Sonarr.Windows.*
|
||||
|
||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||
if [ "$framework" = "$framework" ]; then
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||
fi
|
||||
|
||||
ProgressEnd "Creating $runtime Package for $framework"
|
||||
}
|
||||
|
||||
PackageMacOSApp()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating $runtime App Package for $framework"
|
||||
|
||||
local folder=$artifactsFolder/$runtime-app/$framework
|
||||
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r distribution/macOS/Sonarr.app $folder
|
||||
mkdir -p $folder/Sonarr.app/Contents/MacOS
|
||||
|
||||
echo "Copying Binaries"
|
||||
cp -r $artifactsFolder/$runtime/$framework/Sonarr/* $folder/Sonarr.app/Contents/MacOS
|
||||
|
||||
echo "Removing Update Folder"
|
||||
rm -r $folder/Sonarr.app/Contents/MacOS/Sonarr.Update
|
||||
|
||||
ProgressEnd "Creating $runtime App Package for $framework"
|
||||
}
|
||||
|
||||
PackageWindows()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating Windows Package for $framework"
|
||||
|
||||
local folder=$artifactsFolder/$runtime/$framework/Sonarr
|
||||
|
||||
PackageFiles "$folder" "$framework" "$runtime"
|
||||
cp -r $outputFolder/$framework-windows/$runtime/publish/* $folder
|
||||
|
||||
echo "Removing Sonarr.Mono"
|
||||
rm -f $folder/Sonarr.Mono.*
|
||||
rm -f $folder/Mono.Posix.NETStandard.*
|
||||
rm -f $folder/libMonoPosixHelper.*
|
||||
|
||||
echo "Adding Sonarr.Windows to UpdatePackage"
|
||||
cp $folder/Sonarr.Windows.* $folder/Sonarr.Update
|
||||
|
||||
ProgressEnd "Creating Windows Package for $framework"
|
||||
}
|
||||
|
||||
Package()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
local SPLIT
|
||||
|
||||
IFS='-' read -ra SPLIT <<< "$runtime"
|
||||
|
||||
case "${SPLIT[0]}" in
|
||||
linux|freebsd*)
|
||||
PackageLinux "$framework" "$runtime"
|
||||
;;
|
||||
win)
|
||||
PackageWindows "$framework" "$runtime"
|
||||
;;
|
||||
osx)
|
||||
PackageMacOS "$framework" "$runtime"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
PackageTests()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating $runtime Test Package for $framework"
|
||||
|
||||
cp test.sh "$testPackageFolder/$framework/$runtime/publish"
|
||||
|
||||
rm -f $testPackageFolder/$framework/$runtime/*.log.config
|
||||
|
||||
ProgressEnd "Creating $runtime Test Package for $framework"
|
||||
}
|
||||
|
||||
UploadTestArtifacts()
|
||||
{
|
||||
local framework="$1"
|
||||
|
||||
ProgressStart 'Publishing Test Artifacts'
|
||||
|
||||
# Tests
|
||||
for dir in $testPackageFolder/$framework/*
|
||||
do
|
||||
local runtime=$(basename "$dir")
|
||||
echo "##teamcity[publishArtifacts '$testPackageFolder/$framework/$runtime/publish/** => tests.$runtime.zip']"
|
||||
done
|
||||
|
||||
ProgressEnd 'Publishing Test Artifacts'
|
||||
}
|
||||
|
||||
UploadArtifacts()
|
||||
{
|
||||
local framework="$1"
|
||||
|
||||
ProgressStart 'Publishing Artifacts'
|
||||
|
||||
# Releases
|
||||
for dir in $artifactsFolder/*
|
||||
do
|
||||
local runtime=$(basename "$dir")
|
||||
|
||||
echo "##teamcity[publishArtifacts '$artifactsFolder/$runtime/$framework/** => Sonarr.$BRANCH.$SONARR_VERSION.$runtime.zip']"
|
||||
done
|
||||
|
||||
# Debian Package / Windows installer / macOS app
|
||||
echo "##teamcity[publishArtifacts 'distribution/** => distribution.zip']"
|
||||
|
||||
ProgressEnd 'Publishing Artifacts'
|
||||
}
|
||||
|
||||
UploadUIArtifacts()
|
||||
{
|
||||
local framework="$1"
|
||||
|
||||
ProgressStart 'Publishing UI Artifacts'
|
||||
|
||||
# UI folder
|
||||
echo "##teamcity[publishArtifacts '$outputFolder/UI/** => UI.zip']"
|
||||
|
||||
ProgressEnd 'Publishing UI Artifacts'
|
||||
}
|
||||
|
||||
# Use mono or .net depending on OS
|
||||
case "$(uname -s)" in
|
||||
CYGWIN*|MINGW32*|MINGW64*|MSYS*)
|
||||
# on windows, use dotnet
|
||||
os="windows"
|
||||
;;
|
||||
*)
|
||||
# otherwise use mono
|
||||
os="posix"
|
||||
;;
|
||||
esac
|
||||
|
||||
POSITIONAL=()
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "No arguments provided, building everything"
|
||||
BACKEND=YES
|
||||
FRONTEND=YES
|
||||
PACKAGES=YES
|
||||
LINT=YES
|
||||
ENABLE_EXTRA_PLATFORMS=NO
|
||||
ENABLE_EXTRA_PLATFORMS_IN_SDK=NO
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]
|
||||
do
|
||||
key="$1"
|
||||
|
||||
case $key in
|
||||
--backend)
|
||||
BACKEND=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--enable-bsd|--enable-extra-platforms)
|
||||
ENABLE_EXTRA_PLATFORMS=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--enable-extra-platforms-in-sdk)
|
||||
ENABLE_EXTRA_PLATFORMS_IN_SDK=YES
|
||||
shift # past argument
|
||||
;;
|
||||
-r|--runtime)
|
||||
RID="$2"
|
||||
shift # past argument
|
||||
shift # past value
|
||||
;;
|
||||
-f|--framework)
|
||||
FRAMEWORK="$2"
|
||||
shift # past argument
|
||||
shift # past value
|
||||
;;
|
||||
--frontend)
|
||||
FRONTEND=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--packages)
|
||||
PACKAGES=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--lint)
|
||||
LINT=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--all)
|
||||
BACKEND=YES
|
||||
FRONTEND=YES
|
||||
PACKAGES=YES
|
||||
LINT=YES
|
||||
shift # past argument
|
||||
;;
|
||||
*) # unknown option
|
||||
POSITIONAL+=("$1") # save it in an array for later
|
||||
shift # past argument
|
||||
;;
|
||||
esac
|
||||
done
|
||||
set -- "${POSITIONAL[@]}" # restore positional parameters
|
||||
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS_IN_SDK" = "YES" ];
|
||||
then
|
||||
EnableExtraPlatformsInSDK
|
||||
fi
|
||||
|
||||
if [ "$BACKEND" = "YES" ];
|
||||
then
|
||||
UpdateVersionNumber
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||
then
|
||||
EnableExtraPlatforms
|
||||
fi
|
||||
|
||||
Build
|
||||
|
||||
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||
then
|
||||
PackageTests "$framework" "win-x64"
|
||||
PackageTests "$framework" "win-x86"
|
||||
PackageTests "$framework" "linux-x64"
|
||||
PackageTests "$framework" "linux-musl-x64"
|
||||
PackageTests "$framework" "osx-x64"
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||
then
|
||||
PackageTests "$framework" "freebsd-x64"
|
||||
fi
|
||||
else
|
||||
PackageTests "$FRAMEWORK" "$RID"
|
||||
fi
|
||||
|
||||
UploadTestArtifacts "$framework"
|
||||
fi
|
||||
|
||||
if [ "$FRONTEND" = "YES" ];
|
||||
then
|
||||
YarnInstall
|
||||
|
||||
if [ "$LINT" = "YES" ];
|
||||
then
|
||||
LintUI
|
||||
fi
|
||||
|
||||
RunWebpack
|
||||
UploadUIArtifacts
|
||||
fi
|
||||
|
||||
if [ "$PACKAGES" = "YES" ];
|
||||
then
|
||||
UpdateVersionNumber
|
||||
|
||||
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||
then
|
||||
Package "$framework" "win-x64"
|
||||
Package "$framework" "win-x86"
|
||||
Package "$framework" "linux-x64"
|
||||
Package "$framework" "linux-musl-x64"
|
||||
Package "$framework" "linux-arm64"
|
||||
Package "$framework" "linux-musl-arm64"
|
||||
Package "$framework" "linux-arm"
|
||||
Package "$framework" "osx-x64"
|
||||
Package "$framework" "osx-arm64"
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||
then
|
||||
Package "$framework" "freebsd-x64"
|
||||
fi
|
||||
else
|
||||
Package "$FRAMEWORK" "$RID"
|
||||
fi
|
||||
|
||||
UploadArtifacts "$framework"
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
@REM SET SONARR_MAJOR_VERSION=4
|
||||
@REM SET SONARR_VERSION=4.0.0.5
|
||||
@REM SET BRANCH=develop
|
||||
@REM SET FRAMEWORK=net6.0
|
||||
@REM SET FRAMEWORK=net8.0
|
||||
@REM SET RUNTIME=win-x64
|
||||
|
||||
inno\ISCC.exe sonarr.iss
|
||||
|
||||
@@ -7,9 +7,9 @@ cd /data/test
|
||||
|
||||
runTest()
|
||||
{
|
||||
bash test.sh Linux $1
|
||||
bash scripts/test.sh Linux $1
|
||||
cp TestResult.xml /data/_tests_results/TestResult_$1.xml
|
||||
}
|
||||
|
||||
runTest Integration
|
||||
runTest Unit
|
||||
runTest Unit
|
||||
|
||||
@@ -14,7 +14,6 @@ module.exports = (env) => {
|
||||
const srcFolder = path.join(frontendFolder, 'src');
|
||||
const isProduction = !!env.production;
|
||||
const isProfiling = isProduction && !!env.profile;
|
||||
const inlineWebWorkers = 'no-fallback';
|
||||
|
||||
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
||||
|
||||
@@ -66,7 +65,7 @@ module.exports = (env) => {
|
||||
|
||||
output: {
|
||||
path: distFolder,
|
||||
publicPath: '/',
|
||||
publicPath: 'auto',
|
||||
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
|
||||
sourceMapFilename: '[file].map'
|
||||
},
|
||||
@@ -160,16 +159,6 @@ module.exports = (env) => {
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.worker\.js$/,
|
||||
use: {
|
||||
loader: 'worker-loader',
|
||||
options: {
|
||||
filename: '[name].js',
|
||||
inline: inlineWebWorkers
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: [/\.jsx?$/, /\.tsx?$/],
|
||||
exclude: /(node_modules|JsLibraries)/,
|
||||
|
||||
@@ -145,7 +145,7 @@ function Blocklist() {
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setBlocklistFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
|
||||
|
||||
function createBlocklistSelector() {
|
||||
@@ -23,9 +23,7 @@ function createFilterBuilderPropsSelector() {
|
||||
);
|
||||
}
|
||||
|
||||
interface BlocklistFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
type BlocklistFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
||||
const sectionItems = useSelector(createBlocklistSelector());
|
||||
@@ -43,7 +41,6 @@ export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'typings/History';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import formatAge from 'Utilities/Number/formatAge';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './HistoryDetails.css';
|
||||
@@ -50,6 +51,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
ageHours,
|
||||
ageMinutes,
|
||||
publishedDate,
|
||||
size,
|
||||
} = data as GrabbedHistoryData;
|
||||
|
||||
const downloadClientNameInfo = downloadClientName ?? downloadClient;
|
||||
@@ -160,12 +162,19 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('Size')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFailed') {
|
||||
const { message } = data as DownloadFailedHistory;
|
||||
const { message, indexer } = data as DownloadFailedHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
@@ -179,6 +188,10 @@ 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}
|
||||
@@ -187,7 +200,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFolderImported') {
|
||||
const { customFormatScore, droppedPath, importedPath } =
|
||||
const { customFormatScore, droppedPath, importedPath, size } =
|
||||
data as DownloadFolderImportedHistory;
|
||||
|
||||
return (
|
||||
@@ -220,12 +233,20 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('FileSize')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'episodeFileDeleted') {
|
||||
const { reason, customFormatScore } = data as EpisodeFileDeletedHistory;
|
||||
const { reason, customFormatScore, size } =
|
||||
data as EpisodeFileDeletedHistory;
|
||||
|
||||
let reasonMessage = '';
|
||||
|
||||
@@ -255,6 +276,13 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('FileSize')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ function History() {
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setHistoryFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setHistoryFilter } from 'Store/Actions/historyActions';
|
||||
|
||||
function createHistorySelector() {
|
||||
@@ -23,19 +23,16 @@ function createFilterBuilderPropsSelector() {
|
||||
);
|
||||
}
|
||||
|
||||
interface HistoryFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
type HistoryFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
|
||||
const sectionItems = useSelector(createHistorySelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'history';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: unknown) => {
|
||||
(payload: { selectedFilterKey: string | number }) => {
|
||||
dispatch(setHistoryFilter(payload));
|
||||
},
|
||||
[dispatch]
|
||||
@@ -43,11 +40,10 @@ export default function HistoryFilterModal(props: HistoryFilterModalProps) {
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
customFilterType="history"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -185,7 +185,7 @@ function Queue() {
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setQueueFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -61,7 +61,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
anchor={progressBar!}
|
||||
title={`${state} - ${progress.toFixed(1)}%`}
|
||||
body={<div>{title}</div>}
|
||||
position={tooltipPositions.LEFT}
|
||||
position="bottom-start"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setQueueFilter } from 'Store/Actions/queueActions';
|
||||
|
||||
function createQueueSelector() {
|
||||
@@ -23,9 +23,7 @@ function createFilterBuilderPropsSelector() {
|
||||
);
|
||||
}
|
||||
|
||||
interface QueueFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
type QueueFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||
const sectionItems = useSelector(createQueueSelector());
|
||||
@@ -43,7 +41,6 @@ export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
|
||||
@@ -6,7 +6,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function QueueOptions() {
|
||||
@@ -16,7 +16,7 @@ function QueueOptions() {
|
||||
);
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
({ name, value }: CheckInputChanged) => {
|
||||
({ name, value }: InputChanged<boolean>) => {
|
||||
dispatch(
|
||||
setQueueOption({
|
||||
[name]: value,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Icon, { IconKind } from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
|
||||
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
|
||||
// status === 'downloading'
|
||||
let iconName = icons.DOWNLOADING;
|
||||
let iconKind: IconProps['kind'] = kinds.DEFAULT;
|
||||
let iconKind: IconKind = kinds.DEFAULT;
|
||||
let title = translate('Downloading');
|
||||
|
||||
if (status === 'paused') {
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
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 { icons, kinds } from 'Helpers/Props';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector';
|
||||
import styles from './AddNewSeries.css';
|
||||
|
||||
class AddNewSeries extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
term: props.term || '',
|
||||
isFetching: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const term = this.state.term;
|
||||
|
||||
if (term) {
|
||||
this.props.onSeriesLookupChange(term);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
term,
|
||||
isFetching
|
||||
} = this.props;
|
||||
|
||||
if (term && term !== prevProps.term) {
|
||||
this.setState({
|
||||
term,
|
||||
isFetching: true
|
||||
});
|
||||
this.props.onSeriesLookupChange(term);
|
||||
} else if (isFetching !== prevProps.isFetching) {
|
||||
this.setState({
|
||||
isFetching
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSearchInputChange = ({ value }) => {
|
||||
const hasValue = !!value.trim();
|
||||
|
||||
this.setState({ term: value, isFetching: hasValue }, () => {
|
||||
if (hasValue) {
|
||||
this.props.onSeriesLookupChange(value);
|
||||
} else {
|
||||
this.props.onClearSeriesLookup();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onClearSeriesLookupPress = () => {
|
||||
this.setState({ term: '' });
|
||||
this.props.onClearSeriesLookup();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
error,
|
||||
items,
|
||||
hasExistingSeries
|
||||
} = this.props;
|
||||
|
||||
const term = this.state.term;
|
||||
const isFetching = this.state.isFetching;
|
||||
|
||||
return (
|
||||
<PageContent title={translate('AddNewSeries')}>
|
||||
<PageContentBody>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon
|
||||
name={icons.SEARCH}
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name="seriesLookup"
|
||||
value={term}
|
||||
placeholder="eg. Breaking Bad, tvdb:####"
|
||||
autoFocus={true}
|
||||
onChange={this.onSearchInputChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={styles.clearLookupButton}
|
||||
onPress={this.onClearSeriesLookupPress}
|
||||
>
|
||||
<Icon
|
||||
name={icons.REMOVE}
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error ?
|
||||
<div className={styles.message}>
|
||||
<div className={styles.helpText}>
|
||||
{translate('AddNewSeriesError')}
|
||||
</div>
|
||||
|
||||
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
|
||||
</div> : null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !error && !!items.length &&
|
||||
<div className={styles.searchResults}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<AddNewSeriesSearchResultConnector
|
||||
key={item.tvdbId}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !error && !items.length && !!term &&
|
||||
<div className={styles.message}>
|
||||
<div className={styles.noResults}>{translate('CouldNotFindResults', { term })}</div>
|
||||
<div>{translate('SearchByTvdbId')}</div>
|
||||
<div>
|
||||
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
|
||||
{translate('WhyCantIFindMyShow')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
term ?
|
||||
null :
|
||||
<div className={styles.message}>
|
||||
<div className={styles.helpText}>
|
||||
{translate('AddNewSeriesHelpText')}
|
||||
</div>
|
||||
<div>{translate('SearchByTvdbId')}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!term && !hasExistingSeries ?
|
||||
<div className={styles.message}>
|
||||
<div className={styles.noSeriesText}>
|
||||
{translate('NoSeriesHaveBeenAdded')}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
to="/add/import"
|
||||
kind={kinds.PRIMARY}
|
||||
>
|
||||
{translate('ImportExistingSeries')}
|
||||
</Button>
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<div />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeries.propTypes = {
|
||||
term: PropTypes.string,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
isAdding: PropTypes.bool.isRequired,
|
||||
addError: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
hasExistingSeries: PropTypes.bool.isRequired,
|
||||
onSeriesLookupChange: PropTypes.func.isRequired,
|
||||
onClearSeriesLookup: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewSeries;
|
||||
149
frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx
Normal file
149
frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
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 useDebounce from 'Helpers/Hooks/useDebounce';
|
||||
import useQueryParams from 'Helpers/Hooks/useQueryParams';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
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() {
|
||||
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
|
||||
|
||||
const seriesCount = useSelector(
|
||||
(state: AppState) => state.series.items.length
|
||||
);
|
||||
|
||||
const [term, setTerm] = useState(initialTerm);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const query = useDebounce(term, term ? 300 : 0);
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
setTerm(value);
|
||||
setIsFetching(!!value.trim());
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClearSeriesLookupPress = useCallback(() => {
|
||||
setTerm('');
|
||||
setIsFetching(false);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
isFetching: isFetchingApi,
|
||||
error,
|
||||
data = [],
|
||||
} = useLookupSeries(query);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFetching(isFetchingApi);
|
||||
}, [isFetchingApi]);
|
||||
|
||||
useEffect(() => {
|
||||
setTerm(initialTerm);
|
||||
}, [initialTerm]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('AddNewSeries')}>
|
||||
<PageContentBody>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon name={icons.SEARCH} size={20} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name="seriesLookup"
|
||||
value={term}
|
||||
placeholder="eg. Breaking Bad, tvdb:####"
|
||||
autoFocus={true}
|
||||
onChange={handleSearchInputChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={styles.clearLookupButton}
|
||||
onPress={handleClearSeriesLookupPress}
|
||||
>
|
||||
<Icon name={icons.REMOVE} size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isFetching ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.helpText}>
|
||||
{translate('AddNewSeriesError')}
|
||||
</div>
|
||||
|
||||
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isFetching && !error && !!data.length ? (
|
||||
<div className={styles.searchResults}>
|
||||
{data.map((item) => {
|
||||
return (
|
||||
<AddNewSeriesSearchResult key={item.tvdbId} series={item} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isFetching && !error && !data.length && term ? (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.noResults}>
|
||||
{translate('CouldNotFindResults', { term })}
|
||||
</div>
|
||||
<div>{translate('SearchByTvdbId')}</div>
|
||||
<div>
|
||||
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
|
||||
{translate('WhyCantIFindMyShow')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{term ? null : (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.helpText}>
|
||||
{translate('AddNewSeriesHelpText')}
|
||||
</div>
|
||||
<div>{translate('SearchByTvdbId')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!term && !seriesCount ? (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.noSeriesText}>
|
||||
{translate('NoSeriesHaveBeenAdded')}
|
||||
</div>
|
||||
<div>
|
||||
<Button to="/add/import" kind={kinds.PRIMARY}>
|
||||
{translate('ImportExistingSeries')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewSeries;
|
||||
@@ -1,104 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearAddSeries, lookupSeries } from 'Store/Actions/addSeriesActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import parseUrl from 'Utilities/String/parseUrl';
|
||||
import AddNewSeries from './AddNewSeries';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.addSeries,
|
||||
(state) => state.series.items.length,
|
||||
(state) => state.router.location,
|
||||
(addSeries, existingSeriesCount, location) => {
|
||||
const { params } = parseUrl(location.search);
|
||||
|
||||
return {
|
||||
...addSeries,
|
||||
term: params.term,
|
||||
hasExistingSeries: existingSeriesCount > 0
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
lookupSeries,
|
||||
clearAddSeries,
|
||||
fetchRootFolders
|
||||
};
|
||||
|
||||
class AddNewSeriesConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._seriesLookupTimeout = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchRootFolders();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._seriesLookupTimeout) {
|
||||
clearTimeout(this._seriesLookupTimeout);
|
||||
}
|
||||
|
||||
this.props.clearAddSeries();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSeriesLookupChange = (term) => {
|
||||
if (this._seriesLookupTimeout) {
|
||||
clearTimeout(this._seriesLookupTimeout);
|
||||
}
|
||||
|
||||
if (term.trim() === '') {
|
||||
this.props.clearAddSeries();
|
||||
} else {
|
||||
this._seriesLookupTimeout = setTimeout(() => {
|
||||
this.props.lookupSeries({ term });
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
onClearSeriesLookup = () => {
|
||||
this.props.clearAddSeries();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
term,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<AddNewSeries
|
||||
term={term}
|
||||
{...otherProps}
|
||||
onSeriesLookupChange={this.onSeriesLookupChange}
|
||||
onClearSeriesLookup={this.onClearSeriesLookup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeriesConnector.propTypes = {
|
||||
term: PropTypes.string,
|
||||
lookupSeries: PropTypes.func.isRequired,
|
||||
clearAddSeries: PropTypes.func.isRequired,
|
||||
fetchRootFolders: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector);
|
||||
@@ -1,31 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import AddNewSeriesModalContentConnector from './AddNewSeriesModalContentConnector';
|
||||
|
||||
function AddNewSeriesModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AddNewSeriesModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddNewSeriesModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewSeriesModal;
|
||||
23
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.tsx
Normal file
23
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import AddNewSeriesModalContent, {
|
||||
AddNewSeriesModalContentProps,
|
||||
} from './AddNewSeriesModalContent';
|
||||
|
||||
interface AddNewSeriesModalProps extends AddNewSeriesModalContentProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function AddNewSeriesModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: AddNewSeriesModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<AddNewSeriesModalContent {...otherProps} onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewSeriesModal;
|
||||
@@ -1,300 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
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 Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import * as seriesTypes from 'Utilities/Series/seriesTypes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AddNewSeriesModalContent.css';
|
||||
|
||||
class AddNewSeriesModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
seriesType: props.initialSeriesType === seriesTypes.STANDARD ?
|
||||
props.seriesType.value :
|
||||
props.initialSeriesType
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.seriesType.value !== prevProps.seriesType.value) {
|
||||
this.setState({ seriesType: this.props.seriesType.value });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onQualityProfileIdChange = ({ value }) => {
|
||||
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
|
||||
};
|
||||
|
||||
onAddSeriesPress = () => {
|
||||
const {
|
||||
seriesType
|
||||
} = this.state;
|
||||
|
||||
this.props.onAddSeriesPress(
|
||||
seriesType
|
||||
);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
year,
|
||||
overview,
|
||||
images,
|
||||
isAdding,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
seasonFolder,
|
||||
searchForMissingEpisodes,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
folder,
|
||||
tags,
|
||||
isSmallScreen,
|
||||
isWindows,
|
||||
onModalClose,
|
||||
onInputChange,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{title}
|
||||
|
||||
{
|
||||
!title.contains(year) && !!year &&
|
||||
<span className={styles.year}>({year})</span>
|
||||
}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.container}>
|
||||
{
|
||||
isSmallScreen ?
|
||||
null :
|
||||
<div className={styles.poster}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={styles.info}>
|
||||
{
|
||||
overview ?
|
||||
<div className={styles.overview}>
|
||||
{overview}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('RootFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.ROOT_FOLDER_SELECT}
|
||||
name="rootFolderPath"
|
||||
valueOptions={{
|
||||
seriesFolder: folder,
|
||||
isWindows
|
||||
}}
|
||||
selectedValueOptions={{
|
||||
seriesFolder: folder,
|
||||
isWindows
|
||||
}}
|
||||
helpText={translate('AddNewSeriesRootFolderHelpText', { folder })}
|
||||
onChange={onInputChange}
|
||||
{...rootFolderPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('Monitor')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.labelIcon}
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
title={translate('MonitoringOptions')}
|
||||
body={<SeriesMonitoringOptionsPopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
onChange={onInputChange}
|
||||
{...monitor}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
onChange={this.onQualityProfileIdChange}
|
||||
{...qualityProfileId}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('SeriesType')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.labelIcon}
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
title={translate('SeriesTypes')}
|
||||
body={<SeriesTypePopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
onChange={onInputChange}
|
||||
{...seriesType}
|
||||
value={this.state.seriesType}
|
||||
helpText={translate('SeriesTypesHelpText')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('SeasonFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
onChange={onInputChange}
|
||||
{...seasonFolder}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
onChange={onInputChange}
|
||||
{...tags}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div>
|
||||
<label className={styles.searchLabelContainer}>
|
||||
<span className={styles.searchLabel}>
|
||||
{translate('AddNewSeriesSearchForMissingEpisodes')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchInputContainer}
|
||||
className={styles.searchInput}
|
||||
name="searchForMissingEpisodes"
|
||||
onChange={onInputChange}
|
||||
{...searchForMissingEpisodes}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.searchLabelContainer}>
|
||||
<span className={styles.searchLabel}>
|
||||
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchInputContainer}
|
||||
className={styles.searchInput}
|
||||
name="searchForCutoffUnmetEpisodes"
|
||||
onChange={onInputChange}
|
||||
{...searchForCutoffUnmetEpisodes}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SpinnerButton
|
||||
className={styles.addButton}
|
||||
kind={kinds.SUCCESS}
|
||||
isSpinning={isAdding}
|
||||
onPress={this.onAddSeriesPress}
|
||||
>
|
||||
{translate('AddSeriesWithTitle', { title })}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeriesModalContent.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
overview: PropTypes.string,
|
||||
initialSeriesType: PropTypes.string.isRequired,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isAdding: PropTypes.bool.isRequired,
|
||||
addError: PropTypes.object,
|
||||
rootFolderPath: PropTypes.object,
|
||||
monitor: PropTypes.object.isRequired,
|
||||
qualityProfileId: PropTypes.object,
|
||||
seriesType: PropTypes.object.isRequired,
|
||||
seasonFolder: PropTypes.object.isRequired,
|
||||
searchForMissingEpisodes: PropTypes.object.isRequired,
|
||||
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
|
||||
folder: PropTypes.string.isRequired,
|
||||
tags: PropTypes.object.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
isWindows: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onAddSeriesPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewSeriesModalContent;
|
||||
300
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx
Normal file
300
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
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 SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
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 Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { SeriesType } from 'Series/Series';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
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;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function AddNewSeriesModalContent({
|
||||
series,
|
||||
initialSeriesType,
|
||||
onModalClose,
|
||||
}: AddNewSeriesModalContentProps) {
|
||||
const { title, year, overview, images, folder } = series;
|
||||
const options = useAddSeriesOptions();
|
||||
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]);
|
||||
|
||||
const [seriesType, setSeriesType] = useState<SeriesType>(
|
||||
initialSeriesType === 'standard'
|
||||
? settings.seriesType.value
|
||||
: initialSeriesType
|
||||
);
|
||||
|
||||
const {
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
rootFolderPath,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
searchForMissingEpisodes,
|
||||
seasonFolder,
|
||||
seriesType: seriesTypeSetting,
|
||||
tags,
|
||||
} = settings;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleQualityProfileIdChange = useCallback(
|
||||
({ value }: InputChanged<string | number>) => {
|
||||
setAddSeriesOption('qualityProfileId', value as number);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
}, [
|
||||
series,
|
||||
seriesType,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seasonFolder,
|
||||
searchForMissingEpisodes,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
tags,
|
||||
addSeries,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setSeriesType(seriesTypeSetting.value);
|
||||
}, [seriesTypeSetting]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{title}
|
||||
|
||||
{!title.includes(String(year)) && year ? (
|
||||
<span className={styles.year}>({year})</span>
|
||||
) : null}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.container}>
|
||||
{isSmallScreen ? null : (
|
||||
<div className={styles.poster}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.info}>
|
||||
{overview ? (
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
) : null}
|
||||
|
||||
<Form
|
||||
validationErrors={validationErrors}
|
||||
validationWarnings={validationWarnings}
|
||||
>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('RootFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.ROOT_FOLDER_SELECT}
|
||||
name="rootFolderPath"
|
||||
valueOptions={{
|
||||
seriesFolder: folder,
|
||||
isWindows,
|
||||
}}
|
||||
selectedValueOptions={{
|
||||
seriesFolder: folder,
|
||||
isWindows,
|
||||
}}
|
||||
helpText={translate('AddNewSeriesRootFolderHelpText', {
|
||||
folder,
|
||||
})}
|
||||
onChange={handleInputChange}
|
||||
{...rootFolderPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('Monitor')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon className={styles.labelIcon} name={icons.INFO} />
|
||||
}
|
||||
title={translate('MonitoringOptions')}
|
||||
body={<SeriesMonitoringOptionsPopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
onChange={handleInputChange}
|
||||
{...monitor}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
onChange={handleQualityProfileIdChange}
|
||||
{...qualityProfileId}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('SeriesType')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon className={styles.labelIcon} name={icons.INFO} />
|
||||
}
|
||||
title={translate('SeriesTypes')}
|
||||
body={<SeriesTypePopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
onChange={handleInputChange}
|
||||
{...seriesTypeSetting}
|
||||
value={seriesType}
|
||||
helpText={translate('SeriesTypesHelpText')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('SeasonFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
onChange={handleInputChange}
|
||||
{...seasonFolder}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
onChange={handleInputChange}
|
||||
{...tags}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div>
|
||||
<label className={styles.searchLabelContainer}>
|
||||
<span className={styles.searchLabel}>
|
||||
{translate('AddNewSeriesSearchForMissingEpisodes')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchInputContainer}
|
||||
className={styles.searchInput}
|
||||
name="searchForMissingEpisodes"
|
||||
onChange={handleInputChange}
|
||||
{...searchForMissingEpisodes}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.searchLabelContainer}>
|
||||
<span className={styles.searchLabel}>
|
||||
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchInputContainer}
|
||||
className={styles.searchInput}
|
||||
name="searchForCutoffUnmetEpisodes"
|
||||
onChange={handleInputChange}
|
||||
{...searchForCutoffUnmetEpisodes}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SpinnerButton
|
||||
className={styles.addButton}
|
||||
kind={kinds.SUCCESS}
|
||||
isSpinning={isAdding}
|
||||
onPress={handleAddSeriesPress}
|
||||
>
|
||||
{translate('AddSeriesWithTitle', { title })}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewSeriesModalContent;
|
||||
@@ -1,110 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import AddNewSeriesModalContent from './AddNewSeriesModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.addSeries,
|
||||
createDimensionsSelector(),
|
||||
createSystemStatusSelector(),
|
||||
(addSeriesState, dimensions, systemStatus) => {
|
||||
const {
|
||||
isAdding,
|
||||
addError,
|
||||
defaults
|
||||
} = addSeriesState;
|
||||
|
||||
const {
|
||||
settings,
|
||||
validationErrors,
|
||||
validationWarnings
|
||||
} = selectSettings(defaults, {}, addError);
|
||||
|
||||
return {
|
||||
isAdding,
|
||||
addError,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
validationErrors,
|
||||
validationWarnings,
|
||||
isWindows: systemStatus.isWindows,
|
||||
...settings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setAddSeriesDefault,
|
||||
addSeries
|
||||
};
|
||||
|
||||
class AddNewSeriesModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setAddSeriesDefault({ [name]: value });
|
||||
};
|
||||
|
||||
onAddSeriesPress = (seriesType) => {
|
||||
const {
|
||||
tvdbId,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seasonFolder,
|
||||
searchForMissingEpisodes,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
tags
|
||||
} = this.props;
|
||||
|
||||
this.props.addSeries({
|
||||
tvdbId,
|
||||
rootFolderPath: rootFolderPath.value,
|
||||
monitor: monitor.value,
|
||||
qualityProfileId: qualityProfileId.value,
|
||||
seriesType,
|
||||
seasonFolder: seasonFolder.value,
|
||||
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
||||
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
||||
tags: tags.value
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<AddNewSeriesModalContent
|
||||
{...this.props}
|
||||
onInputChange={this.onInputChange}
|
||||
onAddSeriesPress={this.onAddSeriesPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeriesModalContentConnector.propTypes = {
|
||||
tvdbId: PropTypes.number.isRequired,
|
||||
rootFolderPath: PropTypes.object,
|
||||
monitor: PropTypes.object.isRequired,
|
||||
qualityProfileId: PropTypes.object,
|
||||
seriesType: PropTypes.object.isRequired,
|
||||
seasonFolder: PropTypes.object.isRequired,
|
||||
searchForMissingEpisodes: PropTypes.object.isRequired,
|
||||
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
|
||||
tags: PropTypes.object.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
setAddSeriesDefault: PropTypes.func.isRequired,
|
||||
addSeries: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector);
|
||||
@@ -1,276 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import HeartRating from 'Components/HeartRating';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import Link from 'Components/Link/Link';
|
||||
import MetadataAttribution from 'Components/MetadataAttribution';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import SeriesGenres from 'Series/SeriesGenres';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesModal from './AddNewSeriesModal';
|
||||
import styles from './AddNewSeriesSearchResult.css';
|
||||
|
||||
class AddNewSeriesSearchResult extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isNewAddSeriesModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!prevProps.isExistingSeries && this.props.isExistingSeries) {
|
||||
this.onAddSeriesModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.setState({ isNewAddSeriesModalOpen: true });
|
||||
};
|
||||
|
||||
onAddSeriesModalClose = () => {
|
||||
this.setState({ isNewAddSeriesModalOpen: false });
|
||||
};
|
||||
|
||||
onTVDBLinkPress = (event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
tvdbId,
|
||||
title,
|
||||
titleSlug,
|
||||
year,
|
||||
network,
|
||||
originalLanguage,
|
||||
genres,
|
||||
status,
|
||||
overview,
|
||||
statistics,
|
||||
ratings,
|
||||
folder,
|
||||
seriesType,
|
||||
images,
|
||||
isExistingSeries,
|
||||
isSmallScreen
|
||||
} = this.props;
|
||||
|
||||
const seasonCount = statistics.seasonCount;
|
||||
|
||||
const {
|
||||
isNewAddSeriesModalOpen
|
||||
} = this.state;
|
||||
|
||||
const linkProps = isExistingSeries ? { to: `/series/${titleSlug}` } : { onPress: this.onPress };
|
||||
let seasons = translate('OneSeason');
|
||||
|
||||
if (seasonCount > 1) {
|
||||
seasons = translate('CountSeasons', { count: seasonCount });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.searchResult}>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
{...linkProps}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay}>
|
||||
{
|
||||
isSmallScreen ?
|
||||
null :
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
overflow={true}
|
||||
lazy={false}
|
||||
/>
|
||||
}
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
|
||||
{
|
||||
!title.contains(year) && year ?
|
||||
<span className={styles.year}>
|
||||
({year})
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.icons}>
|
||||
{
|
||||
isExistingSeries ?
|
||||
<Icon
|
||||
className={styles.alreadyExistsIcon}
|
||||
name={icons.CHECK_CIRCLE}
|
||||
size={36}
|
||||
title={translate('AlreadyInYourLibrary')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
<Link
|
||||
className={styles.tvdbLink}
|
||||
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||
onPress={this.onTVDBLinkPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.tvdbLinkIcon}
|
||||
name={icons.EXTERNAL_LINK}
|
||||
size={28}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label size={sizes.LARGE}>
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={13}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
{
|
||||
originalLanguage?.name ?
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon
|
||||
name={icons.LANGUAGE}
|
||||
size={13}
|
||||
/>
|
||||
|
||||
<span className={styles.originalLanguageName}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
network ?
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon
|
||||
name={icons.NETWORK}
|
||||
size={13}
|
||||
/>
|
||||
|
||||
<span className={styles.network}>
|
||||
{network}
|
||||
</span>
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
genres.length > 0 ?
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon
|
||||
name={icons.GENRE}
|
||||
size={13}
|
||||
/>
|
||||
<SeriesGenres className={styles.genres} genres={genres} />
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
seasonCount ?
|
||||
<Label size={sizes.LARGE}>
|
||||
{seasons}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
status === 'ended' ?
|
||||
<Label
|
||||
kind={kinds.DANGER}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
{translate('Ended')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
status === 'upcoming' ?
|
||||
<Label
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
{translate('Upcoming')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>
|
||||
{overview}
|
||||
</div>
|
||||
|
||||
<MetadataAttribution />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddNewSeriesModal
|
||||
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
|
||||
tvdbId={tvdbId}
|
||||
title={title}
|
||||
year={year}
|
||||
overview={overview}
|
||||
folder={folder}
|
||||
initialSeriesType={seriesType}
|
||||
images={images}
|
||||
onModalClose={this.onAddSeriesModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeriesSearchResult.propTypes = {
|
||||
tvdbId: PropTypes.number.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
network: PropTypes.string,
|
||||
originalLanguage: PropTypes.object,
|
||||
genres: PropTypes.arrayOf(PropTypes.string),
|
||||
status: PropTypes.string.isRequired,
|
||||
overview: PropTypes.string,
|
||||
statistics: PropTypes.object.isRequired,
|
||||
ratings: PropTypes.object.isRequired,
|
||||
folder: PropTypes.string.isRequired,
|
||||
seriesType: PropTypes.string.isRequired,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isExistingSeries: PropTypes.bool.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
AddNewSeriesSearchResult.defaultProps = {
|
||||
genres: []
|
||||
};
|
||||
|
||||
export default AddNewSeriesSearchResult;
|
||||
182
frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx
Normal file
182
frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import HeartRating from 'Components/HeartRating';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import Link from 'Components/Link/Link';
|
||||
import MetadataAttribution from 'Components/MetadataAttribution';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import { Statistics } from 'Series/Series';
|
||||
import SeriesGenres from 'Series/SeriesGenres';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
||||
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;
|
||||
|
||||
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
|
||||
|
||||
const seasonCount = statistics.seasonCount;
|
||||
const handlePress = useCallback(() => {
|
||||
setIsNewAddSeriesModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddSeriesModalClose = useCallback(() => {
|
||||
setIsNewAddSeriesModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleTvdbLinkPress = useCallback((event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const linkProps = isExistingSeries
|
||||
? { to: `/series/${titleSlug}` }
|
||||
: { onPress: handlePress };
|
||||
let seasons = translate('OneSeason');
|
||||
|
||||
if (seasonCount > 1) {
|
||||
seasons = translate('CountSeasons', { count: seasonCount });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.searchResult}>
|
||||
<Link className={styles.underlay} {...linkProps} />
|
||||
|
||||
<div className={styles.overlay}>
|
||||
{isSmallScreen ? null : (
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
overflow={true}
|
||||
lazy={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
|
||||
{!title.includes(String(year)) && year ? (
|
||||
<span className={styles.year}>({year})</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.icons}>
|
||||
{isExistingSeries ? (
|
||||
<Icon
|
||||
className={styles.alreadyExistsIcon}
|
||||
name={icons.CHECK_CIRCLE}
|
||||
size={36}
|
||||
title={translate('AlreadyInYourLibrary')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Link
|
||||
className={styles.tvdbLink}
|
||||
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||
onPress={handleTvdbLinkPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.tvdbLinkIcon}
|
||||
name={icons.EXTERNAL_LINK}
|
||||
size={28}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label size={sizes.LARGE}>
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={13}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
{originalLanguage?.name ? (
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon name={icons.LANGUAGE} size={13} />
|
||||
|
||||
<span className={styles.originalLanguageName}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{network ? (
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon name={icons.NETWORK} size={13} />
|
||||
|
||||
<span className={styles.network}>{network}</span>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{genres.length > 0 ? (
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon name={icons.GENRE} size={13} />
|
||||
<SeriesGenres className={styles.genres} genres={genres} />
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{seasonCount ? <Label size={sizes.LARGE}>{seasons}</Label> : null}
|
||||
|
||||
{status === 'ended' ? (
|
||||
<Label kind={kinds.DANGER} size={sizes.LARGE}>
|
||||
{translate('Ended')}
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{status === 'upcoming' ? (
|
||||
<Label kind={kinds.INFO} size={sizes.LARGE}>
|
||||
{translate('Upcoming')}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
|
||||
<MetadataAttribution />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddNewSeriesModal
|
||||
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
|
||||
series={series}
|
||||
initialSeriesType={seriesType}
|
||||
onModalClose={handleAddSeriesModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewSeriesSearchResult;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
||||
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createExistingSeriesSelector(),
|
||||
createDimensionsSelector(),
|
||||
(isExistingSeries, dimensions) => {
|
||||
return {
|
||||
isExistingSeries,
|
||||
isSmallScreen: dimensions.isSmallScreen
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(AddNewSeriesSearchResult);
|
||||
43
frontend/src/AddSeries/AddNewSeries/useAddSeries.ts
Normal file
43
frontend/src/AddSeries/AddNewSeries/useAddSeries.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
7
frontend/src/AddSeries/AddSeries.ts
Normal file
7
frontend/src/AddSeries/AddSeries.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Series from 'Series/Series';
|
||||
|
||||
interface AddSeries extends Series {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
export default AddSeries;
|
||||
@@ -1,179 +0,0 @@
|
||||
import { reduce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import ImportSeriesFooterConnector from './ImportSeriesFooterConnector';
|
||||
import ImportSeriesTableConnector from './ImportSeriesTableConnector';
|
||||
|
||||
class ImportSeries extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.scrollerRef = React.createRef();
|
||||
|
||||
this.state = {
|
||||
allSelected: false,
|
||||
allUnselected: false,
|
||||
lastToggled: null,
|
||||
selectedState: {}
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
getSelectedIds = () => {
|
||||
return reduce(
|
||||
this.state.selectedState,
|
||||
(result, value, id) => {
|
||||
if (value) {
|
||||
result.push(id);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
onSelectAllChange = ({ value }) => {
|
||||
// Only select non-dupes
|
||||
this.setState(selectAll(this.state.selectedState, value));
|
||||
};
|
||||
|
||||
onSelectedChange = ({ id, value, shiftKey = false }) => {
|
||||
this.setState((state) => {
|
||||
return toggleSelected(state, this.props.items, id, value, shiftKey);
|
||||
});
|
||||
};
|
||||
|
||||
onRemoveSelectedStateItem = (id) => {
|
||||
this.setState((state) => {
|
||||
const selectedState = Object.assign({}, state.selectedState);
|
||||
delete selectedState[id];
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedState
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.onInputChange(this.getSelectedIds(), name, value);
|
||||
};
|
||||
|
||||
onImportPress = () => {
|
||||
this.props.onImportPress(this.getSelectedIds());
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
rootFolderId,
|
||||
path,
|
||||
rootFoldersFetching,
|
||||
rootFoldersPopulated,
|
||||
rootFoldersError,
|
||||
unmappedFolders
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
allSelected,
|
||||
allUnselected,
|
||||
selectedState
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<PageContent title={translate('ImportSeries')}>
|
||||
<PageContentBody ref={this.scrollerRef} >
|
||||
{
|
||||
rootFoldersFetching ? <LoadingIndicator /> : null
|
||||
}
|
||||
|
||||
{
|
||||
!rootFoldersFetching && !!rootFoldersError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('RootFoldersLoadError')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
rootFoldersPopulated &&
|
||||
!unmappedFolders.length ?
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
rootFoldersPopulated &&
|
||||
!!unmappedFolders.length &&
|
||||
this.scrollerRef.current ?
|
||||
<ImportSeriesTableConnector
|
||||
rootFolderId={rootFolderId}
|
||||
unmappedFolders={unmappedFolders}
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
selectedState={selectedState}
|
||||
scroller={this.scrollerRef.current}
|
||||
onSelectAllChange={this.onSelectAllChange}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
onRemoveSelectedStateItem={this.onRemoveSelectedStateItem}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</PageContentBody>
|
||||
|
||||
{
|
||||
!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
!!unmappedFolders.length ?
|
||||
<ImportSeriesFooterConnector
|
||||
selectedIds={this.getSelectedIds()}
|
||||
onInputChange={this.onInputChange}
|
||||
onImportPress={this.onImportPress}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeries.propTypes = {
|
||||
rootFolderId: PropTypes.number.isRequired,
|
||||
path: PropTypes.string,
|
||||
rootFoldersFetching: PropTypes.bool.isRequired,
|
||||
rootFoldersPopulated: PropTypes.bool.isRequired,
|
||||
rootFoldersError: PropTypes.object,
|
||||
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onImportPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ImportSeries.defaultProps = {
|
||||
unmappedFolders: []
|
||||
};
|
||||
|
||||
export default ImportSeries;
|
||||
127
frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx
Normal file
127
frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
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';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { clearImportSeries } from 'Store/Actions/importSeriesActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import ImportSeriesFooter from './ImportSeriesFooter';
|
||||
import ImportSeriesTable from './ImportSeriesTable';
|
||||
|
||||
function ImportSeries() {
|
||||
const dispatch = useDispatch();
|
||||
const { rootFolderId: rootFolderIdString } = useParams<{
|
||||
rootFolderId: string;
|
||||
}>();
|
||||
const rootFolderId = parseInt(rootFolderIdString);
|
||||
|
||||
const {
|
||||
isFetching: rootFoldersFetching,
|
||||
isPopulated: rootFoldersPopulated,
|
||||
error: rootFoldersError,
|
||||
items: rootFolders,
|
||||
} = useSelector((state: AppState) => state.rootFolders);
|
||||
|
||||
const { path, unmappedFolders } = useMemo(() => {
|
||||
const rootFolder = rootFolders.find((r) => r.id === rootFolderId);
|
||||
|
||||
return {
|
||||
path: rootFolder?.path ?? '',
|
||||
unmappedFolders:
|
||||
rootFolder?.unmappedFolders.map((unmappedFolders) => {
|
||||
return {
|
||||
...unmappedFolders,
|
||||
id: unmappedFolders.name,
|
||||
};
|
||||
}) ?? [],
|
||||
};
|
||||
}, [rootFolders, rootFolderId]);
|
||||
|
||||
const qualityProfiles = useSelector(
|
||||
(state: AppState) => state.settings.qualityProfiles.items
|
||||
);
|
||||
|
||||
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const items = useMemo(() => {
|
||||
return unmappedFolders.map((unmappedFolder) => {
|
||||
return {
|
||||
...unmappedFolder,
|
||||
id: unmappedFolder.name,
|
||||
};
|
||||
});
|
||||
}, [unmappedFolders]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchRootFolders({ id: rootFolderId, timeout: false }));
|
||||
|
||||
return () => {
|
||||
dispatch(clearImportSeries());
|
||||
};
|
||||
}, [rootFolderId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!defaultQualityProfileId ||
|
||||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
|
||||
) {
|
||||
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
|
||||
}
|
||||
}, [defaultQualityProfileId, qualityProfiles, dispatch]);
|
||||
|
||||
return (
|
||||
<SelectProvider items={items}>
|
||||
<PageContent title={translate('ImportSeries')}>
|
||||
<PageContentBody ref={scrollerRef}>
|
||||
{rootFoldersFetching ? <LoadingIndicator /> : null}
|
||||
|
||||
{!rootFoldersFetching && !!rootFoldersError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('RootFoldersLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
rootFoldersPopulated &&
|
||||
!unmappedFolders.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
rootFoldersPopulated &&
|
||||
!!unmappedFolders.length &&
|
||||
scrollerRef.current ? (
|
||||
<ImportSeriesTable
|
||||
unmappedFolders={unmappedFolders}
|
||||
scrollerRef={scrollerRef}
|
||||
/>
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
|
||||
{!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
!!unmappedFolders.length ? (
|
||||
<ImportSeriesFooter />
|
||||
) : null}
|
||||
</PageContent>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeries;
|
||||
@@ -1,153 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
|
||||
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import { clearImportSeries, importSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import ImportSeries from './ImportSeries';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { match }) => match,
|
||||
(state) => state.rootFolders,
|
||||
(state) => state.addSeries,
|
||||
(state) => state.importSeries,
|
||||
(state) => state.settings.qualityProfiles,
|
||||
(
|
||||
match,
|
||||
rootFolders,
|
||||
addSeries,
|
||||
importSeriesState,
|
||||
qualityProfiles
|
||||
) => {
|
||||
const {
|
||||
isFetching: rootFoldersFetching,
|
||||
isPopulated: rootFoldersPopulated,
|
||||
error: rootFoldersError,
|
||||
items
|
||||
} = rootFolders;
|
||||
|
||||
const rootFolderId = parseInt(match.params.rootFolderId);
|
||||
|
||||
const result = {
|
||||
rootFolderId,
|
||||
rootFoldersFetching,
|
||||
rootFoldersPopulated,
|
||||
rootFoldersError,
|
||||
qualityProfiles: qualityProfiles.items,
|
||||
defaultQualityProfileId: addSeries.defaults.qualityProfileId
|
||||
};
|
||||
|
||||
if (items.length) {
|
||||
const rootFolder = _.find(items, { id: rootFolderId });
|
||||
|
||||
return {
|
||||
...result,
|
||||
...rootFolder,
|
||||
items: importSeriesState.items
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchSetImportSeriesValue: setImportSeriesValue,
|
||||
dispatchImportSeries: importSeries,
|
||||
dispatchClearImportSeries: clearImportSeries,
|
||||
dispatchFetchRootFolders: fetchRootFolders,
|
||||
dispatchSetAddSeriesDefault: setAddSeriesDefault
|
||||
};
|
||||
|
||||
class ImportSeriesConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
rootFolderId,
|
||||
qualityProfiles,
|
||||
defaultQualityProfileId,
|
||||
dispatchFetchRootFolders,
|
||||
dispatchSetAddSeriesDefault
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchRootFolders({ id: rootFolderId, timeout: false });
|
||||
|
||||
let setDefaults = false;
|
||||
const setDefaultPayload = {};
|
||||
|
||||
if (
|
||||
!defaultQualityProfileId ||
|
||||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
|
||||
) {
|
||||
setDefaults = true;
|
||||
setDefaultPayload.qualityProfileId = qualityProfiles[0].id;
|
||||
}
|
||||
|
||||
if (setDefaults) {
|
||||
dispatchSetAddSeriesDefault(setDefaultPayload);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.dispatchClearImportSeries();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = (ids, name, value) => {
|
||||
this.props.dispatchSetAddSeriesDefault({ [name]: value });
|
||||
|
||||
ids.forEach((id) => {
|
||||
this.props.dispatchSetImportSeriesValue({
|
||||
id,
|
||||
[name]: value
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
onImportPress = (ids) => {
|
||||
this.props.dispatchImportSeries({ ids });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ImportSeries
|
||||
{...this.props}
|
||||
onInputChange={this.onInputChange}
|
||||
onImportPress={this.onImportPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const routeMatchShape = createRouteMatchShape({
|
||||
rootFolderId: PropTypes.string.isRequired
|
||||
});
|
||||
|
||||
ImportSeriesConnector.propTypes = {
|
||||
match: routeMatchShape.isRequired,
|
||||
rootFolderId: PropTypes.number.isRequired,
|
||||
rootFoldersFetching: PropTypes.bool.isRequired,
|
||||
rootFoldersPopulated: PropTypes.bool.isRequired,
|
||||
qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
defaultQualityProfileId: PropTypes.number.isRequired,
|
||||
dispatchSetImportSeriesValue: PropTypes.func.isRequired,
|
||||
dispatchImportSeries: PropTypes.func.isRequired,
|
||||
dispatchClearImportSeries: PropTypes.func.isRequired,
|
||||
dispatchFetchRootFolders: PropTypes.func.isRequired,
|
||||
dispatchSetAddSeriesDefault: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector);
|
||||
@@ -1,300 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContentFooter from 'Components/Page/PageContentFooter';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesFooter.css';
|
||||
|
||||
const MIXED = 'mixed';
|
||||
|
||||
class ImportSeriesFooter extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const {
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeasonFolder,
|
||||
defaultSeriesType
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
monitor: defaultMonitor,
|
||||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeriesType,
|
||||
defaultSeasonFolder,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
isSeasonFolderMixed
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
seasonFolder
|
||||
} = this.state;
|
||||
|
||||
const newState = {};
|
||||
|
||||
if (isMonitorMixed && monitor !== MIXED) {
|
||||
newState.monitor = MIXED;
|
||||
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
|
||||
newState.monitor = defaultMonitor;
|
||||
}
|
||||
|
||||
if (isQualityProfileIdMixed && qualityProfileId !== MIXED) {
|
||||
newState.qualityProfileId = MIXED;
|
||||
} else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) {
|
||||
newState.qualityProfileId = defaultQualityProfileId;
|
||||
}
|
||||
|
||||
if (isSeriesTypeMixed && seriesType !== MIXED) {
|
||||
newState.seriesType = MIXED;
|
||||
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
|
||||
newState.seriesType = defaultSeriesType;
|
||||
}
|
||||
|
||||
if (isSeasonFolderMixed && seasonFolder != null) {
|
||||
newState.seasonFolder = null;
|
||||
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
|
||||
newState.seasonFolder = defaultSeasonFolder;
|
||||
}
|
||||
|
||||
if (!_.isEmpty(newState)) {
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.setState({ [name]: value });
|
||||
this.props.onInputChange({ name, value });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedCount,
|
||||
isImporting,
|
||||
isLookingUpSeries,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
hasUnsearchedItems,
|
||||
importError,
|
||||
onImportPress,
|
||||
onLookupPress,
|
||||
onCancelLookupPress
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
seasonFolder
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<PageContentFooter>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>
|
||||
{translate('Monitor')}
|
||||
</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isMonitorMixed}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>
|
||||
{translate('QualityProfile')}
|
||||
</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isQualityProfileIdMixed}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>
|
||||
{translate('SeriesType')}
|
||||
</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
value={seriesType}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isSeriesTypeMixed}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>
|
||||
{translate('SeasonFolder')}
|
||||
</div>
|
||||
|
||||
<CheckInput
|
||||
name="seasonFolder"
|
||||
value={seasonFolder}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.label}>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.importButtonContainer}>
|
||||
<SpinnerButton
|
||||
className={styles.importButton}
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isImporting}
|
||||
isDisabled={!selectedCount || isLookingUpSeries}
|
||||
onPress={onImportPress}
|
||||
>
|
||||
{translate('ImportCountSeries', { selectedCount })}
|
||||
</SpinnerButton>
|
||||
|
||||
{
|
||||
isLookingUpSeries ?
|
||||
<Button
|
||||
className={styles.loadingButton}
|
||||
kind={kinds.WARNING}
|
||||
onPress={onCancelLookupPress}
|
||||
>
|
||||
{translate('CancelProcessing')}
|
||||
</Button> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
hasUnsearchedItems ?
|
||||
<Button
|
||||
className={styles.loadingButton}
|
||||
kind={kinds.SUCCESS}
|
||||
onPress={onLookupPress}
|
||||
>
|
||||
{translate('StartProcessing')}
|
||||
</Button> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isLookingUpSeries ?
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={24}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isLookingUpSeries ?
|
||||
translate('ProcessingFolders') :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
importError ?
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.importError}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
}
|
||||
title={translate('ImportErrors')}
|
||||
body={
|
||||
<ul>
|
||||
{
|
||||
Array.isArray(importError.responseJSON) ?
|
||||
importError.responseJSON.map((error, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
{error.errorMessage}
|
||||
</li>
|
||||
);
|
||||
}) :
|
||||
<li>
|
||||
{
|
||||
JSON.stringify(importError.responseJSON)
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PageContentFooter>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesFooter.propTypes = {
|
||||
selectedCount: PropTypes.number.isRequired,
|
||||
isImporting: PropTypes.bool.isRequired,
|
||||
isLookingUpSeries: PropTypes.bool.isRequired,
|
||||
defaultMonitor: PropTypes.string.isRequired,
|
||||
defaultQualityProfileId: PropTypes.number,
|
||||
defaultSeriesType: PropTypes.string.isRequired,
|
||||
defaultSeasonFolder: PropTypes.bool.isRequired,
|
||||
isMonitorMixed: PropTypes.bool.isRequired,
|
||||
isQualityProfileIdMixed: PropTypes.bool.isRequired,
|
||||
isSeriesTypeMixed: PropTypes.bool.isRequired,
|
||||
isSeasonFolderMixed: PropTypes.bool.isRequired,
|
||||
hasUnsearchedItems: PropTypes.bool.isRequired,
|
||||
importError: PropTypes.object,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onImportPress: PropTypes.func.isRequired,
|
||||
onLookupPress: PropTypes.func.isRequired,
|
||||
onCancelLookupPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesFooter;
|
||||
@@ -0,0 +1,314 @@
|
||||
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';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
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 {
|
||||
cancelLookupSeries,
|
||||
importSeries,
|
||||
lookupUnsearchedSeries,
|
||||
setImportSeriesValue,
|
||||
} from 'Store/Actions/importSeriesActions';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import styles from './ImportSeriesFooter.css';
|
||||
|
||||
type MixedType = 'mixed';
|
||||
|
||||
function ImportSeriesFooter() {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
monitor: defaultMonitor,
|
||||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder,
|
||||
} = useAddSeriesOptions();
|
||||
|
||||
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
|
||||
(state: AppState) => state.importSeries
|
||||
);
|
||||
|
||||
const [monitor, setMonitor] = useState<SeriesMonitor | MixedType>(
|
||||
defaultMonitor
|
||||
);
|
||||
const [qualityProfileId, setQualityProfileId] = useState<number | MixedType>(
|
||||
defaultQualityProfileId
|
||||
);
|
||||
const [seriesType, setSeriesType] = useState<SeriesType | MixedType>(
|
||||
defaultSeriesType
|
||||
);
|
||||
const [seasonFolder, setSeasonFolder] = useState<boolean | MixedType>(
|
||||
defaultSeasonFolder
|
||||
);
|
||||
|
||||
const [selectState] = useSelect();
|
||||
|
||||
const selectedIds = useMemo(() => {
|
||||
return getSelectedIds(selectState.selectedState, (id) => id);
|
||||
}, [selectState.selectedState]);
|
||||
|
||||
const {
|
||||
hasUnsearchedItems,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
isSeasonFolderMixed,
|
||||
} = useMemo(() => {
|
||||
let isMonitorMixed = false;
|
||||
let isQualityProfileIdMixed = false;
|
||||
let isSeriesTypeMixed = false;
|
||||
let isSeasonFolderMixed = false;
|
||||
let hasUnsearchedItems = false;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.monitor !== defaultMonitor) {
|
||||
isMonitorMixed = true;
|
||||
}
|
||||
|
||||
if (item.qualityProfileId !== defaultQualityProfileId) {
|
||||
isQualityProfileIdMixed = true;
|
||||
}
|
||||
|
||||
if (item.seriesType !== defaultSeriesType) {
|
||||
isSeriesTypeMixed = true;
|
||||
}
|
||||
|
||||
if (item.seasonFolder !== defaultSeasonFolder) {
|
||||
isSeasonFolderMixed = true;
|
||||
}
|
||||
|
||||
if (!item.isPopulated) {
|
||||
hasUnsearchedItems = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hasUnsearchedItems: !isLookingUpSeries && hasUnsearchedItems,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
isSeasonFolderMixed,
|
||||
};
|
||||
}, [
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeasonFolder,
|
||||
defaultSeriesType,
|
||||
items,
|
||||
isLookingUpSeries,
|
||||
]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
||||
if (name === 'monitor') {
|
||||
setMonitor(value as SeriesMonitor);
|
||||
} else if (name === 'qualityProfileId') {
|
||||
setQualityProfileId(value as number);
|
||||
} else if (name === 'seriesType') {
|
||||
setSeriesType(value as SeriesType);
|
||||
} else if (name === 'seasonFolder') {
|
||||
setSeasonFolder(value as boolean);
|
||||
}
|
||||
|
||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setImportSeriesValue({
|
||||
id,
|
||||
[name]: value,
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
[selectedIds, dispatch]
|
||||
);
|
||||
|
||||
const handleLookupPress = useCallback(() => {
|
||||
dispatch(lookupUnsearchedSeries());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleCancelLookupPress = useCallback(() => {
|
||||
dispatch(cancelLookupSeries());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleImportPress = useCallback(() => {
|
||||
dispatch(importSeries({ ids: selectedIds }));
|
||||
}, [selectedIds, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMonitorMixed && monitor !== 'mixed') {
|
||||
setMonitor('mixed');
|
||||
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
|
||||
setMonitor(defaultMonitor);
|
||||
}
|
||||
}, [defaultMonitor, isMonitorMixed, monitor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isQualityProfileIdMixed && qualityProfileId !== 'mixed') {
|
||||
setQualityProfileId('mixed');
|
||||
} else if (
|
||||
!isQualityProfileIdMixed &&
|
||||
qualityProfileId !== defaultQualityProfileId
|
||||
) {
|
||||
setQualityProfileId(defaultQualityProfileId);
|
||||
}
|
||||
}, [defaultQualityProfileId, isQualityProfileIdMixed, qualityProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSeriesTypeMixed && seriesType !== 'mixed') {
|
||||
setSeriesType('mixed');
|
||||
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
|
||||
setSeriesType(defaultSeriesType);
|
||||
}
|
||||
}, [defaultSeriesType, isSeriesTypeMixed, seriesType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSeasonFolderMixed && seasonFolder !== 'mixed') {
|
||||
setSeasonFolder('mixed');
|
||||
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
|
||||
setSeasonFolder(defaultSeasonFolder);
|
||||
}
|
||||
}, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]);
|
||||
|
||||
const selectedCount = selectedIds.length;
|
||||
|
||||
return (
|
||||
<PageContentFooter>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>{translate('Monitor')}</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isMonitorMixed}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>{translate('QualityProfile')}</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isQualityProfileIdMixed}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>{translate('SeriesType')}</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
value={seriesType}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isSeriesTypeMixed}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>{translate('SeasonFolder')}</div>
|
||||
|
||||
<CheckInput
|
||||
name="seasonFolder"
|
||||
value={seasonFolder}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.label}> </div>
|
||||
|
||||
<div className={styles.importButtonContainer}>
|
||||
<SpinnerButton
|
||||
className={styles.importButton}
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isImporting}
|
||||
isDisabled={!selectedCount || isLookingUpSeries}
|
||||
onPress={handleImportPress}
|
||||
>
|
||||
{translate('ImportCountSeries', { selectedCount })}
|
||||
</SpinnerButton>
|
||||
|
||||
{isLookingUpSeries ? (
|
||||
<Button
|
||||
className={styles.loadingButton}
|
||||
kind={kinds.WARNING}
|
||||
onPress={handleCancelLookupPress}
|
||||
>
|
||||
{translate('CancelProcessing')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{hasUnsearchedItems ? (
|
||||
<Button
|
||||
className={styles.loadingButton}
|
||||
kind={kinds.SUCCESS}
|
||||
onPress={handleLookupPress}
|
||||
>
|
||||
{translate('StartProcessing')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isLookingUpSeries ? (
|
||||
<LoadingIndicator className={styles.loading} size={24} />
|
||||
) : null}
|
||||
|
||||
{isLookingUpSeries ? translate('ProcessingFolders') : null}
|
||||
|
||||
{importError ? (
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.importError}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
}
|
||||
title={translate('ImportErrors')}
|
||||
body={
|
||||
<ul>
|
||||
{Array.isArray(importError.responseJSON) ? (
|
||||
importError.responseJSON.map((error, index) => {
|
||||
return <li key={index}>{error.errorMessage}</li>;
|
||||
})
|
||||
) : (
|
||||
<li>{JSON.stringify(importError.responseJSON)}</li>
|
||||
)}
|
||||
</ul>
|
||||
}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PageContentFooter>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesFooter;
|
||||
@@ -1,63 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { cancelLookupSeries, lookupUnsearchedSeries } from 'Store/Actions/importSeriesActions';
|
||||
import ImportSeriesFooter from './ImportSeriesFooter';
|
||||
|
||||
function isMixed(items, selectedIds, defaultValue, key) {
|
||||
return _.some(items, (series) => {
|
||||
return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue;
|
||||
});
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.addSeries,
|
||||
(state) => state.importSeries,
|
||||
(state, { selectedIds }) => selectedIds,
|
||||
(addSeries, importSeries, selectedIds) => {
|
||||
const {
|
||||
monitor: defaultMonitor,
|
||||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder
|
||||
} = addSeries.defaults;
|
||||
|
||||
const {
|
||||
isLookingUpSeries,
|
||||
isImporting,
|
||||
items,
|
||||
importError
|
||||
} = importSeries;
|
||||
|
||||
const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
|
||||
const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
|
||||
const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType');
|
||||
const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder');
|
||||
const hasUnsearchedItems = !isLookingUpSeries && items.some((item) => !item.isPopulated);
|
||||
|
||||
return {
|
||||
selectedCount: selectedIds.length,
|
||||
isLookingUpSeries,
|
||||
isImporting,
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeriesType,
|
||||
defaultSeasonFolder,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
isSeasonFolderMixed,
|
||||
importError,
|
||||
hasUnsearchedItems
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
onLookupPress: lookupUnsearchedSeries,
|
||||
onCancelLookupPress: cancelLookupSeries
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesFooter);
|
||||
@@ -1,4 +1,3 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||
@@ -8,16 +7,21 @@ import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
|
||||
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesHeader.css';
|
||||
|
||||
function ImportSeriesHeader(props) {
|
||||
const {
|
||||
allSelected,
|
||||
allUnselected,
|
||||
onSelectAllChange
|
||||
} = props;
|
||||
interface ImportSeriesHeaderProps {
|
||||
allSelected: boolean;
|
||||
allUnselected: boolean;
|
||||
onSelectAllChange: (change: CheckInputChanged) => void;
|
||||
}
|
||||
|
||||
function ImportSeriesHeader({
|
||||
allSelected,
|
||||
allUnselected,
|
||||
onSelectAllChange,
|
||||
}: ImportSeriesHeaderProps) {
|
||||
return (
|
||||
<VirtualTableHeader>
|
||||
<VirtualTableSelectAllHeaderCell
|
||||
@@ -26,26 +30,15 @@ function ImportSeriesHeader(props) {
|
||||
onSelectAllChange={onSelectAllChange}
|
||||
/>
|
||||
|
||||
<VirtualTableHeaderCell
|
||||
className={styles.folder}
|
||||
name="folder"
|
||||
>
|
||||
<VirtualTableHeaderCell className={styles.folder} name="folder">
|
||||
{translate('Folder')}
|
||||
</VirtualTableHeaderCell>
|
||||
|
||||
<VirtualTableHeaderCell
|
||||
className={styles.monitor}
|
||||
name="monitor"
|
||||
>
|
||||
<VirtualTableHeaderCell className={styles.monitor} name="monitor">
|
||||
{translate('Monitor')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.detailsIcon}
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
anchor={<Icon className={styles.detailsIcon} name={icons.INFO} />}
|
||||
title={translate('MonitoringOptions')}
|
||||
body={<SeriesMonitoringOptionsPopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
@@ -59,19 +52,11 @@ function ImportSeriesHeader(props) {
|
||||
{translate('QualityProfile')}
|
||||
</VirtualTableHeaderCell>
|
||||
|
||||
<VirtualTableHeaderCell
|
||||
className={styles.seriesType}
|
||||
name="seriesType"
|
||||
>
|
||||
<VirtualTableHeaderCell className={styles.seriesType} name="seriesType">
|
||||
{translate('SeriesType')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.detailsIcon}
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
anchor={<Icon className={styles.detailsIcon} name={icons.INFO} />}
|
||||
title={translate('SeriesType')}
|
||||
body={<SeriesTypePopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
@@ -85,20 +70,11 @@ function ImportSeriesHeader(props) {
|
||||
{translate('SeasonFolder')}
|
||||
</VirtualTableHeaderCell>
|
||||
|
||||
<VirtualTableHeaderCell
|
||||
className={styles.series}
|
||||
name="series"
|
||||
>
|
||||
<VirtualTableHeaderCell className={styles.series} name="series">
|
||||
{translate('Series')}
|
||||
</VirtualTableHeaderCell>
|
||||
</VirtualTableHeader>
|
||||
);
|
||||
}
|
||||
|
||||
ImportSeriesHeader.propTypes = {
|
||||
allSelected: PropTypes.bool.isRequired,
|
||||
allUnselected: PropTypes.bool.isRequired,
|
||||
onSelectAllChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesHeader;
|
||||
@@ -1,105 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector';
|
||||
import styles from './ImportSeriesRow.css';
|
||||
|
||||
function ImportSeriesRow(props) {
|
||||
const {
|
||||
id,
|
||||
relativePath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seasonFolder,
|
||||
seriesType,
|
||||
selectedSeries,
|
||||
isExistingSeries,
|
||||
isSelected,
|
||||
onSelectedChange,
|
||||
onInputChange
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualTableSelectCell
|
||||
inputClassName={styles.selectInput}
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
isDisabled={!selectedSeries || isExistingSeries}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>
|
||||
|
||||
<VirtualTableRowCell className={styles.folder}>
|
||||
{relativePath}
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.monitor}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.qualityProfile}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.seriesType}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
value={seriesType}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.seasonFolder}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
value={seasonFolder}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.series}>
|
||||
<ImportSeriesSelectSeriesConnector
|
||||
id={id}
|
||||
isExistingSeries={isExistingSeries}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ImportSeriesRow.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
relativePath: PropTypes.string.isRequired,
|
||||
monitor: PropTypes.string.isRequired,
|
||||
qualityProfileId: PropTypes.number.isRequired,
|
||||
seriesType: PropTypes.string.isRequired,
|
||||
seasonFolder: PropTypes.bool.isRequired,
|
||||
selectedSeries: PropTypes.object,
|
||||
isExistingSeries: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
onSelectedChange: PropTypes.func.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ImportSeriesRow.defaultsProps = {
|
||||
items: []
|
||||
};
|
||||
|
||||
export default ImportSeriesRow;
|
||||
135
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx
Normal file
135
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { ImportSeries } from 'App/State/ImportSeriesAppState';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries';
|
||||
import styles from './ImportSeriesRow.css';
|
||||
|
||||
function createItemSelector(id: string) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.importSeries.items,
|
||||
(items) => {
|
||||
return (
|
||||
items.find((item) => {
|
||||
return item.id === id;
|
||||
}) || ({} as ImportSeries)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface ImportSeriesRowProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function ImportSeriesRow({ id }: ImportSeriesRowProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
relativePath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seasonFolder,
|
||||
seriesType,
|
||||
selectedSeries,
|
||||
} = useSelector(createItemSelector(id));
|
||||
|
||||
const isExistingSeries = useSelector(
|
||||
createExistingSeriesSelector(selectedSeries?.tvdbId)
|
||||
);
|
||||
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setImportSeriesValue({
|
||||
id,
|
||||
[name]: value,
|
||||
})
|
||||
);
|
||||
},
|
||||
[id, dispatch]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey }: SelectStateInputProps) => {
|
||||
selectDispatch({
|
||||
type: 'toggleSelected',
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[selectDispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualTableSelectCell
|
||||
inputClassName={styles.selectInput}
|
||||
id={id}
|
||||
isSelected={selectState.selectedState[id]}
|
||||
isDisabled={!selectedSeries || isExistingSeries}
|
||||
onSelectedChange={handleSelectedChange}
|
||||
/>
|
||||
|
||||
<VirtualTableRowCell className={styles.folder}>
|
||||
{relativePath}
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.monitor}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.qualityProfile}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.seriesType}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
value={seriesType}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.seasonFolder}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
value={seasonFolder}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.series}>
|
||||
<ImportSeriesSelectSeries id={id} onInputChange={handleInputChange} />
|
||||
</VirtualTableRowCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesRow;
|
||||
@@ -1,89 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import ImportSeriesRow from './ImportSeriesRow';
|
||||
|
||||
function createImportSeriesItemSelector() {
|
||||
return createSelector(
|
||||
(state, { id }) => id,
|
||||
(state) => state.importSeries.items,
|
||||
(id, items) => {
|
||||
return _.find(items, { id }) || {};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createImportSeriesItemSelector(),
|
||||
createAllSeriesSelector(),
|
||||
(item, series) => {
|
||||
const selectedSeries = item && item.selectedSeries;
|
||||
const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId });
|
||||
|
||||
return {
|
||||
...item,
|
||||
isExistingSeries
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setImportSeriesValue
|
||||
};
|
||||
|
||||
class ImportSeriesRowConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setImportSeriesValue({
|
||||
id: this.props.id,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
// Don't show the row until we have the information we require for it.
|
||||
|
||||
const {
|
||||
items,
|
||||
monitor,
|
||||
seriesType,
|
||||
seasonFolder
|
||||
} = this.props;
|
||||
|
||||
if (!items || !monitor || !seriesType || !seasonFolder == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImportSeriesRow
|
||||
{...this.props}
|
||||
onInputChange={this.onInputChange}
|
||||
onSeriesSelect={this.onSeriesSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesRowConnector.propTypes = {
|
||||
rootFolderId: PropTypes.number.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
monitor: PropTypes.string,
|
||||
seriesType: PropTypes.string,
|
||||
seasonFolder: PropTypes.bool,
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
setImportSeriesValue: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector);
|
||||
@@ -0,0 +1,7 @@
|
||||
.row {
|
||||
transition: background-color 500ms;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--tableRowHoverBackgroundColor);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'dragLayer': string;
|
||||
'row': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -1,188 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import VirtualTable from 'Components/Table/VirtualTable';
|
||||
import VirtualTableRow from 'Components/Table/VirtualTableRow';
|
||||
import ImportSeriesHeader from './ImportSeriesHeader';
|
||||
import ImportSeriesRowConnector from './ImportSeriesRowConnector';
|
||||
|
||||
class ImportSeriesTable extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
unmappedFolders,
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeriesType,
|
||||
defaultSeasonFolder,
|
||||
onSeriesLookup,
|
||||
onSetImportSeriesValue
|
||||
} = this.props;
|
||||
|
||||
const values = {
|
||||
monitor: defaultMonitor,
|
||||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder
|
||||
};
|
||||
|
||||
unmappedFolders.forEach((unmappedFolder) => {
|
||||
const id = unmappedFolder.name;
|
||||
|
||||
onSeriesLookup(id, unmappedFolder.path, unmappedFolder.relativePath);
|
||||
|
||||
onSetImportSeriesValue({
|
||||
id,
|
||||
...values
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// This isn't great, but it's the most reliable way to ensure the items
|
||||
// are checked off even if they aren't actually visible since the cells
|
||||
// are virtualized.
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
items,
|
||||
selectedState,
|
||||
onSelectedChange,
|
||||
onRemoveSelectedStateItem
|
||||
} = this.props;
|
||||
|
||||
prevProps.items.forEach((prevItem) => {
|
||||
const {
|
||||
id
|
||||
} = prevItem;
|
||||
|
||||
const item = _.find(items, { id });
|
||||
|
||||
if (!item) {
|
||||
onRemoveSelectedStateItem(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedSeries = item.selectedSeries;
|
||||
const isSelected = selectedState[id];
|
||||
|
||||
const isExistingSeries = !!selectedSeries &&
|
||||
_.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId });
|
||||
|
||||
// Props doesn't have a selected series or
|
||||
// the selected series is an existing series.
|
||||
if ((!selectedSeries && prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) {
|
||||
onSelectedChange({ id, value: false });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// State is selected, but a series isn't selected or
|
||||
// the selected series is an existing series.
|
||||
if (isSelected && (!selectedSeries || isExistingSeries)) {
|
||||
onSelectedChange({ id, value: false });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// A series is being selected that wasn't previously selected.
|
||||
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
|
||||
onSelectedChange({ id, value: true });
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
rowRenderer = ({ key, rowIndex, style }) => {
|
||||
const {
|
||||
rootFolderId,
|
||||
items,
|
||||
selectedState,
|
||||
onSelectedChange
|
||||
} = this.props;
|
||||
|
||||
const item = items[rowIndex];
|
||||
|
||||
return (
|
||||
<VirtualTableRow
|
||||
key={key}
|
||||
style={style}
|
||||
>
|
||||
<ImportSeriesRowConnector
|
||||
key={item.id}
|
||||
rootFolderId={rootFolderId}
|
||||
isSelected={selectedState[item.id]}
|
||||
onSelectedChange={onSelectedChange}
|
||||
id={item.id}
|
||||
/>
|
||||
</VirtualTableRow>
|
||||
);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
items,
|
||||
allSelected,
|
||||
allUnselected,
|
||||
isSmallScreen,
|
||||
scroller,
|
||||
selectedState,
|
||||
onSelectAllChange
|
||||
} = this.props;
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualTable
|
||||
items={items}
|
||||
scroller={scroller}
|
||||
isSmallScreen={isSmallScreen}
|
||||
rowHeight={52}
|
||||
overscanRowCount={2}
|
||||
rowRenderer={this.rowRenderer}
|
||||
header={
|
||||
<ImportSeriesHeader
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
onSelectAllChange={onSelectAllChange}
|
||||
/>
|
||||
}
|
||||
selectedState={selectedState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesTable.propTypes = {
|
||||
rootFolderId: PropTypes.number.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
|
||||
defaultMonitor: PropTypes.string.isRequired,
|
||||
defaultQualityProfileId: PropTypes.number,
|
||||
defaultSeriesType: PropTypes.string.isRequired,
|
||||
defaultSeasonFolder: PropTypes.bool.isRequired,
|
||||
allSelected: PropTypes.bool.isRequired,
|
||||
allUnselected: PropTypes.bool.isRequired,
|
||||
selectedState: PropTypes.object.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
allSeries: PropTypes.arrayOf(PropTypes.object),
|
||||
scroller: PropTypes.instanceOf(Element).isRequired,
|
||||
onSelectAllChange: PropTypes.func.isRequired,
|
||||
onSelectedChange: PropTypes.func.isRequired,
|
||||
onRemoveSelectedStateItem: PropTypes.func.isRequired,
|
||||
onSeriesLookup: PropTypes.func.isRequired,
|
||||
onSetImportSeriesValue: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesTable;
|
||||
209
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx
Normal file
209
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
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';
|
||||
import VirtualTable from 'Components/Table/VirtualTable';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import {
|
||||
queueLookupSeries,
|
||||
setImportSeriesValue,
|
||||
} from 'Store/Actions/importSeriesActions';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import { UnmappedFolder } from 'typings/RootFolder';
|
||||
import ImportSeriesHeader from './ImportSeriesHeader';
|
||||
import ImportSeriesRow from './ImportSeriesRow';
|
||||
import styles from './ImportSeriesTable.css';
|
||||
|
||||
const ROW_HEIGHT = 52;
|
||||
|
||||
interface RowItemData {
|
||||
items: ImportSeries[];
|
||||
}
|
||||
|
||||
interface ImportSeriesTableProps {
|
||||
unmappedFolders: UnmappedFolder[];
|
||||
scrollerRef: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
|
||||
const { items } = data;
|
||||
|
||||
if (index >= items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = items[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
...style,
|
||||
}}
|
||||
className={styles.row}
|
||||
>
|
||||
<ImportSeriesRow key={item.id} id={item.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportSeriesTable({
|
||||
unmappedFolders,
|
||||
scrollerRef,
|
||||
}: ImportSeriesTableProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { monitor, qualityProfileId, seriesType, seasonFolder } =
|
||||
useAddSeriesOptions();
|
||||
|
||||
const items = useSelector((state: AppState) => state.importSeries.items);
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const allSeries = useSelector(createAllSeriesSelector());
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
|
||||
const defaultValues = useRef({
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
seasonFolder,
|
||||
});
|
||||
|
||||
const listRef = useRef<FixedSizeList<RowItemData>>(null);
|
||||
const initialUnmappedFolders = useRef(unmappedFolders);
|
||||
const previousItems = usePrevious(items);
|
||||
const { allSelected, allUnselected, selectedState } = selectState;
|
||||
|
||||
const handleSelectAllChange = useCallback(
|
||||
({ value }: CheckInputChanged) => {
|
||||
selectDispatch({
|
||||
type: value ? 'selectAll' : 'unselectAll',
|
||||
});
|
||||
},
|
||||
[selectDispatch]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey }: SelectStateInputProps) => {
|
||||
selectDispatch({
|
||||
type: 'toggleSelected',
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[selectDispatch]
|
||||
);
|
||||
|
||||
const handleRemoveSelectedStateItem = useCallback(
|
||||
(id: string) => {
|
||||
selectDispatch({
|
||||
type: 'removeItem',
|
||||
id,
|
||||
});
|
||||
},
|
||||
[selectDispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
initialUnmappedFolders.current.forEach(({ name, path, relativePath }) => {
|
||||
dispatch(
|
||||
queueLookupSeries({
|
||||
name,
|
||||
path,
|
||||
relativePath,
|
||||
term: name,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setImportSeriesValue({
|
||||
id: name,
|
||||
...defaultValues.current,
|
||||
})
|
||||
);
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
previousItems?.forEach((prevItem) => {
|
||||
const { id } = prevItem;
|
||||
|
||||
const item = items.find((i) => i.id === id);
|
||||
|
||||
if (!item) {
|
||||
handleRemoveSelectedStateItem(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedSeries = item.selectedSeries;
|
||||
const isSelected = selectedState[id];
|
||||
|
||||
const isExistingSeries =
|
||||
!!selectedSeries &&
|
||||
allSeries.some((s) => s.tvdbId === selectedSeries.tvdbId);
|
||||
|
||||
if (
|
||||
(!selectedSeries && prevItem.selectedSeries) ||
|
||||
(isExistingSeries && !prevItem.selectedSeries)
|
||||
) {
|
||||
handleSelectedChange({ id, value: false, shiftKey: false });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelected && (!selectedSeries || isExistingSeries)) {
|
||||
handleSelectedChange({ id, value: false, shiftKey: false });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
|
||||
handleSelectedChange({ id, value: true, shiftKey: false });
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
}, [
|
||||
allSeries,
|
||||
items,
|
||||
previousItems,
|
||||
selectedState,
|
||||
handleRemoveSelectedStateItem,
|
||||
handleSelectedChange,
|
||||
]);
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualTable
|
||||
Header={
|
||||
<ImportSeriesHeader
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
onSelectAllChange={handleSelectAllChange}
|
||||
/>
|
||||
}
|
||||
itemCount={items.length}
|
||||
itemData={{
|
||||
items,
|
||||
}}
|
||||
isSmallScreen={isSmallScreen}
|
||||
listRef={listRef}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
Row={Row}
|
||||
scrollerRef={scrollerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesTable;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import ImportSeriesTable from './ImportSeriesTable';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.addSeries,
|
||||
(state) => state.importSeries,
|
||||
(state) => state.app.dimensions,
|
||||
createAllSeriesSelector(),
|
||||
(addSeries, importSeries, dimensions, allSeries) => {
|
||||
return {
|
||||
defaultMonitor: addSeries.defaults.monitor,
|
||||
defaultQualityProfileId: addSeries.defaults.qualityProfileId,
|
||||
defaultSeriesType: addSeries.defaults.seriesType,
|
||||
defaultSeasonFolder: addSeries.defaults.seasonFolder,
|
||||
items: importSeries.items,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
allSeries
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onSeriesLookup(name, path, relativePath) {
|
||||
dispatch(queueLookupSeries({
|
||||
name,
|
||||
path,
|
||||
relativePath,
|
||||
term: name
|
||||
}));
|
||||
},
|
||||
|
||||
onSetImportSeriesValue(values) {
|
||||
dispatch(setImportSeriesValue(values));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable);
|
||||
@@ -1,29 +1,36 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||
import styles from './ImportSeriesSearchResult.css';
|
||||
|
||||
function ImportSeriesSearchResult(props) {
|
||||
const {
|
||||
tvdbId,
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
isExistingSeries,
|
||||
onPress
|
||||
} = props;
|
||||
interface ImportSeriesSearchResultProps {
|
||||
tvdbId: number;
|
||||
title: string;
|
||||
year: number;
|
||||
network?: string;
|
||||
onPress: (tvdbId: number) => void;
|
||||
}
|
||||
|
||||
const onPressCallback = useCallback(() => onPress(tvdbId), [tvdbId, onPress]);
|
||||
function ImportSeriesSearchResult({
|
||||
tvdbId,
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
onPress,
|
||||
}: ImportSeriesSearchResultProps) {
|
||||
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(tvdbId);
|
||||
}, [tvdbId, onPress]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Link
|
||||
className={styles.series}
|
||||
onPress={onPressCallback}
|
||||
>
|
||||
<Link className={styles.series} onPress={handlePress}>
|
||||
<ImportSeriesTitle
|
||||
title={title}
|
||||
year={year}
|
||||
@@ -46,13 +53,4 @@ function ImportSeriesSearchResult(props) {
|
||||
);
|
||||
}
|
||||
|
||||
ImportSeriesSearchResult.propTypes = {
|
||||
tvdbId: PropTypes.number.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
network: PropTypes.string,
|
||||
isExistingSeries: PropTypes.bool.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesSearchResult;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
||||
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createExistingSeriesSelector(),
|
||||
(isExistingSeries) => {
|
||||
return {
|
||||
isExistingSeries
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(ImportSeriesSearchResult);
|
||||
@@ -1,303 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
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 getUniqueElementId from 'Utilities/getUniqueElementId';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector';
|
||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||
import styles from './ImportSeriesSelectSeries.css';
|
||||
|
||||
class ImportSeriesSelectSeries extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._seriesLookupTimeout = null;
|
||||
this._scheduleUpdate = null;
|
||||
this._buttonId = getUniqueElementId();
|
||||
this._contentId = getUniqueElementId();
|
||||
|
||||
this.state = {
|
||||
term: props.id,
|
||||
isOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this._scheduleUpdate) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_addListener() {
|
||||
window.addEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
_removeListener() {
|
||||
window.removeEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onWindowClick = (event) => {
|
||||
const button = document.getElementById(this._buttonId);
|
||||
const content = document.getElementById(this._contentId);
|
||||
|
||||
if (!button || !content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!button.contains(event.target) &&
|
||||
!content.contains(event.target) &&
|
||||
this.state.isOpen
|
||||
) {
|
||||
this.setState({ isOpen: false });
|
||||
this._removeListener();
|
||||
}
|
||||
};
|
||||
|
||||
onPress = () => {
|
||||
if (this.state.isOpen) {
|
||||
this._removeListener();
|
||||
} else {
|
||||
this._addListener();
|
||||
}
|
||||
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
};
|
||||
|
||||
onSearchInputChange = ({ value }) => {
|
||||
if (this._seriesLookupTimeout) {
|
||||
clearTimeout(this._seriesLookupTimeout);
|
||||
}
|
||||
|
||||
this.setState({ term: value }, () => {
|
||||
this._seriesLookupTimeout = setTimeout(() => {
|
||||
this.props.onSearchInputChange(value);
|
||||
}, 200);
|
||||
});
|
||||
};
|
||||
|
||||
onRefreshPress = () => {
|
||||
this.props.onSearchInputChange(this.state.term);
|
||||
};
|
||||
|
||||
onSeriesSelect = (tvdbId) => {
|
||||
this.setState({ isOpen: false });
|
||||
|
||||
this.props.onSeriesSelect(tvdbId);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedSeries,
|
||||
isExistingSeries,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
isQueued,
|
||||
isLookingUpSeries
|
||||
} = this.props;
|
||||
|
||||
const errorMessage = error &&
|
||||
error.responseJSON &&
|
||||
error.responseJSON.message;
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._buttonId}
|
||||
>
|
||||
<Link
|
||||
ref={ref}
|
||||
className={styles.button}
|
||||
component="div"
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{
|
||||
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 ?
|
||||
<ImportSeriesTitle
|
||||
title={selectedSeries.title}
|
||||
year={selectedSeries.year}
|
||||
network={selectedSeries.network}
|
||||
isExistingSeries={isExistingSeries}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !selectedSeries ?
|
||||
<div className={styles.noMatches}>
|
||||
<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>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom"
|
||||
modifiers={{
|
||||
preventOverflow: {
|
||||
boundariesElement: 'viewport'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._contentId}
|
||||
className={styles.contentContainer}
|
||||
style={style}
|
||||
>
|
||||
{
|
||||
this.state.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={this.state.term}
|
||||
onChange={this.onSearchInputChange}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
kind={kinds.DEFAULT}
|
||||
spinnerIcon={icons.REFRESH}
|
||||
canSpin={true}
|
||||
isSpinning={isFetching}
|
||||
onPress={this.onRefreshPress}
|
||||
>
|
||||
<Icon name={icons.REFRESH} />
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
<div className={styles.results}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<ImportSeriesSearchResultConnector
|
||||
key={item.tvdbId}
|
||||
tvdbId={item.tvdbId}
|
||||
title={item.title}
|
||||
year={item.year}
|
||||
network={item.network}
|
||||
onPress={this.onSeriesSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesSelectSeries.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
selectedSeries: PropTypes.object,
|
||||
isExistingSeries: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isQueued: PropTypes.bool.isRequired,
|
||||
isLookingUpSeries: PropTypes.bool.isRequired,
|
||||
onSearchInputChange: PropTypes.func.isRequired,
|
||||
onSeriesSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ImportSeriesSelectSeries.defaultProps = {
|
||||
isFetching: true,
|
||||
isPopulated: false,
|
||||
items: [],
|
||||
isQueued: true
|
||||
};
|
||||
|
||||
export default ImportSeriesSelectSeries;
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingPortal,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
queueLookupSeries,
|
||||
setImportSeriesValue,
|
||||
} from 'Store/Actions/importSeriesActions';
|
||||
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
|
||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||
import styles from './ImportSeriesSelectSeries.css';
|
||||
|
||||
interface ImportSeriesSelectSeriesProps {
|
||||
id: string;
|
||||
onInputChange: (input: InputChanged) => void;
|
||||
}
|
||||
|
||||
function ImportSeriesSelectSeries({
|
||||
id,
|
||||
onInputChange,
|
||||
}: ImportSeriesSelectSeriesProps) {
|
||||
const dispatch = useDispatch();
|
||||
const isLookingUpSeries = useSelector(
|
||||
(state: AppState) => state.importSeries.isLookingUpSeries
|
||||
);
|
||||
|
||||
const {
|
||||
error,
|
||||
isFetching = true,
|
||||
isPopulated = false,
|
||||
items = [],
|
||||
isQueued = true,
|
||||
selectedSeries,
|
||||
isExistingSeries,
|
||||
term: itemTerm,
|
||||
// @ts-expect-error - ignoring this for now
|
||||
} = useSelector(createImportSeriesItemSelector(id, { id }));
|
||||
|
||||
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const [term, setTerm] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
}, []);
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
if (seriesLookupTimeout.current) {
|
||||
clearTimeout(seriesLookupTimeout.current);
|
||||
}
|
||||
|
||||
setTerm(value);
|
||||
|
||||
seriesLookupTimeout.current = setTimeout(() => {
|
||||
dispatch(
|
||||
queueLookupSeries({
|
||||
name: id,
|
||||
term: value,
|
||||
topOfQueue: true,
|
||||
})
|
||||
);
|
||||
}, 200);
|
||||
},
|
||||
[id, dispatch]
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
dispatch(
|
||||
queueLookupSeries({
|
||||
name: id,
|
||||
term,
|
||||
topOfQueue: true,
|
||||
})
|
||||
);
|
||||
}, [id, term, dispatch]);
|
||||
|
||||
const handleSeriesSelect = useCallback(
|
||||
(tvdbId: number) => {
|
||||
setIsOpen(false);
|
||||
|
||||
const selectedSeries = items.find((item) => item.tvdbId === tvdbId)!;
|
||||
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setImportSeriesValue({
|
||||
id,
|
||||
selectedSeries,
|
||||
})
|
||||
);
|
||||
|
||||
if (selectedSeries.seriesType !== 'standard') {
|
||||
onInputChange({
|
||||
name: 'seriesType',
|
||||
value: selectedSeries.seriesType,
|
||||
});
|
||||
}
|
||||
},
|
||||
[id, items, dispatch, onInputChange]
|
||||
);
|
||||
|
||||
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}
|
||||
|
||||
{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 ? (
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</FloatingPortal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesSelectSeries;
|
||||
@@ -1,87 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
|
||||
import * as seriesTypes from 'Utilities/Series/seriesTypes';
|
||||
import ImportSeriesSelectSeries from './ImportSeriesSelectSeries';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.importSeries.isLookingUpSeries,
|
||||
createImportSeriesItemSelector(),
|
||||
(isLookingUpSeries, item) => {
|
||||
return {
|
||||
isLookingUpSeries,
|
||||
...item
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
queueLookupSeries,
|
||||
setImportSeriesValue
|
||||
};
|
||||
|
||||
class ImportSeriesSelectSeriesConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSearchInputChange = (term) => {
|
||||
this.props.queueLookupSeries({
|
||||
name: this.props.id,
|
||||
term,
|
||||
topOfQueue: true
|
||||
});
|
||||
};
|
||||
|
||||
onSeriesSelect = (tvdbId) => {
|
||||
const {
|
||||
id,
|
||||
items,
|
||||
onInputChange
|
||||
} = this.props;
|
||||
|
||||
const selectedSeries = items.find((item) => item.tvdbId === tvdbId);
|
||||
|
||||
this.props.setImportSeriesValue({
|
||||
id,
|
||||
selectedSeries
|
||||
});
|
||||
|
||||
if (selectedSeries.seriesType !== seriesTypes.STANDARD) {
|
||||
onInputChange({
|
||||
name: 'seriesType',
|
||||
value: selectedSeries.seriesType
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ImportSeriesSelectSeries
|
||||
{...this.props}
|
||||
onSearchInputChange={this.onSearchInputChange}
|
||||
onSeriesSelect={this.onSeriesSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesSelectSeriesConnector.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
selectedSeries: PropTypes.object,
|
||||
isSelected: PropTypes.bool,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
queueLookupSeries: PropTypes.func.isRequired,
|
||||
setImportSeriesValue: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector);
|
||||
@@ -1,57 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesTitle.css';
|
||||
|
||||
function ImportSeriesTitle(props) {
|
||||
const {
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
isExistingSeries
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{
|
||||
!title.contains(year) &&
|
||||
year > 0 ?
|
||||
<span className={styles.year}>
|
||||
({year})
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
network ?
|
||||
<Label>{network}</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isExistingSeries ?
|
||||
<Label
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
{translate('Existing')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ImportSeriesTitle.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
network: PropTypes.string,
|
||||
isExistingSeries: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesTitle;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesTitle.css';
|
||||
|
||||
interface ImportSeriesTitleProps {
|
||||
title: string;
|
||||
year: number;
|
||||
network?: string;
|
||||
isExistingSeries: boolean;
|
||||
}
|
||||
|
||||
function ImportSeriesTitle({
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
isExistingSeries,
|
||||
}: ImportSeriesTitleProps) {
|
||||
return (
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
{year > 0 && !title.includes(String(year)) ? (
|
||||
<span className={styles.year}>({year})</span>
|
||||
) : null}
|
||||
|
||||
{network ? <Label>{network}</Label> : null}
|
||||
|
||||
{isExistingSeries ? (
|
||||
<Label kind={kinds.WARNING}>{translate('Existing')}</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesTitle;
|
||||
@@ -1,30 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ImportSeriesConnector from 'AddSeries/ImportSeries/Import/ImportSeriesConnector';
|
||||
import ImportSeriesSelectFolderConnector from 'AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
|
||||
class ImportSeries extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
exact={true}
|
||||
path="/add/import"
|
||||
component={ImportSeriesSelectFolderConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/add/import/:rootFolderId"
|
||||
component={ImportSeriesConnector}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ImportSeries;
|
||||
21
frontend/src/AddSeries/ImportSeries/ImportSeriesPage.tsx
Normal file
21
frontend/src/AddSeries/ImportSeries/ImportSeriesPage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import ImportSeries from './Import/ImportSeries';
|
||||
import ImportSeriesSelectFolder from './SelectFolder/ImportSeriesSelectFolder';
|
||||
|
||||
function ImportSeriesPage() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
exact={true}
|
||||
path="/add/import"
|
||||
component={ImportSeriesSelectFolder}
|
||||
/>
|
||||
|
||||
<Route path="/add/import/:rootFolderId" component={ImportSeries} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesPage;
|
||||
@@ -1,188 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import RootFolders from 'RootFolder/RootFolders';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesSelectFolder.css';
|
||||
|
||||
class ImportSeriesSelectFolder extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isAddNewRootFolderModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
onAddNewRootFolderPress = () => {
|
||||
this.setState({ isAddNewRootFolderModalOpen: true });
|
||||
};
|
||||
|
||||
onNewRootFolderSelect = ({ value }) => {
|
||||
this.props.onNewRootFolderSelect(value);
|
||||
};
|
||||
|
||||
onAddRootFolderModalClose = () => {
|
||||
this.setState({ isAddNewRootFolderModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isWindows,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
isSaving,
|
||||
error,
|
||||
saveError,
|
||||
items
|
||||
} = this.props;
|
||||
|
||||
const hasRootFolders = items.length > 0;
|
||||
const goodFolderExample = (isWindows) ? 'C:\\tv shows' : '/tv shows';
|
||||
const badFolderExample = (isWindows) ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons';
|
||||
|
||||
return (
|
||||
<PageContent title={translate('ImportSeries')}>
|
||||
<PageContentBody>
|
||||
{
|
||||
isFetching && !isPopulated ?
|
||||
<LoadingIndicator /> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && error ?
|
||||
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!error && isPopulated &&
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
{translate('LibraryImportSeriesHeader')}
|
||||
</div>
|
||||
|
||||
<div className={styles.tips}>
|
||||
{translate('LibraryImportTips')}
|
||||
<ul>
|
||||
<li className={styles.tip}>
|
||||
<InlineMarkdown data={translate('LibraryImportTipsQualityInEpisodeFilename')} />
|
||||
</li>
|
||||
<li className={styles.tip}>
|
||||
<InlineMarkdown data={translate('LibraryImportTipsSeriesUseRootFolder', { goodFolderExample, badFolderExample })} />
|
||||
</li>
|
||||
<li className={styles.tip}>
|
||||
{translate('LibraryImportTipsDontUseDownloadsFolder')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{
|
||||
hasRootFolders ?
|
||||
<div className={styles.recentFolders}>
|
||||
<FieldSet legend={translate('RootFolders')}>
|
||||
<RootFolders
|
||||
isFetching={isFetching}
|
||||
isPopulated={isPopulated}
|
||||
error={error}
|
||||
items={items}
|
||||
/>
|
||||
</FieldSet>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isSaving && saveError ?
|
||||
<Alert
|
||||
className={styles.addErrorAlert}
|
||||
kind={kinds.DANGER}
|
||||
>
|
||||
{translate('AddRootFolderError')}
|
||||
|
||||
<ul>
|
||||
{
|
||||
Array.isArray(saveError.responseJSON) ?
|
||||
saveError.responseJSON.map((e, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
{e.errorMessage}
|
||||
</li>
|
||||
);
|
||||
}) :
|
||||
<li>
|
||||
{
|
||||
JSON.stringify(saveError.responseJSON)
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
<div className={hasRootFolders ? undefined : styles.startImport}>
|
||||
<Button
|
||||
kind={kinds.PRIMARY}
|
||||
size={sizes.LARGE}
|
||||
onPress={this.onAddNewRootFolderPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.importButtonIcon}
|
||||
name={icons.DRIVE}
|
||||
/>
|
||||
{
|
||||
hasRootFolders ?
|
||||
translate('ChooseAnotherFolder') :
|
||||
translate('StartImport')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={this.state.isAddNewRootFolderModalOpen}
|
||||
name="rootFolderPath"
|
||||
value=""
|
||||
onChange={this.onNewRootFolderSelect}
|
||||
onModalClose={this.onAddRootFolderModalClose}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesSelectFolder.propTypes = {
|
||||
isWindows: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
saveError: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onNewRootFolderSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesSelectFolder;
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import RootFolders from 'RootFolder/RootFolders';
|
||||
import {
|
||||
addRootFolder,
|
||||
fetchRootFolders,
|
||||
} from 'Store/Actions/rootFolderActions';
|
||||
import useIsWindows from 'System/useIsWindows';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesSelectFolder.css';
|
||||
|
||||
function ImportSeriesSelectFolder() {
|
||||
const dispatch = useDispatch();
|
||||
const { isFetching, isPopulated, isSaving, error, saveError, items } =
|
||||
useSelector((state: AppState) => state.rootFolders);
|
||||
|
||||
const isWindows = useIsWindows();
|
||||
|
||||
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const wasSaving = usePrevious(isSaving);
|
||||
|
||||
const hasRootFolders = items.length > 0;
|
||||
const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows';
|
||||
const badFolderExample = isWindows
|
||||
? 'C:\\tv shows\\the simpsons'
|
||||
: '/tv shows/the simpsons';
|
||||
|
||||
const handleAddNewRootFolderPress = useCallback(() => {
|
||||
setIsAddNewRootFolderModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddRootFolderModalClose = useCallback(() => {
|
||||
setIsAddNewRootFolderModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleNewRootFolderSelect = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
dispatch(addRootFolder({ path: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchRootFolders());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSaving && wasSaving && !saveError) {
|
||||
items.reduce((acc, item) => {
|
||||
if (item.id > acc) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
}, [isSaving, wasSaving, saveError, items]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('ImportSeries')}>
|
||||
<PageContentBody>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && (
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
{translate('LibraryImportSeriesHeader')}
|
||||
</div>
|
||||
|
||||
<div className={styles.tips}>
|
||||
{translate('LibraryImportTips')}
|
||||
<ul>
|
||||
<li className={styles.tip}>
|
||||
<InlineMarkdown
|
||||
data={translate(
|
||||
'LibraryImportTipsQualityInEpisodeFilename'
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
<li className={styles.tip}>
|
||||
<InlineMarkdown
|
||||
data={translate('LibraryImportTipsSeriesUseRootFolder', {
|
||||
goodFolderExample,
|
||||
badFolderExample,
|
||||
})}
|
||||
/>
|
||||
</li>
|
||||
<li className={styles.tip}>
|
||||
{translate('LibraryImportTipsDontUseDownloadsFolder')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{hasRootFolders ? (
|
||||
<div className={styles.recentFolders}>
|
||||
<FieldSet legend={translate('RootFolders')}>
|
||||
<RootFolders />
|
||||
</FieldSet>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isSaving && saveError ? (
|
||||
<Alert className={styles.addErrorAlert} kind={kinds.DANGER}>
|
||||
{translate('AddRootFolderError')}
|
||||
|
||||
<ul>
|
||||
{Array.isArray(saveError.responseJSON) ? (
|
||||
saveError.responseJSON.map((e, index) => {
|
||||
return <li key={index}>{e.errorMessage}</li>;
|
||||
})
|
||||
) : (
|
||||
<li>{JSON.stringify(saveError.responseJSON)}</li>
|
||||
)}
|
||||
</ul>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className={hasRootFolders ? undefined : styles.startImport}>
|
||||
<Button
|
||||
kind={kinds.PRIMARY}
|
||||
size={sizes.LARGE}
|
||||
onPress={handleAddNewRootFolderPress}
|
||||
>
|
||||
<Icon className={styles.importButtonIcon} name={icons.DRIVE} />
|
||||
{hasRootFolders
|
||||
? translate('ChooseAnotherFolder')
|
||||
: translate('StartImport')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={isAddNewRootFolderModalOpen}
|
||||
name="rootFolderPath"
|
||||
value=""
|
||||
onChange={handleNewRootFolderSelect}
|
||||
onModalClose={handleAddRootFolderModalClose}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesSelectFolder;
|
||||
@@ -1,85 +0,0 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { addRootFolder, fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createRootFoldersSelector(),
|
||||
createSystemStatusSelector(),
|
||||
(rootFolders, systemStatus) => {
|
||||
return {
|
||||
...rootFolders,
|
||||
isWindows: systemStatus.isWindows
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchRootFolders,
|
||||
addRootFolder,
|
||||
push
|
||||
};
|
||||
|
||||
class ImportSeriesSelectFolderConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchRootFolders();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
items,
|
||||
isSaving,
|
||||
saveError
|
||||
} = this.props;
|
||||
|
||||
if (prevProps.isSaving && !isSaving && !saveError) {
|
||||
const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id);
|
||||
|
||||
if (newRootFolders.length === 1) {
|
||||
this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onNewRootFolderSelect = (path) => {
|
||||
this.props.addRootFolder({ path });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ImportSeriesSelectFolder
|
||||
{...this.props}
|
||||
onNewRootFolderSelect={this.onNewRootFolderSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesSelectFolderConnector.propTypes = {
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
fetchRootFolders: PropTypes.func.isRequired,
|
||||
addRootFolder: PropTypes.func.isRequired,
|
||||
push: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector);
|
||||
49
frontend/src/AddSeries/addSeriesOptionsStore.ts
Normal file
49
frontend/src/AddSeries/addSeriesOptionsStore.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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,
|
||||
}));
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import React from 'react';
|
||||
import DocumentTitle from 'react-document-title';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
import PageConnector from 'Components/Page/PageConnector';
|
||||
import Page from 'Components/Page/Page';
|
||||
import ApplyTheme from './ApplyTheme';
|
||||
import AppRoutes from './AppRoutes';
|
||||
|
||||
@@ -22,9 +22,9 @@ function App({ store, history }: AppProps) {
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<Page>
|
||||
<AppRoutes />
|
||||
</PageConnector>
|
||||
</Page>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -3,36 +3,36 @@ import { Redirect, Route } from 'react-router-dom';
|
||||
import Blocklist from 'Activity/Blocklist/Blocklist';
|
||||
import History from 'Activity/History/History';
|
||||
import Queue from 'Activity/Queue/Queue';
|
||||
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
|
||||
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
|
||||
import AddNewSeries from 'AddSeries/AddNewSeries/AddNewSeries';
|
||||
import ImportSeriesPage from 'AddSeries/ImportSeries/ImportSeriesPage';
|
||||
import CalendarPage from 'Calendar/CalendarPage';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
|
||||
import SeriesDetailsPage from 'Series/Details/SeriesDetailsPage';
|
||||
import SeriesIndex from 'Series/Index/SeriesIndex';
|
||||
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
|
||||
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
|
||||
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
|
||||
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
|
||||
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
|
||||
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
|
||||
import DownloadClientSettings from 'Settings/DownloadClients/DownloadClientSettings';
|
||||
import GeneralSettings from 'Settings/General/GeneralSettings';
|
||||
import ImportListSettings from 'Settings/ImportLists/ImportListSettings';
|
||||
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
|
||||
import MediaManagement from 'Settings/MediaManagement/MediaManagement';
|
||||
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
|
||||
import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings';
|
||||
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
|
||||
import Profiles from 'Settings/Profiles/Profiles';
|
||||
import QualityConnector from 'Settings/Quality/QualityConnector';
|
||||
import Quality from 'Settings/Quality/Quality';
|
||||
import Settings from 'Settings/Settings';
|
||||
import TagSettings from 'Settings/Tags/TagSettings';
|
||||
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
|
||||
import BackupsConnector from 'System/Backup/BackupsConnector';
|
||||
import LogsTableConnector from 'System/Events/LogsTableConnector';
|
||||
import UISettings from 'Settings/UI/UISettings';
|
||||
import Backups from 'System/Backup/Backups';
|
||||
import LogsTable from 'System/Events/LogsTable';
|
||||
import Logs from 'System/Logs/Logs';
|
||||
import Status from 'System/Status/Status';
|
||||
import Tasks from 'System/Tasks/Tasks';
|
||||
import Updates from 'System/Updates/Updates';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
|
||||
import MissingConnector from 'Wanted/Missing/MissingConnector';
|
||||
import CutoffUnmet from 'Wanted/CutoffUnmet/CutoffUnmet';
|
||||
import Missing from 'Wanted/Missing/Missing';
|
||||
|
||||
function RedirectWithUrlBase() {
|
||||
return <Redirect to={getPathWithUrlBase('/')} />;
|
||||
@@ -58,15 +58,15 @@ function AppRoutes() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route path="/add/new" component={AddNewSeriesConnector} />
|
||||
<Route path="/add/new" component={AddNewSeries} />
|
||||
|
||||
<Route path="/add/import" component={ImportSeries} />
|
||||
<Route path="/add/import" component={ImportSeriesPage} />
|
||||
|
||||
<Route path="/serieseditor" exact={true} render={RedirectWithUrlBase} />
|
||||
|
||||
<Route path="/seasonpass" exact={true} render={RedirectWithUrlBase} />
|
||||
|
||||
<Route path="/series/:titleSlug" component={SeriesDetailsPageConnector} />
|
||||
<Route path="/series/:titleSlug" component={SeriesDetailsPage} />
|
||||
|
||||
{/*
|
||||
Calendar
|
||||
@@ -88,9 +88,9 @@ function AppRoutes() {
|
||||
Wanted
|
||||
*/}
|
||||
|
||||
<Route path="/wanted/missing" component={MissingConnector} />
|
||||
<Route path="/wanted/missing" component={Missing} />
|
||||
|
||||
<Route path="/wanted/cutoffunmet" component={CutoffUnmetConnector} />
|
||||
<Route path="/wanted/cutoffunmet" component={CutoffUnmet} />
|
||||
|
||||
{/*
|
||||
Settings
|
||||
@@ -98,31 +98,25 @@ function AppRoutes() {
|
||||
|
||||
<Route exact={true} path="/settings" component={Settings} />
|
||||
|
||||
<Route
|
||||
path="/settings/mediamanagement"
|
||||
component={MediaManagementConnector}
|
||||
/>
|
||||
<Route path="/settings/mediamanagement" component={MediaManagement} />
|
||||
|
||||
<Route path="/settings/profiles" component={Profiles} />
|
||||
|
||||
<Route path="/settings/quality" component={QualityConnector} />
|
||||
<Route path="/settings/quality" component={Quality} />
|
||||
|
||||
<Route
|
||||
path="/settings/customformats"
|
||||
component={CustomFormatSettingsPage}
|
||||
/>
|
||||
|
||||
<Route path="/settings/indexers" component={IndexerSettingsConnector} />
|
||||
<Route path="/settings/indexers" component={IndexerSettings} />
|
||||
|
||||
<Route
|
||||
path="/settings/downloadclients"
|
||||
component={DownloadClientSettingsConnector}
|
||||
component={DownloadClientSettings}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/importlists"
|
||||
component={ImportListSettingsConnector}
|
||||
/>
|
||||
<Route path="/settings/importlists" component={ImportListSettings} />
|
||||
|
||||
<Route path="/settings/connect" component={NotificationSettings} />
|
||||
|
||||
@@ -135,9 +129,9 @@ function AppRoutes() {
|
||||
|
||||
<Route path="/settings/tags" component={TagSettings} />
|
||||
|
||||
<Route path="/settings/general" component={GeneralSettingsConnector} />
|
||||
<Route path="/settings/general" component={GeneralSettings} />
|
||||
|
||||
<Route path="/settings/ui" component={UISettingsConnector} />
|
||||
<Route path="/settings/ui" component={UISettings} />
|
||||
|
||||
{/*
|
||||
System
|
||||
@@ -147,11 +141,11 @@ function AppRoutes() {
|
||||
|
||||
<Route path="/system/tasks" component={Tasks} />
|
||||
|
||||
<Route path="/system/backup" component={BackupsConnector} />
|
||||
<Route path="/system/backup" component={Backups} />
|
||||
|
||||
<Route path="/system/updates" component={Updates} />
|
||||
|
||||
<Route path="/system/events" component={LogsTableConnector} />
|
||||
<Route path="/system/events" component={LogsTable} />
|
||||
|
||||
<Route path="/system/logs/files" component={Logs} />
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -65,14 +66,12 @@ interface AppUpdatedModalContentProps {
|
||||
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { version, prevVersion } = useSelector((state: AppState) => state.app);
|
||||
const { isPopulated, error, items } = useSelector(
|
||||
(state: AppState) => state.system.updates
|
||||
);
|
||||
const { isFetched, error, data } = useUpdates();
|
||||
const previousVersion = usePrevious(version);
|
||||
|
||||
const { onModalClose } = props;
|
||||
|
||||
const update = mergeUpdates(items, version, prevVersion);
|
||||
const update = mergeUpdates(data, version, prevVersion);
|
||||
|
||||
const handleSeeChangesPress = useCallback(() => {
|
||||
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
|
||||
@@ -100,7 +99,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPopulated && !error && !!update ? (
|
||||
{isFetched && !error && !!update ? (
|
||||
<div>
|
||||
{update.changes ? (
|
||||
<div className={styles.maintenance}>
|
||||
@@ -126,7 +125,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isPopulated && !error ? <LoadingIndicator /> : null}
|
||||
{!isFetched && !error ? <LoadingIndicator /> : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import useTheme from 'Helpers/Hooks/useTheme';
|
||||
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 = useSelector(createThemeSelector());
|
||||
const theme = useTheme();
|
||||
|
||||
const updateCSSVariables = useCallback(() => {
|
||||
Object.entries(themes[theme]).forEach(([key, value]) => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState';
|
||||
import useSelectState, {
|
||||
SelectState,
|
||||
SelectStateModel,
|
||||
} from 'Helpers/Hooks/useSelectState';
|
||||
import ModelBase from './ModelBase';
|
||||
|
||||
export type SelectContextAction =
|
||||
@@ -9,13 +12,13 @@ export type SelectContextAction =
|
||||
| { type: 'unselectAll' }
|
||||
| {
|
||||
type: 'toggleSelected';
|
||||
id: number;
|
||||
isSelected: boolean;
|
||||
id: number | string;
|
||||
isSelected: boolean | null;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'removeItem';
|
||||
id: number;
|
||||
id: number | string;
|
||||
}
|
||||
| {
|
||||
type: 'updateItems';
|
||||
@@ -24,7 +27,7 @@ export type SelectContextAction =
|
||||
|
||||
export type SelectDispatch = (action: SelectContextAction) => void;
|
||||
|
||||
interface SelectProviderOptions<T extends ModelBase> {
|
||||
interface SelectProviderOptions<T extends SelectStateModel> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children: any;
|
||||
items: Array<T>;
|
||||
@@ -34,7 +37,7 @@ const SelectContext = React.createContext<
|
||||
[SelectState, SelectDispatch] | undefined
|
||||
>(cloneDeep(undefined));
|
||||
|
||||
export function SelectProvider<T extends ModelBase>(
|
||||
export function SelectProvider<T extends SelectStateModel>(
|
||||
props: SelectProviderOptions<T>
|
||||
) {
|
||||
const { items } = props;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import { ValidationFailure } from 'typings/pending';
|
||||
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||
import { Filter, FilterBuilderProp } from './AppState';
|
||||
|
||||
export interface Error {
|
||||
status?: number;
|
||||
@@ -35,7 +35,7 @@ export interface TableAppSectionState {
|
||||
|
||||
export interface AppSectionFilterState<T> {
|
||||
selectedFilterKey: string;
|
||||
filters: PropertyFilter[];
|
||||
filters: Filter[];
|
||||
filterBuilderProps: FilterBuilderProp<T>[];
|
||||
}
|
||||
|
||||
@@ -43,9 +43,8 @@ export interface AppSectionSchemaState<T> {
|
||||
isSchemaFetching: boolean;
|
||||
isSchemaPopulated: boolean;
|
||||
schemaError: Error;
|
||||
schema: {
|
||||
items: T[];
|
||||
};
|
||||
schema: T[];
|
||||
selectedSchema?: T;
|
||||
}
|
||||
|
||||
export interface AppSectionItemSchemaState<T> {
|
||||
@@ -63,14 +62,23 @@ export interface AppSectionItemState<T> {
|
||||
item: T;
|
||||
}
|
||||
|
||||
export interface AppSectionListState<T> {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
items: T[];
|
||||
pendingChanges: Partial<T>[];
|
||||
}
|
||||
|
||||
export interface AppSectionProviderState<T>
|
||||
extends AppSectionDeleteState,
|
||||
AppSectionSaveState {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
isTesting?: boolean;
|
||||
error: Error;
|
||||
items: T[];
|
||||
pendingChanges: Partial<T>;
|
||||
pendingChanges?: Partial<T>;
|
||||
}
|
||||
|
||||
interface AppSectionState<T> {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
|
||||
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
|
||||
import { Error } from './AppSectionState';
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
import CaptchaAppState from './CaptchaAppState';
|
||||
import CommandAppState from './CommandAppState';
|
||||
import CustomFiltersAppState from './CustomFiltersAppState';
|
||||
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
||||
import EpisodesAppState from './EpisodesAppState';
|
||||
import HistoryAppState from './HistoryAppState';
|
||||
import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState';
|
||||
import ImportSeriesAppState from './ImportSeriesAppState';
|
||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||
import MessagesAppState from './MessagesAppState';
|
||||
import OAuthAppState from './OAuthAppState';
|
||||
import OrganizePreviewAppState from './OrganizePreviewAppState';
|
||||
import ParseAppState from './ParseAppState';
|
||||
import PathsAppState from './PathsAppState';
|
||||
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
||||
@@ -19,41 +27,43 @@ import SystemAppState from './SystemAppState';
|
||||
import TagsAppState from './TagsAppState';
|
||||
import WantedAppState from './WantedAppState';
|
||||
|
||||
interface FilterBuilderPropOption {
|
||||
export interface FilterBuilderPropOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FilterBuilderProp<T> {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
label: string | (() => string);
|
||||
type: FilterBuilderTypes;
|
||||
valueType?: string;
|
||||
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
|
||||
}
|
||||
|
||||
export interface PropertyFilter {
|
||||
key: string;
|
||||
value: boolean | string | number | string[] | number[];
|
||||
type: string;
|
||||
value: string | string[] | number[] | boolean[] | DateFilterValue;
|
||||
type: FilterType;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
key: string;
|
||||
label: string;
|
||||
label: string | (() => string);
|
||||
filters: PropertyFilter[];
|
||||
}
|
||||
|
||||
export interface CustomFilter {
|
||||
id: number;
|
||||
export interface CustomFilter extends ModelBase {
|
||||
type: string;
|
||||
label: string;
|
||||
filters: PropertyFilter[];
|
||||
}
|
||||
|
||||
export interface AppSectionState {
|
||||
isUpdated: boolean;
|
||||
isConnected: boolean;
|
||||
isDisconnected: boolean;
|
||||
isReconnecting: boolean;
|
||||
isRestarting: boolean;
|
||||
isSidebarVisible: boolean;
|
||||
version: string;
|
||||
prevVersion?: string;
|
||||
@@ -63,6 +73,11 @@ export interface AppSectionState {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
translations: {
|
||||
error?: Error;
|
||||
isPopulated: boolean;
|
||||
};
|
||||
messages: MessagesAppState;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
@@ -71,13 +86,16 @@ interface AppState {
|
||||
calendar: CalendarAppState;
|
||||
captcha: CaptchaAppState;
|
||||
commands: CommandAppState;
|
||||
customFilters: CustomFiltersAppState;
|
||||
episodeFiles: EpisodeFilesAppState;
|
||||
episodeHistory: HistoryAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodesSelection: EpisodesAppState;
|
||||
history: HistoryAppState;
|
||||
importSeries: ImportSeriesAppState;
|
||||
interactiveImport: InteractiveImportAppState;
|
||||
oAuth: OAuthAppState;
|
||||
organizePreview: OrganizePreviewAppState;
|
||||
parse: ParseAppState;
|
||||
paths: PathsAppState;
|
||||
providerOptions: ProviderOptionsAppState;
|
||||
@@ -85,6 +103,7 @@ interface AppState {
|
||||
releases: ReleasesAppState;
|
||||
rootFolders: RootFolderAppState;
|
||||
series: SeriesAppState;
|
||||
seriesHistory: SeriesHistoryAppState;
|
||||
seriesIndex: SeriesIndexAppState;
|
||||
settings: SettingsAppState;
|
||||
system: SystemAppState;
|
||||
|
||||
9
frontend/src/App/State/BackupAppState.ts
Normal file
9
frontend/src/App/State/BackupAppState.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import Backup from 'typings/Backup';
|
||||
import AppSectionState, { Error } from './AppSectionState';
|
||||
|
||||
interface BackupAppState extends AppSectionState<Backup> {
|
||||
isRestoring: boolean;
|
||||
restoreError?: Error;
|
||||
}
|
||||
|
||||
export default BackupAppState;
|
||||
@@ -1,10 +1,12 @@
|
||||
import AppSectionState, {
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import { CustomFilter } from './AppState';
|
||||
|
||||
interface CustomFiltersAppState
|
||||
extends AppSectionState<CustomFilter>,
|
||||
AppSectionDeleteState {}
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export default CustomFiltersAppState;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Episode from 'Episode/Episode';
|
||||
|
||||
type EpisodesAppState = AppSectionState<Episode>;
|
||||
interface EpisodesAppState extends AppSectionState<Episode> {
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
export default EpisodesAppState;
|
||||
|
||||
@@ -5,6 +5,8 @@ import AppSectionState, {
|
||||
} from 'App/State/AppSectionState';
|
||||
import History from 'typings/History';
|
||||
|
||||
export type SeriesHistoryAppState = AppSectionState<History>;
|
||||
|
||||
interface HistoryAppState
|
||||
extends AppSectionState<History>,
|
||||
AppSectionFilterState<History>,
|
||||
|
||||
29
frontend/src/App/State/ImportSeriesAppState.ts
Normal file
29
frontend/src/App/State/ImportSeriesAppState.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
import { Error } from './AppSectionState';
|
||||
|
||||
export interface ImportSeries {
|
||||
id: string;
|
||||
error?: Error;
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
isQueued: boolean;
|
||||
items: Series[];
|
||||
monitor: SeriesMonitor;
|
||||
path: string;
|
||||
qualityProfileId: number;
|
||||
relativePath: string;
|
||||
seasonFolder: boolean;
|
||||
selectedSeries?: Series;
|
||||
seriesType: SeriesType;
|
||||
term: string;
|
||||
}
|
||||
|
||||
interface ImportSeriesAppState {
|
||||
isLookingUpSeries: false;
|
||||
isImporting: false;
|
||||
isImported: false;
|
||||
importError: Error | null;
|
||||
items: ImportSeries[];
|
||||
}
|
||||
|
||||
export default ImportSeriesAppState;
|
||||
15
frontend/src/App/State/MessagesAppState.ts
Normal file
15
frontend/src/App/State/MessagesAppState.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
|
||||
export type MessageType = 'error' | 'info' | 'success' | 'warning';
|
||||
|
||||
export interface Message extends ModelBase {
|
||||
hideAfter: number;
|
||||
message: string;
|
||||
name: string;
|
||||
type: MessageType;
|
||||
}
|
||||
|
||||
type MessagesAppState = AppSectionState<Message>;
|
||||
|
||||
export default MessagesAppState;
|
||||
15
frontend/src/App/State/OrganizePreviewAppState.ts
Normal file
15
frontend/src/App/State/OrganizePreviewAppState.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
|
||||
export interface OrganizePreviewModel extends ModelBase {
|
||||
seriesId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumbers: number[];
|
||||
episodeFileId: number;
|
||||
existingPath: string;
|
||||
newPath: string;
|
||||
}
|
||||
|
||||
type OrganizePreviewAppState = AppSectionState<OrganizePreviewModel>;
|
||||
|
||||
export default OrganizePreviewAppState;
|
||||
@@ -2,11 +2,16 @@ import AppSectionState, {
|
||||
AppSectionDeleteState,
|
||||
AppSectionItemSchemaState,
|
||||
AppSectionItemState,
|
||||
AppSectionListState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState,
|
||||
PagedAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Language from 'Language/Language';
|
||||
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import CustomFormatSpecification from 'typings/CustomFormatSpecification';
|
||||
import DelayProfile from 'typings/DelayProfile';
|
||||
import DownloadClient from 'typings/DownloadClient';
|
||||
import ImportList from 'typings/ImportList';
|
||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||
@@ -14,25 +19,59 @@ import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
|
||||
import Indexer from 'typings/Indexer';
|
||||
import IndexerFlag from 'typings/IndexerFlag';
|
||||
import Notification from 'typings/Notification';
|
||||
import QualityDefinition from 'typings/QualityDefinition';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
|
||||
import General from 'typings/Settings/General';
|
||||
import IndexerOptions from 'typings/Settings/IndexerOptions';
|
||||
import MediaManagement from 'typings/Settings/MediaManagement';
|
||||
import NamingConfig from 'typings/Settings/NamingConfig';
|
||||
import NamingExample from 'typings/Settings/NamingExample';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import RemotePathMapping from 'typings/Settings/RemotePathMapping';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
import MetadataAppState from './MetadataAppState';
|
||||
|
||||
type Presets<T> = T & {
|
||||
presets: T[];
|
||||
};
|
||||
|
||||
export interface AutoTaggingAppState
|
||||
extends AppSectionState<AutoTagging>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface AutoTaggingSpecificationAppState
|
||||
extends AppSectionState<AutoTaggingSpecification>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<AutoTaggingSpecification> {}
|
||||
|
||||
export interface DelayProfileAppState
|
||||
extends AppSectionListState<DelayProfile>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface DownloadClientAppState
|
||||
extends AppSectionState<DownloadClient>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<Presets<DownloadClient>> {
|
||||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadClientOptionsAppState
|
||||
extends AppSectionItemState<DownloadClientOptions>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface GeneralAppState
|
||||
extends AppSectionItemState<General>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface MediaManagementAppState
|
||||
extends AppSectionItemState<MediaManagement>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface NamingAppState
|
||||
extends AppSectionItemState<NamingConfig>,
|
||||
AppSectionSaveState {}
|
||||
@@ -42,22 +81,42 @@ export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
|
||||
export interface ImportListAppState
|
||||
extends AppSectionState<ImportList>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<Presets<ImportList>> {
|
||||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
export interface IndexerOptionsAppState
|
||||
extends AppSectionItemState<IndexerOptions>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface IndexerAppState
|
||||
extends AppSectionState<Indexer>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<Presets<Indexer>> {
|
||||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationAppState
|
||||
extends AppSectionState<Notification>,
|
||||
AppSectionDeleteState {}
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<Presets<Notification>> {}
|
||||
|
||||
export interface QualityDefinitionsAppState
|
||||
extends AppSectionState<QualityDefinition>,
|
||||
AppSectionSaveState {
|
||||
pendingChanges: {
|
||||
[key: number]: Partial<QualityProfile>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface QualityProfilesAppState
|
||||
extends AppSectionState<QualityProfile>,
|
||||
AppSectionItemSchemaState<QualityProfile> {}
|
||||
AppSectionItemSchemaState<QualityProfile>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ReleaseProfilesAppState
|
||||
extends AppSectionState<ReleaseProfile>,
|
||||
@@ -70,6 +129,12 @@ export interface CustomFormatAppState
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface CustomFormatSpecificationAppState
|
||||
extends AppSectionState<CustomFormatSpecification>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<Presets<CustomFormatSpecification>> {}
|
||||
|
||||
export interface ImportListOptionsSettingsAppState
|
||||
extends AppSectionItemState<ImportListOptionsSettings>,
|
||||
AppSectionSaveState {}
|
||||
@@ -82,27 +147,43 @@ export interface ImportListExclusionsSettingsAppState
|
||||
pendingChanges: Partial<ImportListExclusion>;
|
||||
}
|
||||
|
||||
export interface RemotePathMappingsAppState
|
||||
extends AppSectionState<RemotePathMapping>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {
|
||||
pendingChanges: Partial<RemotePathMapping>;
|
||||
}
|
||||
|
||||
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
|
||||
export type LanguageSettingsAppState = AppSectionState<Language>;
|
||||
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
||||
|
||||
interface SettingsAppState {
|
||||
advancedSettings: boolean;
|
||||
autoTaggings: AutoTaggingAppState;
|
||||
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
|
||||
customFormats: CustomFormatAppState;
|
||||
customFormatSpecifications: CustomFormatSpecificationAppState;
|
||||
delayProfiles: DelayProfileAppState;
|
||||
downloadClients: DownloadClientAppState;
|
||||
downloadClientOptions: DownloadClientOptionsAppState;
|
||||
general: GeneralAppState;
|
||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||
importListOptions: ImportListOptionsSettingsAppState;
|
||||
importLists: ImportListAppState;
|
||||
indexerFlags: IndexerFlagSettingsAppState;
|
||||
indexerOptions: IndexerOptionsAppState;
|
||||
indexers: IndexerAppState;
|
||||
languages: LanguageSettingsAppState;
|
||||
mediaManagement: MediaManagementAppState;
|
||||
metadata: MetadataAppState;
|
||||
naming: NamingAppState;
|
||||
namingExamples: NamingExamplesAppState;
|
||||
notifications: NotificationAppState;
|
||||
qualityDefinitions: QualityDefinitionsAppState;
|
||||
qualityProfiles: QualityProfilesAppState;
|
||||
releaseProfiles: ReleaseProfilesAppState;
|
||||
remotePathMappings: RemotePathMappingsAppState;
|
||||
ui: UiSettingsAppState;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import DiskSpace from 'typings/DiskSpace';
|
||||
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';
|
||||
|
||||
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
|
||||
export type HealthAppState = AppSectionState<Health>;
|
||||
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
|
||||
export type TaskAppState = AppSectionState<Task>;
|
||||
export type UpdateAppState = AppSectionState<Update>;
|
||||
export type LogFilesAppState = AppSectionState<LogFile>;
|
||||
|
||||
interface SystemAppState {
|
||||
backups: BackupAppState;
|
||||
diskSpace: DiskSpaceAppState;
|
||||
health: HealthAppState;
|
||||
logFiles: LogFilesAppState;
|
||||
status: SystemStatusAppState;
|
||||
tasks: TaskAppState;
|
||||
updates: UpdateAppState;
|
||||
updateLogFiles: LogFilesAppState;
|
||||
}
|
||||
|
||||
export default SystemAppState;
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Episode from 'Episode/Episode';
|
||||
|
||||
type WantedCutoffUnmetAppState = AppSectionState<Episode>;
|
||||
interface WantedEpisode extends Episode {
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
type WantedMissingAppState = AppSectionState<Episode>;
|
||||
interface WantedCutoffUnmetAppState
|
||||
extends AppSectionState<WantedEpisode>,
|
||||
AppSectionFilterState<WantedEpisode>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {}
|
||||
|
||||
interface WantedMissingAppState
|
||||
extends AppSectionState<WantedEpisode>,
|
||||
AppSectionFilterState<WantedEpisode>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {}
|
||||
|
||||
interface WantedAppState {
|
||||
cutoffUnmet: WantedCutoffUnmetAppState;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setCalendarFilter } from 'Store/Actions/calendarActions';
|
||||
|
||||
function createCalendarSelector() {
|
||||
@@ -23,9 +23,7 @@ function createFilterBuilderPropsSelector() {
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
type CalendarFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function CalendarFilterModal(props: CalendarFilterModalProps) {
|
||||
const sectionItems = useSelector(createCalendarSelector());
|
||||
@@ -43,7 +41,6 @@ export default function CalendarFilterModal(props: CalendarFilterModalProps) {
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, 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 Measure from 'Components/Measure';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
@@ -12,6 +11,7 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import NoSeries from 'Series/NoSeries';
|
||||
import {
|
||||
@@ -96,27 +96,13 @@ function CalendarPage() {
|
||||
const customFilters = useSelector(createCustomFiltersSelector('calendar'));
|
||||
const hasSeries = !!useSelector(createSeriesCountSelector());
|
||||
|
||||
const [pageContentRef, { width }] = useMeasure();
|
||||
const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false);
|
||||
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
const isMeasured = width > 0;
|
||||
const PageComponent = hasSeries ? Calendar : NoSeries;
|
||||
|
||||
const handleMeasure = useCallback(
|
||||
({ width: newWidth }: { width: number }) => {
|
||||
setWidth(newWidth);
|
||||
|
||||
const dayCount = Math.max(
|
||||
3,
|
||||
Math.min(7, Math.floor(newWidth / MINIMUM_DAY_WIDTH))
|
||||
);
|
||||
|
||||
dispatch(setCalendarDaysCount({ dayCount }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleGetCalendarLinkPress = useCallback(() => {
|
||||
setIsCalendarLinkModalOpen(true);
|
||||
}, []);
|
||||
@@ -146,12 +132,25 @@ function CalendarPage() {
|
||||
}, [missingEpisodeIds, dispatch]);
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(key: string) => {
|
||||
(key: string | number) => {
|
||||
dispatch(setCalendarFilter({ selectedFilterKey: key }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dayCount = Math.max(
|
||||
3,
|
||||
Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))
|
||||
);
|
||||
|
||||
dispatch(setCalendarDaysCount({ dayCount }));
|
||||
}, [width, dispatch]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
@@ -200,13 +199,11 @@ function CalendarPage() {
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody
|
||||
ref={pageContentRef}
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
<Measure whitelist={['width']} onMeasure={handleMeasure}>
|
||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||
</Measure>
|
||||
|
||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||
{hasSeries && <Legend />}
|
||||
</PageContentBody>
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import moment from 'moment';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { CalendarView } from 'Calendar/calendarViews';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
@@ -37,7 +36,7 @@ function CalendarHeader() {
|
||||
const { longDateFormat } = useSelector(createUISettingsSelector());
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
(newView: CalendarView) => {
|
||||
(newView: string) => {
|
||||
dispatch(setCalendarView({ view: newView }));
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.dragLayer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import styles from './DragPreviewLayer.css';
|
||||
|
||||
interface DragPreviewLayerProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function DragPreviewLayer({
|
||||
className = styles.dragLayer,
|
||||
children,
|
||||
...otherProps
|
||||
}: DragPreviewLayerProps) {
|
||||
return (
|
||||
<div className={className} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DragPreviewLayer;
|
||||
@@ -4,6 +4,7 @@ import React, { Component, ErrorInfo } from 'react';
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
errorComponent: React.ElementType;
|
||||
onModalClose?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
@@ -32,11 +33,17 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, errorComponent: ErrorComponent } = this.props;
|
||||
const {
|
||||
children,
|
||||
errorComponent: ErrorComponent,
|
||||
onModalClose,
|
||||
} = this.props;
|
||||
const { error, info } = this.state;
|
||||
|
||||
if (error) {
|
||||
return <ErrorComponent error={error} info={info} />;
|
||||
return (
|
||||
<ErrorComponent error={error} info={info} onModalClose={onModalClose} />
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
|
||||
@@ -3,7 +3,7 @@ import StackTrace from 'stacktrace-js';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ErrorBoundaryError.css';
|
||||
|
||||
interface ErrorBoundaryErrorProps {
|
||||
export interface ErrorBoundaryErrorProps {
|
||||
className: string;
|
||||
messageClassName: string;
|
||||
detailsClassName: string;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import FilterBuilderRowValue from './FilterBuilderRowValue';
|
||||
|
||||
const protocols = [
|
||||
{ id: true, name: 'true' },
|
||||
{ id: false, name: 'false' }
|
||||
];
|
||||
|
||||
function BoolFilterBuilderRowValue(props) {
|
||||
return (
|
||||
<FilterBuilderRowValue
|
||||
tagList={protocols}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default BoolFilterBuilderRowValue;
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import FilterBuilderRowValue, {
|
||||
FilterBuilderRowValueProps,
|
||||
} from './FilterBuilderRowValue';
|
||||
|
||||
const protocols = [
|
||||
{ id: true, name: 'true' },
|
||||
{ id: false, name: 'false' },
|
||||
];
|
||||
|
||||
type BoolFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, boolean>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
function BoolFilterBuilderRowValue<T>(
|
||||
props: BoolFilterBuilderRowValueProps<T>
|
||||
) {
|
||||
return <FilterBuilderRowValue tagList={protocols} {...props} />;
|
||||
}
|
||||
|
||||
export default BoolFilterBuilderRowValue;
|
||||
@@ -1,177 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import NumberInput from 'Components/Form/NumberInput';
|
||||
import SelectInput from 'Components/Form/SelectInput';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import { IN_LAST, IN_NEXT, NOT_IN_LAST, NOT_IN_NEXT } from 'Helpers/Props/filterTypes';
|
||||
import isString from 'Utilities/String/isString';
|
||||
import { NAME } from './FilterBuilderRowValue';
|
||||
import styles from './DateFilterBuilderRowValue.css';
|
||||
|
||||
const timeOptions = [
|
||||
{ key: 'seconds', value: 'seconds' },
|
||||
{ key: 'minutes', value: 'minutes' },
|
||||
{ key: 'hours', value: 'hours' },
|
||||
{ key: 'days', value: 'days' },
|
||||
{ key: 'weeks', value: 'weeks' },
|
||||
{ key: 'months', value: 'months' }
|
||||
];
|
||||
|
||||
function isInFilter(filterType) {
|
||||
return (
|
||||
filterType === IN_LAST ||
|
||||
filterType === NOT_IN_LAST ||
|
||||
filterType === IN_NEXT ||
|
||||
filterType === NOT_IN_NEXT
|
||||
);
|
||||
}
|
||||
|
||||
class DateFilterBuilderRowValue extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
filterType,
|
||||
filterValue,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
if (isInFilter(filterType) && isString(filterValue)) {
|
||||
onChange({
|
||||
name: NAME,
|
||||
value: {
|
||||
time: timeOptions[0].key,
|
||||
value: null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
filterType,
|
||||
filterValue,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
if (prevProps.filterType === filterType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInFilter(filterType) && isString(filterValue)) {
|
||||
onChange({
|
||||
name: NAME,
|
||||
value: {
|
||||
time: timeOptions[0].key,
|
||||
value: null
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInFilter(filterType) && !isString(filterValue)) {
|
||||
onChange({
|
||||
name: NAME,
|
||||
value: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onValueChange = ({ value }) => {
|
||||
const {
|
||||
filterValue,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
let newValue = value;
|
||||
|
||||
if (!isString(value)) {
|
||||
newValue = {
|
||||
time: filterValue.time,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
onChange({
|
||||
name: NAME,
|
||||
value: newValue
|
||||
});
|
||||
};
|
||||
|
||||
onTimeChange = ({ value }) => {
|
||||
const {
|
||||
filterValue,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
onChange({
|
||||
name: NAME,
|
||||
value: {
|
||||
time: value,
|
||||
value: filterValue.value
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
filterType,
|
||||
filterValue
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
(isInFilter(filterType) && isString(filterValue)) ||
|
||||
(!isInFilter(filterType) && !isString(filterValue))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isInFilter(filterType)) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<NumberInput
|
||||
className={styles.numberInput}
|
||||
name={NAME}
|
||||
value={filterValue.value}
|
||||
onChange={this.onValueChange}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
className={styles.selectInput}
|
||||
name={NAME}
|
||||
value={filterValue.time}
|
||||
values={timeOptions}
|
||||
onChange={this.onTimeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
name={NAME}
|
||||
value={filterValue}
|
||||
type="date"
|
||||
placeholder="yyyy-mm-dd"
|
||||
onChange={this.onValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DateFilterBuilderRowValue.propTypes = {
|
||||
filterType: PropTypes.string,
|
||||
filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DateFilterBuilderRowValue;
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import NumberInput from 'Components/Form/NumberInput';
|
||||
import SelectInput from 'Components/Form/SelectInput';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import {
|
||||
DateFilterBuilderTime,
|
||||
DateFilterValue,
|
||||
FilterType,
|
||||
} from 'Helpers/Props/filterTypes';
|
||||
import { InputChanged, InputOnChange } from 'typings/inputs';
|
||||
import isString from 'Utilities/String/isString';
|
||||
import { FilterBuilderRowValueProps, NAME } from './FilterBuilderRowValue';
|
||||
import styles from './DateFilterBuilderRowValue.css';
|
||||
|
||||
const timeOptions: DateFilterBuilderTime[] = [
|
||||
{ key: 'seconds', value: 'seconds' },
|
||||
{ key: 'minutes', value: 'minutes' },
|
||||
{ key: 'hours', value: 'hours' },
|
||||
{ key: 'days', value: 'days' },
|
||||
{ key: 'weeks', value: 'weeks' },
|
||||
{ key: 'months', value: 'months' },
|
||||
];
|
||||
|
||||
function isInFilter(filterType: FilterType) {
|
||||
return (
|
||||
filterType === 'inLast' ||
|
||||
filterType === 'notInLast' ||
|
||||
filterType === 'inNext' ||
|
||||
filterType === 'notInNext'
|
||||
);
|
||||
}
|
||||
|
||||
interface DateFilterBuilderRowValueProps<T>
|
||||
extends Omit<
|
||||
FilterBuilderRowValueProps<T, string>,
|
||||
'filterValue' | 'onChange'
|
||||
> {
|
||||
filterValue: string | DateFilterValue;
|
||||
onChange: InputOnChange<string | DateFilterValue>;
|
||||
}
|
||||
|
||||
function DateFilterBuilderRowValue<T>({
|
||||
filterType,
|
||||
filterValue,
|
||||
onChange,
|
||||
}: DateFilterBuilderRowValueProps<T>) {
|
||||
const previousFilterType = usePrevious(filterType);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
({ value }: InputChanged<number | null>) => {
|
||||
onChange({
|
||||
name: NAME,
|
||||
value: {
|
||||
time: (filterValue as DateFilterValue).time,
|
||||
value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[filterValue, onChange]
|
||||
);
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
onChange({
|
||||
name: NAME,
|
||||
value: {
|
||||
time: value,
|
||||
value: (filterValue as DateFilterValue).value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[filterValue, onChange]
|
||||
);
|
||||
|
||||
const handleDateChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
onChange({
|
||||
name: NAME,
|
||||
value,
|
||||
});
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousFilterType === filterType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInFilter(filterType) && isString(filterValue)) {
|
||||
onChange({
|
||||
name: NAME,
|
||||
value: {
|
||||
time: timeOptions[0].key,
|
||||
value: null,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInFilter(filterType) && !isString(filterValue)) {
|
||||
onChange({
|
||||
name: NAME,
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
}, [filterType, previousFilterType, filterValue, onChange]);
|
||||
|
||||
if (
|
||||
(isInFilter(filterType) && isString(filterValue)) ||
|
||||
(!isInFilter(filterType) && !isString(filterValue))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isInFilter(filterType)) {
|
||||
const { value, time } = filterValue as DateFilterValue;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<NumberInput
|
||||
className={styles.numberInput}
|
||||
name={NAME}
|
||||
value={value}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
className={styles.selectInput}
|
||||
name={NAME}
|
||||
value={time}
|
||||
values={timeOptions}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
name={NAME}
|
||||
value={filterValue as string}
|
||||
type="date"
|
||||
placeholder="yyyy-mm-dd"
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DateFilterBuilderRowValue;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { FilterBuilderPropOption } from 'App/State/AppState';
|
||||
import { filterBuilderTypes } from 'Helpers/Props';
|
||||
import * as filterTypes from 'Helpers/Props/filterTypes';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import FilterBuilderRowValue, {
|
||||
FilterBuilderRowValueProps,
|
||||
} from './FilterBuilderRowValue';
|
||||
|
||||
type DefaultFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
function DefaultFilterBuilderRowValue<T>({
|
||||
filterType,
|
||||
sectionItems,
|
||||
selectedFilterBuilderProp,
|
||||
...otherProps
|
||||
}: DefaultFilterBuilderRowValueProps<T>) {
|
||||
const tagList = useMemo(() => {
|
||||
if (
|
||||
((selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ||
|
||||
selectedFilterBuilderProp.type === filterBuilderTypes.STRING) &&
|
||||
filterType !== filterTypes.EQUAL &&
|
||||
filterType !== filterTypes.NOT_EQUAL) ||
|
||||
!selectedFilterBuilderProp.optionsSelector
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let items: FilterBuilderPropOption[] = [];
|
||||
|
||||
if (selectedFilterBuilderProp.optionsSelector) {
|
||||
items = selectedFilterBuilderProp
|
||||
.optionsSelector(sectionItems)
|
||||
.filter(
|
||||
(value, index, array) =>
|
||||
array.findIndex((v) => v.id === value.id) === index
|
||||
);
|
||||
} else {
|
||||
items = sectionItems
|
||||
.reduce<FilterBuilderPropOption[]>((acc, item) => {
|
||||
// @ts-expect-error - can't guarantee that the name property exists on the item
|
||||
const name = item[selectedFilterBuilderProp.name];
|
||||
|
||||
// DOn't add invalid values or items that already exist
|
||||
if (name && acc.findIndex((a) => a.id === name) === -1) {
|
||||
acc.push({
|
||||
id: name,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [])
|
||||
.sort(sortByProp('name'));
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [filterType, sectionItems, selectedFilterBuilderProp]);
|
||||
|
||||
return (
|
||||
<FilterBuilderRowValue
|
||||
{...otherProps}
|
||||
filterType={filterType}
|
||||
sectionItems={sectionItems}
|
||||
selectedFilterBuilderProp={selectedFilterBuilderProp}
|
||||
tagList={tagList}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DefaultFilterBuilderRowValue;
|
||||
@@ -1,232 +0,0 @@
|
||||
import { maxBy } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import FilterBuilderRow from './FilterBuilderRow';
|
||||
import styles from './FilterBuilderModalContent.css';
|
||||
|
||||
class FilterBuilderModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const filters = [...props.filters];
|
||||
|
||||
// Push an empty filter if there aren't any filters. FilterBuilderRow
|
||||
// will handle initializing the filter.
|
||||
|
||||
if (!filters.length) {
|
||||
filters.push({});
|
||||
}
|
||||
|
||||
this.state = {
|
||||
label: props.label,
|
||||
filters,
|
||||
labelErrors: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
id,
|
||||
customFilters,
|
||||
isSaving,
|
||||
saveError,
|
||||
dispatchSetFilter,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
if (prevProps.isSaving && !isSaving && !saveError) {
|
||||
if (id) {
|
||||
dispatchSetFilter({ selectedFilterKey: id });
|
||||
} else {
|
||||
const last = maxBy(customFilters, 'id');
|
||||
dispatchSetFilter({ selectedFilterKey: last.id });
|
||||
}
|
||||
|
||||
onModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onLabelChange = ({ value }) => {
|
||||
this.setState({ label: value });
|
||||
};
|
||||
|
||||
onFilterChange = (index, filter) => {
|
||||
const filters = [...this.state.filters];
|
||||
filters.splice(index, 1, filter);
|
||||
|
||||
this.setState({
|
||||
filters
|
||||
});
|
||||
};
|
||||
|
||||
onAddFilterPress = () => {
|
||||
const filters = [...this.state.filters];
|
||||
filters.push({});
|
||||
|
||||
this.setState({
|
||||
filters
|
||||
});
|
||||
};
|
||||
|
||||
onRemoveFilterPress = (index) => {
|
||||
const filters = [...this.state.filters];
|
||||
filters.splice(index, 1);
|
||||
|
||||
this.setState({
|
||||
filters
|
||||
});
|
||||
};
|
||||
|
||||
onSaveFilterPress = () => {
|
||||
const {
|
||||
id,
|
||||
customFilterType,
|
||||
onSaveCustomFilterPress
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
label,
|
||||
filters
|
||||
} = this.state;
|
||||
|
||||
if (!label) {
|
||||
this.setState({
|
||||
labelErrors: [
|
||||
{
|
||||
message: translate('LabelIsRequired')
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSaveCustomFilterPress({
|
||||
id,
|
||||
type: customFilterType,
|
||||
label,
|
||||
filters
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
sectionItems,
|
||||
filterBuilderProps,
|
||||
isSaving,
|
||||
saveError,
|
||||
onCancelPress,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
label,
|
||||
filters,
|
||||
labelErrors
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('CustomFilter')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.label}>
|
||||
{translate('Label')}
|
||||
</div>
|
||||
|
||||
<div className={styles.labelInputContainer}>
|
||||
<FormInputGroup
|
||||
name="label"
|
||||
value={label}
|
||||
type={inputTypes.TEXT}
|
||||
errors={labelErrors}
|
||||
onChange={this.onLabelChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.label}>
|
||||
{translate('Filters')}
|
||||
</div>
|
||||
|
||||
<div className={styles.rows}>
|
||||
{
|
||||
filters.map((filter, index) => {
|
||||
return (
|
||||
<FilterBuilderRow
|
||||
key={`${filter.key}-${index}`}
|
||||
index={index}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
filterKey={filter.key}
|
||||
filterValue={filter.value}
|
||||
filterType={filter.type}
|
||||
filterCount={filters.length}
|
||||
onAddPress={this.onAddFilterPress}
|
||||
onRemovePress={this.onRemoveFilterPress}
|
||||
onFilterChange={this.onFilterChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onCancelPress}>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={this.onSaveFilterPress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FilterBuilderModalContent.propTypes = {
|
||||
id: PropTypes.number,
|
||||
label: PropTypes.string.isRequired,
|
||||
customFilterType: PropTypes.string.isRequired,
|
||||
sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
dispatchDeleteCustomFilter: PropTypes.func.isRequired,
|
||||
onSaveCustomFilterPress: PropTypes.func.isRequired,
|
||||
dispatchSetFilter: PropTypes.func.isRequired,
|
||||
onCancelPress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FilterBuilderModalContent;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { maxBy } from 'lodash';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState, {
|
||||
CustomFilter,
|
||||
FilterBuilderProp,
|
||||
PropertyFilter,
|
||||
} from 'App/State/AppState';
|
||||
import FormInputGroup, {
|
||||
ValidationMessage,
|
||||
} from 'Components/Form/FormInputGroup';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { saveCustomFilter } from 'Store/Actions/customFilterActions';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import FilterBuilderRow from './FilterBuilderRow';
|
||||
import styles from './FilterBuilderModalContent.css';
|
||||
|
||||
const NEW_FILTER: PropertyFilter = {
|
||||
key: '',
|
||||
value: '',
|
||||
type: 'unknown',
|
||||
};
|
||||
|
||||
interface FilterBuilderModalContentProps<T> {
|
||||
id: number | null;
|
||||
customFilterType: string;
|
||||
sectionItems: T[];
|
||||
filterBuilderProps: FilterBuilderProp<T>[];
|
||||
customFilters: CustomFilter[];
|
||||
dispatchSetFilter: (payload: { selectedFilterKey: string | number }) => void;
|
||||
onCancelPress: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function FilterBuilderModalContent<T>({
|
||||
id,
|
||||
customFilters,
|
||||
customFilterType,
|
||||
sectionItems,
|
||||
filterBuilderProps,
|
||||
dispatchSetFilter,
|
||||
onCancelPress,
|
||||
onModalClose,
|
||||
}: FilterBuilderModalContentProps<T>) {
|
||||
const dispatch = useDispatch();
|
||||
const { isSaving, saveError } = useSelector(
|
||||
(state: AppState) => state.customFilters
|
||||
);
|
||||
|
||||
const { initialLabel, initialFilters } = useMemo(() => {
|
||||
if (id) {
|
||||
const customFilter = customFilters.find((c) => c.id === id);
|
||||
|
||||
if (customFilter) {
|
||||
return {
|
||||
initialLabel: customFilter.label,
|
||||
initialFilters: customFilter.filters,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialLabel: '',
|
||||
initialFilters: [],
|
||||
};
|
||||
}, [id, customFilters]);
|
||||
|
||||
const [label, setLabel] = useState(initialLabel);
|
||||
// Push an empty filter if there aren't any filters. FilterBuilderRow
|
||||
// will handle initializing the filter.
|
||||
const [filters, setFilters] = useState<PropertyFilter[]>(
|
||||
initialFilters.length ? initialFilters : [NEW_FILTER]
|
||||
);
|
||||
const [labelErrors, setLabelErrors] = useState<ValidationMessage[]>([]);
|
||||
const wasSaving = usePrevious(isSaving);
|
||||
|
||||
const handleLabelChange = useCallback(({ value }: InputChanged<string>) => {
|
||||
setLabel(value);
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(index: number, filter: PropertyFilter) => {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, 1, filter);
|
||||
|
||||
setFilters(newFilters);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
const handleAddFilterPress = useCallback(() => {
|
||||
setFilters([...filters, NEW_FILTER]);
|
||||
}, [filters]);
|
||||
|
||||
const handleRemoveFilterPress = useCallback(
|
||||
(index: number) => {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, 1);
|
||||
|
||||
setFilters(newFilters);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
const handleSaveFilterPress = useCallback(() => {
|
||||
if (!label) {
|
||||
setLabelErrors([
|
||||
{
|
||||
message: translate('LabelIsRequired'),
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(saveCustomFilter({ id, type: customFilterType, label, filters }));
|
||||
}, [id, customFilterType, label, filters, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasSaving && !isSaving && !saveError) {
|
||||
if (id) {
|
||||
dispatchSetFilter({ selectedFilterKey: id });
|
||||
} else {
|
||||
const last = maxBy(customFilters, 'id');
|
||||
|
||||
if (last) {
|
||||
dispatchSetFilter({ selectedFilterKey: last.id });
|
||||
}
|
||||
}
|
||||
|
||||
onModalClose();
|
||||
}
|
||||
}, [
|
||||
id,
|
||||
customFilters,
|
||||
isSaving,
|
||||
wasSaving,
|
||||
saveError,
|
||||
dispatchSetFilter,
|
||||
onModalClose,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('CustomFilter')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.label}>{translate('Label')}</div>
|
||||
|
||||
<div className={styles.labelInputContainer}>
|
||||
<FormInputGroup
|
||||
name="label"
|
||||
value={label}
|
||||
type={inputTypes.TEXT}
|
||||
errors={labelErrors}
|
||||
onChange={handleLabelChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.label}>{translate('Filters')}</div>
|
||||
|
||||
<div className={styles.rows}>
|
||||
{filters.map((filter, index) => {
|
||||
return (
|
||||
<FilterBuilderRow
|
||||
key={`${filter.key}-${index}`}
|
||||
index={index}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
filterKey={filter.key}
|
||||
filterValue={filter.value}
|
||||
filterType={filter.type}
|
||||
filterCount={filters.length}
|
||||
onAddPress={handleAddFilterPress}
|
||||
onRemovePress={handleRemoveFilterPress}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onCancelPress}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={handleSaveFilterPress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterBuilderModalContent;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user