diff --git a/.devcontainer/Sonarr.code-workspace b/.devcontainer/Sonarr.code-workspace new file mode 100644 index 000000000..a46158e44 --- /dev/null +++ b/.devcontainer/Sonarr.code-workspace @@ -0,0 +1,13 @@ +// This file is used to open the backend and frontend in the same workspace, which is necessary as +// the frontend has vscode settings that are distinct from the backend +{ + "folders": [ + { + "path": ".." + }, + { + "path": "../frontend" + } + ], + "settings": {} +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..629a2aa21 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet +{ + "name": "Sonarr", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "nodeGypDependencies": true, + "version": "16", + "nvmVersion": "latest" + } + }, + "forwardPorts": [8989], + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode"] + } + } +} diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 65e928bb0..bd62f4830 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -27,7 +27,7 @@ runs: using: 'composite' steps: - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 - name: Setup Postgres if: ${{ inputs.use_postgres }} @@ -77,7 +77,7 @@ runs: - name: Run tests shell: bash - run: dotnet test ./_tests/Sonarr.*.Test.dll --filter "${{ inputs.filter }}" --logger "trx;LogFileName=${{ env.RESULTS_NAME }}.trx" + run: dotnet test ./_tests/Sonarr.*.Test.dll --filter "${{ inputs.filter }}" --logger "trx;LogFileName=${{ env.RESULTS_NAME }}.trx" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" - name: Upload Test Results if: ${{ !cancelled() }} @@ -85,12 +85,3 @@ runs: with: name: results-${{ env.RESULTS_NAME }} path: TestResults/*.trx - - - name: Publish Test Results - uses: phoenix-actions/test-reporting@v12 - with: - name: Test Results - output-to: step-summary - path: '*.trx' - reporter: dotnet-trx - working-directory: TestResults diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..f33a02cd1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/labeler.yml b/.github/labeler.yml index 3b42128d4..fdd66d11a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,17 +1,23 @@ 'connection': - - src/NzbDrone.Core/Notifications/**/* + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Notifications/**/* 'db-migration': - - src/NzbDrone.Core/Datastore/Migration/* + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Datastore/Migration/* 'download-client': - - src/NzbDrone.Core/Download/Clients/**/* + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Download/Clients/**/* 'indexer': - - src/NzbDrone.Core/Indexers/**/* + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Indexers/**/* 'parsing': - - src/NzbDrone.Core/Parser/**/* + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Parser/**/* 'ui-only': - - all: ['frontend/**/*'] + - changed-files: + - any-glob-to-all-files: frontend/**/* diff --git a/.github/workflows/api_docs.yml b/.github/workflows/api_docs.yml index 1fc69c0fa..dfd8ce0e2 100644 --- a/.github/workflows/api_docs.yml +++ b/.github/workflows/api_docs.yml @@ -26,10 +26,10 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup dotnet - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 id: setup-dotnet - name: Create openapi.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 14f77ea55..52f9d5678 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,9 +5,14 @@ on: branches: - develop - main + paths-ignore: + - "src/Sonarr.Api.*/openapi.json" pull_request: branches: - develop + paths-ignore: + - "src/NzbDrone.Core/Localization/Core/**" + - "src/Sonarr.Api.*/openapi.json" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -15,9 +20,9 @@ concurrency: env: FRAMEWORK: net6.0 - BRANCH: ${{ github.head_ref || github.ref_name }} + RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }} SONARR_MAJOR_VERSION: 4 - VERSION: 4.0.1 + VERSION: 4.0.14 jobs: backend: @@ -27,103 +32,105 @@ jobs: major_version: ${{ steps.variables.outputs.major_version }} version: ${{ steps.variables.outputs.version }} steps: - - name: Check out - uses: actions/checkout@v3 + - name: Check out + uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v3 + - 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) + - 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 "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT" - echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT" - echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT" + 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" - - name: Enable Extra Platforms In SDK - shell: bash - run: ./build.sh --enable-extra-platforms-in-sdk + echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT" + echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT" + echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT" - - name: Build Backend - shell: bash - run: ./build.sh --backend --enable-extra-platforms --packages + - name: Enable Extra Platforms In SDK + shell: bash + run: ./build.sh --enable-extra-platforms-in-sdk - # Test Artifacts + - name: Build Backend + shell: bash + run: ./build.sh --backend --enable-extra-platforms --packages - - name: Publish win-x64 Test Artifact - uses: ./.github/actions/publish-test-artifact - with: - framework: ${{ env.FRAMEWORK }} - runtime: win-x64 + # Test Artifacts - - name: Publish linux-x64 Test Artifact - uses: ./.github/actions/publish-test-artifact - with: - framework: ${{ env.FRAMEWORK }} - runtime: linux-x64 + - name: Publish win-x64 Test Artifact + uses: ./.github/actions/publish-test-artifact + with: + framework: ${{ env.FRAMEWORK }} + runtime: win-x64 - - name: Publish osx-x64 Test Artifact - uses: ./.github/actions/publish-test-artifact - with: - framework: ${{ env.FRAMEWORK }} - runtime: osx-x64 + - name: Publish linux-x64 Test Artifact + uses: ./.github/actions/publish-test-artifact + with: + framework: ${{ env.FRAMEWORK }} + runtime: linux-x64 - # 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-*/**/* + - 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-*/**/* frontend: runs-on: ubuntu-latest steps: - - name: Check out - uses: actions/checkout@v3 + - name: Check out + uses: actions/checkout@v4 - - name: Volta - uses: volta-cli/action@v4 + - name: Volta + uses: volta-cli/action@v4 - - name: Yarn Intsall - run: yarn install + - name: Yarn Install + run: yarn install - - name: Lint - run: yarn lint + - name: Lint + run: yarn lint - - name: Stylelint - run: yarn stylelint + - name: Stylelint + run: yarn stylelint -f github - - name: Build - run: yarn build --env production + - name: Build + run: yarn build --env production - - name: Publish UI Artifact - uses: actions/upload-artifact@v4 - with: - name: build_ui - path: _output/UI/**/* + - name: Publish UI Artifact + uses: actions/upload-artifact@v4 + with: + name: build_ui + path: _output/UI/**/* unit_test: needs: backend @@ -136,43 +143,44 @@ jobs: artifact: tests-linux-x64 filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest - os: macos-latest - artifact: tests-osx-x64 + artifact: tests-osx-arm64 filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest - os: windows-latest artifact: tests-win-x64 filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory!=IntegrationTest&TestCategory!=AutomationTest runs-on: ${{ matrix.os }} steps: - - name: Check out - uses: actions/checkout@v3 + - name: Check out + uses: actions/checkout@v4 - - name: Test - uses: ./.github/actions/test - with: - os: ${{ matrix.os }} - artifact: ${{ matrix.artifact }} - pattern: Sonarr.*.Test.dll - filter: ${{ matrix.filter }} + - name: Test + uses: ./.github/actions/test + with: + os: ${{ matrix.os }} + artifact: ${{ matrix.artifact }} + pattern: Sonarr.*.Test.dll + filter: ${{ matrix.filter }} unit_test_postgres: needs: backend runs-on: ubuntu-latest steps: - - name: Check out - uses: actions/checkout@v3 + - name: Check out + uses: actions/checkout@v4 - - name: Test - uses: ./.github/actions/test - with: - os: ubuntu-latest - artifact: tests-linux-x64 - pattern: Sonarr.*.Test.dll - filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest - use_postgres: true + - name: Test + uses: ./.github/actions/test + with: + os: ubuntu-latest + artifact: tests-linux-x64 + pattern: Sonarr.*.Test.dll + filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest + use_postgres: true integration_test: needs: backend strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] include: @@ -182,10 +190,10 @@ jobs: binary_artifact: build_linux binary_path: linux-x64/${{ needs.backend.outputs.framework }}/Sonarr - os: macos-latest - artifact: tests-osx-x64 + artifact: tests-osx-arm64 filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest binary_artifact: build_macos - binary_path: osx-x64/${{ needs.backend.outputs.framework }}/Sonarr + binary_path: osx-arm64/${{ needs.backend.outputs.framework }}/Sonarr - os: windows-latest artifact: tests-win-x64 filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest @@ -193,23 +201,23 @@ jobs: binary_path: win-x64/${{ needs.backend.outputs.framework }}/Sonarr runs-on: ${{ matrix.os }} steps: - - name: Check out - uses: actions/checkout@v3 + - name: Check out + uses: actions/checkout@v4 - - name: Test - uses: ./.github/actions/test - with: - os: ${{ matrix.os }} - artifact: ${{ matrix.artifact }} - pattern: Sonarr.*.Test.dll - filter: ${{ matrix.filter }} - integration_tests: true - binary_artifact: ${{ matrix.binary_artifact }} - binary_path: ${{ matrix.binary_path }} + - name: Test + uses: ./.github/actions/test + with: + os: ${{ matrix.os }} + artifact: ${{ matrix.artifact }} + pattern: Sonarr.*.Test.dll + filter: ${{ matrix.filter }} + integration_tests: true + binary_artifact: ${{ matrix.binary_artifact }} + binary_path: ${{ matrix.binary_path }} deploy: if: ${{ github.ref_name == 'develop' || github.ref_name == 'main' }} - needs: [backend, unit_test, unit_test_postgres, integration_test] + needs: [backend, frontend, unit_test, unit_test_postgres, integration_test] secrets: inherit uses: ./.github/workflows/deploy.yml with: @@ -217,3 +225,33 @@ jobs: branch: ${{ github.ref_name }} major_version: ${{ needs.backend.outputs.major_version }} version: ${{ needs.backend.outputs.version }} + + notify: + name: Discord Notification + needs: + [ + backend, + frontend, + unit_test, + unit_test_postgres, + integration_test, + deploy, + ] + if: ${{ !cancelled() && (github.ref_name == 'develop' || github.ref_name == 'main') }} + env: + STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }} + runs-on: ubuntu-latest + + steps: + - name: Notify + 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 }}: ${{ env.STATUS == 'success' && 'Success' || 'Failure' }}" + embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + embed-description: | + **Branch** ${{ github.ref }} + **Build** ${{ needs.backend.outputs.version }} + embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }} diff --git a/.github/workflows/conflict_labeler.yml b/.github/workflows/conflict_labeler.yml new file mode 100644 index 000000000..e9afb71a3 --- /dev/null +++ b/.github/workflows/conflict_labeler.yml @@ -0,0 +1,26 @@ +name: Merge Conflict Labeler + +on: + push: + branches: + - develop + pull_request_target: + branches: + - develop + types: [synchronize] + +jobs: + label: + name: Labeling + runs-on: ubuntu-latest + if: ${{ github.repository == 'Sonarr/Sonarr' }} + permissions: + contents: read + pull-requests: write + steps: + - name: Apply label + uses: eps1lon/actions-label-merge-conflict@v3 + with: + dirtyLabel: 'merge-conflict' + repoToken: '${{ secrets.GITHUB_TOKEN }}' + \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index af683e9d8..4fa5b54ee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,7 +41,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Check out - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Package uses: ./.github/actions/package @@ -60,7 +60,7 @@ jobs: contents: write steps: - name: Check out - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Download release artifacts uses: actions/download-artifact@v4 @@ -69,12 +69,38 @@ jobs: 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: 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: true + generateReleaseNotes: false + body: ${{ steps.generate-release-notes.outputs.result }} name: ${{ inputs.version }} prerelease: ${{ inputs.branch != 'main' }} skipIfReleaseExists: true diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 857cfb4a7..df54c0fff 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -8,5 +8,6 @@ jobs: contents: read pull-requests: write runs-on: ubuntu-latest + if: github.repository == 'Sonarr/Sonarr' steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 0435b1c71..d775234db 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -8,14 +8,15 @@ on: jobs: lock: runs-on: ubuntu-latest + if: github.repository == 'Sonarr/Sonarr' steps: - - uses: dessant/lock-threads@v2 + - uses: dessant/lock-threads@v5 with: github-token: ${{ github.token }} - issue-lock-inactive-days: '90' - issue-exclude-created-before: '' - issue-exclude-labels: 'one-day-maybe' - issue-lock-labels: '' - issue-lock-comment: '' + issue-inactive-days: '90' + exclude-issue-created-before: '' + exclude-any-issue-labels: 'one-day-maybe' + add-issue-labels: '' + issue-comment: '' issue-lock-reason: 'resolved' process-only: '' diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml deleted file mode 100644 index 5e6012559..000000000 --- a/.github/workflows/publish-test-results.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Publish Test Results - -on: - workflow_run: - workflows: ['Build'] - types: - - completed - -permissions: - contents: read - actions: read - checks: write - -jobs: - report: - if: ${{ github.event.workflow_run.conclusion != 'skipped' && github.event.workflow_run.conclusion != 'cancelled' }} - runs-on: ubuntu-latest - steps: - - name: Check out - uses: actions/checkout@v3 - - - name: Download Test Reports - uses: actions/download-artifact@v4 - with: - path: test-results - pattern: results-* - merge-multiple: true - repository: ${{ github.event.repository.owner.login }}/${{ github.event.repository.name }} - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish Test Results - uses: phoenix-actions/test-reporting@v12 - with: - list-suites: failed - list-tests: failed - name: Test Results - only-summary: true - path: '*.trx' - reporter: dotnet-trx - working-directory: test-results diff --git a/.github/workflows/support-requests.yml b/.github/workflows/support-requests.yml new file mode 100644 index 000000000..adf5a8c4a --- /dev/null +++ b/.github/workflows/support-requests.yml @@ -0,0 +1,29 @@ +name: 'Support Requests' + +on: + issues: + types: [labeled, unlabeled, reopened] + +permissions: + issues: write + +jobs: + action: + runs-on: ubuntu-latest + if: github.repository == 'Sonarr/Sonarr' + steps: + - uses: dessant/support-requests@v4 + with: + github-token: ${{ github.token }} + support-label: 'support' + issue-comment: > + :wave: @{issue-author}, we use the issue tracker exclusively + for bug reports and feature requests. However, this issue appears + to be a support request. Please use one of the support channels: + [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) + for support/questions. + close-issue: true + issue-close-reason: 'not planned' + lock-issue: false + issue-lock-reason: 'off-topic' diff --git a/.gitignore b/.gitignore index 73bd6ad62..d17209556 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ coverage*.xml coverage*.json setup/Output/ *.~is +.mono #VS outout folders bin @@ -161,3 +162,6 @@ src/.idea/ # API doc generation .config/ + +# Ignore Jetbrains IntelliJ Workspace Directories +.idea/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..7a36fefe1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "ms-dotnettools.csdevkit", + "ms-vscode-remote.remote-containers" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..6ea80f418 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": "Run Sonarr", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build dotnet", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/_output/net6.0/Sonarr", + "args": [], + "cwd": "${workspaceFolder}", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..44aeb4060 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..cfd41d42f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,44 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build dotnet", + "command": "dotnet", + "type": "process", + "args": [ + "msbuild", + "-restore", + "${workspaceFolder}/src/Sonarr.sln", + "-p:GenerateFullPaths=true", + "-p:Configuration=Debug", + "-p:Platform=Posix", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/Sonarr.sln", + "-property:GenerateFullPaths=true", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/Sonarr.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/Logo/Jetbrains/dottrace.svg b/Logo/Jetbrains/dottrace.svg deleted file mode 100644 index b879517cd..000000000 --- a/Logo/Jetbrains/dottrace.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/Jetbrains/jetbrains.svg b/Logo/Jetbrains/jetbrains.svg deleted file mode 100644 index 75d4d2177..000000000 --- a/Logo/Jetbrains/jetbrains.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/Jetbrains/resharper.svg b/Logo/Jetbrains/resharper.svg deleted file mode 100644 index 24c987a78..000000000 --- a/Logo/Jetbrains/resharper.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/Jetbrains/teamcity.svg b/Logo/Jetbrains/teamcity.svg deleted file mode 100644 index ca14b3dc1..000000000 --- a/Logo/Jetbrains/teamcity.svg +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index ef3c2ecea..d43366f93 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Sonarr Sonarr +# Sonarr Sonarr -[![Translated](https://translate.servarr.com/widgets/servarr/-/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/) +[![Translated](https://translate.servarr.com/widget/servarr/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/) [![Backers on Open Collective](https://opencollective.com/Sonarr/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/Sonarr/sponsors/badge.svg)](#sponsors) [![Mega Sponsors on Open Collective](https://opencollective.com/Sonarr/megasponsors/badge.svg)](#mega-sponsors) @@ -33,7 +33,7 @@ Note: GitHub Issues are for Bugs and Feature Requests Only - Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc. - Automatically detects new episodes - Can scan your existing library and download any missing episodes -- Can watch for better quality of the episodes you already have and do an automatic upgrade. *eg. from DVD to Blu-Ray* +- Can watch for better quality of the episodes you already have and do an automatic upgrade. _eg. from DVD to Blu-Ray_ - Automatic failed download handling will try another release if one fails - Manual search so you can pick any release or to see why a release was not downloaded automatically - Fully configurable episode renaming @@ -52,7 +52,7 @@ This project exists thanks to all the people who contribute. [Contribute](CONTRI ### Supporters -This project would not be possible without the support of our users and software providers. +This project would not be possible without the support of our users and software providers. [**Become a sponsor or backer**](https://opencollective.com/sonarr) to help us out! #### Mega Sponsors @@ -69,13 +69,17 @@ This project would not be possible without the support of our users and software #### JetBrains -Thank you to [JetBrains JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools +Thank you to [JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools -* [TeamCity TeamCity](http://www.jetbrains.com/teamcity/) -* [ReSharper ReSharper](http://www.jetbrains.com/resharper/) -* [dotTrace dotTrace](http://www.jetbrains.com/dottrace/) +[TeamCity](http://www.jetbrains.com/teamcity/) + +[ReSharper](http://www.jetbrains.com/resharper/) + +[dotTrace](http://www.jetbrains.com/dottrace/) + +[Rider](http://www.jetbrains.com/rider/) ### Licenses -- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) -- Copyright 2010-2023 +- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) +- Copyright 2010-2024 diff --git a/distribution/debian/install.sh b/distribution/debian/install.sh index 87a0b0914..803d7cf51 100644 --- a/distribution/debian/install.sh +++ b/distribution/debian/install.sh @@ -59,7 +59,7 @@ app_guid=$(echo "$app_guid" | tr -d ' ') app_guid=${app_guid:-media} echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory" -echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that that user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories" +echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories" read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty # Create User / Group as needed @@ -114,7 +114,7 @@ case "$ARCH" in esac echo "" echo "Removing previous tarballs" -# -f to Force so we fail if it doesnt exist +# -f to Force so we fail if it doesn't exist rm -f "${app^}".*.tar.gz echo "" echo "Downloading..." diff --git a/docs.sh b/docs.sh index a0f21c41a..386f5df68 100755 --- a/docs.sh +++ b/docs.sh @@ -25,17 +25,23 @@ slnFile=src/Sonarr.sln platform=Posix +if [ "$PLATFORM" = "Windows" ]; then + application=Sonarr.Console.dll +else + application=Sonarr.dll +fi + dotnet clean $slnFile -c Debug dotnet clean $slnFile -c Release dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids dotnet new tool-manifest -dotnet tool install --version 6.5.0 Swashbuckle.AspNetCore.Cli +dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli -dotnet tool run swagger tofile --output ./src/Sonarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/Sonarr.dll" v3 & +dotnet tool run swagger tofile --output ./src/Sonarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v3 & -sleep 30 +sleep 45 kill %1 diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index cc26a2633..e14b9125d 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -210,7 +210,6 @@ module.exports = { 'no-undef-init': 'off', 'no-undefined': 'off', 'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }], - 'no-use-before-define': 'error', // Node.js and CommonJS @@ -359,11 +358,20 @@ module.exports = { ], rules: Object.assign(typescriptEslintRecommended.rules, { - 'no-shadow': 'off', - // These should be enabled after cleaning things up - '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true + + } + ], '@typescript-eslint/explicit-function-return-type': 'off', - 'react/prop-types': 'off', + 'no-shadow': 'off', 'prettier/prettier': 'error', 'simple-import-sort/imports': [ 'error', @@ -376,7 +384,41 @@ module.exports = { ['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$'] ] } - ] + ], + + // React Hooks + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + + // React + 'react/function-component-definition': 'error', + 'react/hook-use-state': 'error', + 'react/jsx-boolean-value': ['error', 'always'], + 'react/jsx-curly-brace-presence': [ + 'error', + { props: 'never', children: 'never' } + ], + 'react/jsx-fragments': 'error', + 'react/jsx-handler-names': [ + 'error', + { + eventHandlerPrefix: 'on', + eventHandlerPropPrefix: 'on' + } + ], + 'react/jsx-no-bind': ['error', { ignoreRefs: true }], + 'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }], + 'react/jsx-pascal-case': ['error', { allowAllCaps: true }], + 'react/jsx-sort-props': [ + 'error', + { + callbacksLast: true, + noSortAlphabetically: true, + reservedFirst: true + } + ], + 'react/prop-types': 'off', + 'react/self-closing-comp': 'error' }) }, { diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index edb88e0e7..8da95337f 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -9,7 +9,7 @@ "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll": true + "source.fixAll": "explicit" }, "typescript.preferences.quoteStyle": "single", diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index e0ec27c27..0d0364950 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -26,6 +26,7 @@ module.exports = (env) => { const config = { mode: isProduction ? 'production' : 'development', devtool: isProduction ? 'source-map' : 'eval-source-map', + target: 'web', stats: { children: false @@ -51,8 +52,7 @@ module.exports = (env) => { 'node_modules' ], alias: { - jquery: 'jquery/dist/jquery.min', - 'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate' + jquery: 'jquery/dist/jquery.min' }, fallback: { buffer: false, @@ -67,7 +67,7 @@ module.exports = (env) => { output: { path: distFolder, publicPath: '/', - filename: '[name]-[contenthash].js', + filename: isProduction ? '[name]-[contenthash].js' : '[name].js', sourceMapFilename: '[file].map' }, @@ -92,7 +92,7 @@ module.exports = (env) => { new MiniCssExtractPlugin({ filename: 'Content/styles.css', - chunkFilename: 'Content/[id]-[chunkhash].css' + chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css' }), new HtmlWebpackPlugin({ @@ -134,6 +134,12 @@ module.exports = (env) => { { source: 'frontend/src/Content/robots.txt', destination: path.join(distFolder, 'Content/robots.txt') + }, + + // manifest.json and browserconfig.xml + { + source: 'frontend/src/Content/*.(json|xml)', + destination: path.join(distFolder, 'Content') } ] } @@ -181,7 +187,7 @@ module.exports = (env) => { loose: true, debug: false, useBuiltIns: 'entry', - corejs: 3 + corejs: '3.39' } ] ] @@ -202,7 +208,7 @@ module.exports = (env) => { options: { importLoaders: 1, modules: { - localIdentName: '[name]/[local]/[hash:base64:5]' + localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]' } } }, diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index f657adf28..89db00f8c 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -16,6 +16,7 @@ const mixinsFiles = [ module.exports = { plugins: [ + 'autoprefixer', ['postcss-mixins', { mixinsFiles }], diff --git a/frontend/src/Activity/Blocklist/Blocklist.js b/frontend/src/Activity/Blocklist/Blocklist.js deleted file mode 100644 index 797aa5175..000000000 --- a/frontend/src/Activity/Blocklist/Blocklist.js +++ /dev/null @@ -1,261 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import TablePager from 'Components/Table/TablePager'; -import { align, icons, kinds } from 'Helpers/Props'; -import getRemovedItems from 'Utilities/Object/getRemovedItems'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import translate from 'Utilities/String/translate'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import BlocklistRowConnector from './BlocklistRowConnector'; - -class Blocklist extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {}, - isConfirmRemoveModalOpen: false, - isConfirmClearModalOpen: false, - items: props.items - }; - } - - componentDidUpdate(prevProps) { - const { - items - } = this.props; - - if (hasDifferentItems(prevProps.items, items)) { - this.setState((state) => { - return { - ...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)), - items - }; - }); - - return; - } - } - - // - // Control - - getSelectedIds = () => { - return getSelectedIds(this.state.selectedState); - }; - - // - // Listeners - - onSelectAllChange = ({ value }) => { - this.setState(selectAll(this.state.selectedState, value)); - }; - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - }; - - onRemoveSelectedPress = () => { - this.setState({ isConfirmRemoveModalOpen: true }); - }; - - onRemoveSelectedConfirmed = () => { - this.props.onRemoveSelected(this.getSelectedIds()); - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - onConfirmRemoveModalClose = () => { - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - onClearBlocklistPress = () => { - this.setState({ isConfirmClearModalOpen: true }); - }; - - onClearBlocklistConfirmed = () => { - this.props.onClearBlocklistPress(); - this.setState({ isConfirmClearModalOpen: false }); - }; - - onConfirmClearModalClose = () => { - this.setState({ isConfirmClearModalOpen: false }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - columns, - totalRecords, - isRemoving, - isClearingBlocklistExecuting, - ...otherProps - } = this.props; - - const { - allSelected, - allUnselected, - selectedState, - isConfirmRemoveModalOpen, - isConfirmClearModalOpen - } = this.state; - - const selectedIds = this.getSelectedIds(); - - return ( - - - - - - - - - - - - - - - - - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && - - {translate('BlocklistLoadError')} - - } - - { - isPopulated && !error && !items.length && - - {translate('NoHistoryBlocklist')} - - } - - { - isPopulated && !error && !!items.length && -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
- } -
- - - - -
- ); - } -} - -Blocklist.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - totalRecords: PropTypes.number, - isRemoving: PropTypes.bool.isRequired, - isClearingBlocklistExecuting: PropTypes.bool.isRequired, - onRemoveSelected: PropTypes.func.isRequired, - onClearBlocklistPress: PropTypes.func.isRequired -}; - -export default Blocklist; diff --git a/frontend/src/Activity/Blocklist/Blocklist.tsx b/frontend/src/Activity/Blocklist/Blocklist.tsx new file mode 100644 index 000000000..4163bc9ca --- /dev/null +++ b/frontend/src/Activity/Blocklist/Blocklist.tsx @@ -0,0 +1,329 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { SelectProvider } from 'App/SelectContext'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import usePaging from 'Components/Table/usePaging'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { align, icons, kinds } from 'Helpers/Props'; +import { + clearBlocklist, + fetchBlocklist, + gotoBlocklistPage, + removeBlocklistItems, + setBlocklistFilter, + setBlocklistSort, + setBlocklistTableOption, +} from 'Store/Actions/blocklistActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { CheckInputChanged } from 'typings/inputs'; +import { SelectStateInputProps } from 'typings/props'; +import { TableOptionsChangePayload } from 'typings/Table'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import BlocklistFilterModal from './BlocklistFilterModal'; +import BlocklistRow from './BlocklistRow'; + +function Blocklist() { + const requestCurrentPage = useCurrentPage(); + + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + sortKey, + sortDirection, + page, + pageSize, + totalPages, + totalRecords, + isRemoving, + } = useSelector((state: AppState) => state.blocklist); + + const customFilters = useSelector(createCustomFiltersSelector('blocklist')); + const isClearingBlocklistExecuting = useSelector( + createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST) + ); + const dispatch = useDispatch(); + + const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = + useState(false); + const [isConfirmClearModalOpen, setIsConfirmClearModalOpen] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const wasClearingBlocklistExecuting = usePrevious( + isClearingBlocklistExecuting + ); + + const handleSelectAllChange = useCallback( + ({ value }: CheckInputChanged) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const handleSelectedChange = useCallback( + ({ id, value, shiftKey = false }: SelectStateInputProps) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const handleRemoveSelectedPress = useCallback(() => { + setIsConfirmRemoveModalOpen(true); + }, [setIsConfirmRemoveModalOpen]); + + const handleRemoveSelectedConfirmed = useCallback(() => { + dispatch(removeBlocklistItems({ ids: selectedIds })); + setIsConfirmRemoveModalOpen(false); + }, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]); + + const handleConfirmRemoveModalClose = useCallback(() => { + setIsConfirmRemoveModalOpen(false); + }, [setIsConfirmRemoveModalOpen]); + + const handleClearBlocklistPress = useCallback(() => { + setIsConfirmClearModalOpen(true); + }, [setIsConfirmClearModalOpen]); + + const handleClearBlocklistConfirmed = useCallback(() => { + dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST })); + setIsConfirmClearModalOpen(false); + }, [setIsConfirmClearModalOpen, dispatch]); + + const handleConfirmClearModalClose = useCallback(() => { + setIsConfirmClearModalOpen(false); + }, [setIsConfirmClearModalOpen]); + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoBlocklistPage, + }); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string) => { + dispatch(setBlocklistFilter({ selectedFilterKey })); + }, + [dispatch] + ); + + const handleSortPress = useCallback( + (sortKey: string) => { + dispatch(setBlocklistSort({ sortKey })); + }, + [dispatch] + ); + + const handleTableOptionChange = useCallback( + (payload: TableOptionsChangePayload) => { + dispatch(setBlocklistTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoBlocklistPage({ page: 1 })); + } + }, + [dispatch] + ); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchBlocklist()); + } else { + dispatch(gotoBlocklistPage({ page: 1 })); + } + + return () => { + dispatch(clearBlocklist()); + }; + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchBlocklist()); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [dispatch]); + + useEffect(() => { + if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) { + dispatch(gotoBlocklistPage({ page: 1 })); + } + }, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]); + + return ( + + + + + + + + + + + + + + + + + + + + {isFetching && !isPopulated ? : null} + + {!isFetching && !!error ? ( + {translate('BlocklistLoadError')} + ) : null} + + {isPopulated && !error && !items.length ? ( + + {selectedFilterKey === 'all' + ? translate('NoBlocklistItems') + : translate('BlocklistFilterHasNoItems')} + + ) : null} + + {isPopulated && !error && !!items.length ? ( +
+ + + {items.map((item) => { + return ( + + ); + })} + +
+ +
+ ) : null} +
+ + + + +
+
+ ); +} + +export default Blocklist; diff --git a/frontend/src/Activity/Blocklist/BlocklistConnector.js b/frontend/src/Activity/Blocklist/BlocklistConnector.js deleted file mode 100644 index 454fa13a9..000000000 --- a/frontend/src/Activity/Blocklist/BlocklistConnector.js +++ /dev/null @@ -1,152 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withCurrentPage from 'Components/withCurrentPage'; -import * as blocklistActions from 'Store/Actions/blocklistActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import Blocklist from './Blocklist'; - -function createMapStateToProps() { - return createSelector( - (state) => state.blocklist, - createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST), - (blocklist, isClearingBlocklistExecuting) => { - return { - isClearingBlocklistExecuting, - ...blocklist - }; - } - ); -} - -const mapDispatchToProps = { - ...blocklistActions, - executeCommand -}; - -class BlocklistConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - useCurrentPage, - fetchBlocklist, - gotoBlocklistFirstPage - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchBlocklist(); - } else { - gotoBlocklistFirstPage(); - } - } - - componentDidUpdate(prevProps) { - if (prevProps.isClearingBlocklistExecuting && !this.props.isClearingBlocklistExecuting) { - this.props.gotoBlocklistFirstPage(); - } - } - - componentWillUnmount() { - this.props.clearBlocklist(); - unregisterPagePopulator(this.repopulate); - } - - // - // Control - - repopulate = () => { - this.props.fetchBlocklist(); - }; - // - // Listeners - - onFirstPagePress = () => { - this.props.gotoBlocklistFirstPage(); - }; - - onPreviousPagePress = () => { - this.props.gotoBlocklistPreviousPage(); - }; - - onNextPagePress = () => { - this.props.gotoBlocklistNextPage(); - }; - - onLastPagePress = () => { - this.props.gotoBlocklistLastPage(); - }; - - onPageSelect = (page) => { - this.props.gotoBlocklistPage({ page }); - }; - - onRemoveSelected = (ids) => { - this.props.removeBlocklistItems({ ids }); - }; - - onSortPress = (sortKey) => { - this.props.setBlocklistSort({ sortKey }); - }; - - onClearBlocklistPress = () => { - this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST }); - }; - - onTableOptionChange = (payload) => { - this.props.setBlocklistTableOption(payload); - - if (payload.pageSize) { - this.props.gotoBlocklistFirstPage(); - } - }; - - // - // Render - - render() { - return ( - - ); - } -} - -BlocklistConnector.propTypes = { - useCurrentPage: PropTypes.bool.isRequired, - isClearingBlocklistExecuting: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchBlocklist: PropTypes.func.isRequired, - gotoBlocklistFirstPage: PropTypes.func.isRequired, - gotoBlocklistPreviousPage: PropTypes.func.isRequired, - gotoBlocklistNextPage: PropTypes.func.isRequired, - gotoBlocklistLastPage: PropTypes.func.isRequired, - gotoBlocklistPage: PropTypes.func.isRequired, - removeBlocklistItems: PropTypes.func.isRequired, - setBlocklistSort: PropTypes.func.isRequired, - setBlocklistTableOption: PropTypes.func.isRequired, - clearBlocklist: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired -}; - -export default withCurrentPage( - connect(createMapStateToProps, mapDispatchToProps)(BlocklistConnector) -); diff --git a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js deleted file mode 100644 index 5f8b98d3d..000000000 --- a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js +++ /dev/null @@ -1,90 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import Button from 'Components/Link/Button'; -import Modal from 'Components/Modal/Modal'; -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 translate from 'Utilities/String/translate'; - -class BlocklistDetailsModal extends Component { - - // - // Render - - render() { - const { - isOpen, - sourceTitle, - protocol, - indexer, - message, - onModalClose - } = this.props; - - return ( - - - - Details - - - - - - - - - { - !!message && - - } - - { - !!message && - - } - - - - - - - - - ); - } -} - -BlocklistDetailsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - sourceTitle: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - indexer: PropTypes.string, - message: PropTypes.string, - onModalClose: PropTypes.func.isRequired -}; - -export default BlocklistDetailsModal; diff --git a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx new file mode 100644 index 000000000..ec026ae92 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +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 DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import translate from 'Utilities/String/translate'; + +interface BlocklistDetailsModalProps { + isOpen: boolean; + sourceTitle: string; + protocol: DownloadProtocol; + indexer?: string; + message?: string; + onModalClose: () => void; +} + +function BlocklistDetailsModal(props: BlocklistDetailsModalProps) { + const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } = + props; + + return ( + + + Details + + + + + + + + {message ? ( + + ) : null} + + {message ? ( + + ) : null} + + + + + + + + + ); +} + +export default BlocklistDetailsModal; diff --git a/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx b/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx new file mode 100644 index 000000000..ea80458f1 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx @@ -0,0 +1,54 @@ +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 { setBlocklistFilter } from 'Store/Actions/blocklistActions'; + +function createBlocklistSelector() { + return createSelector( + (state: AppState) => state.blocklist.items, + (blocklistItems) => { + return blocklistItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.blocklist.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface BlocklistFilterModalProps { + isOpen: boolean; +} + +export default function BlocklistFilterModal(props: BlocklistFilterModalProps) { + const sectionItems = useSelector(createBlocklistSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'blocklist'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setBlocklistFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.js b/frontend/src/Activity/Blocklist/BlocklistRow.js deleted file mode 100644 index b6bd2863c..000000000 --- a/frontend/src/Activity/Blocklist/BlocklistRow.js +++ /dev/null @@ -1,212 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; -import TableRow from 'Components/Table/TableRow'; -import EpisodeFormats from 'Episode/EpisodeFormats'; -import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import { icons, kinds } from 'Helpers/Props'; -import SeriesTitleLink from 'Series/SeriesTitleLink'; -import translate from 'Utilities/String/translate'; -import BlocklistDetailsModal from './BlocklistDetailsModal'; -import styles from './BlocklistRow.css'; - -class BlocklistRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onDetailsPress = () => { - this.setState({ isDetailsModalOpen: true }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - series, - sourceTitle, - languages, - quality, - customFormats, - date, - protocol, - indexer, - message, - isSelected, - columns, - onSelectedChange, - onRemovePress - } = this.props; - - return ( - - - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'series.sortTitle') { - return ( - - - - ); - } - - if (name === 'sourceTitle') { - return ( - - {sourceTitle} - - ); - } - - if (name === 'languages') { - return ( - - - - ); - } - - if (name === 'quality') { - return ( - - - - ); - } - - if (name === 'customFormats') { - return ( - - - - ); - } - - if (name === 'date') { - return ( - - ); - } - - if (name === 'indexer') { - return ( - - {indexer} - - ); - } - - if (name === 'actions') { - return ( - - - - - - ); - } - - return null; - }) - } - - - - ); - } - -} - -BlocklistRow.propTypes = { - id: PropTypes.number.isRequired, - series: PropTypes.object.isRequired, - sourceTitle: PropTypes.string.isRequired, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object).isRequired, - date: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - indexer: PropTypes.string, - message: PropTypes.string, - isSelected: PropTypes.bool.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onSelectedChange: PropTypes.func.isRequired, - onRemovePress: PropTypes.func.isRequired -}; - -export default BlocklistRow; diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.tsx b/frontend/src/Activity/Blocklist/BlocklistRow.tsx new file mode 100644 index 000000000..c7410320d --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistRow.tsx @@ -0,0 +1,163 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import EpisodeFormats from 'Episode/EpisodeFormats'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import { icons, kinds } from 'Helpers/Props'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import useSeries from 'Series/useSeries'; +import { removeBlocklistItem } from 'Store/Actions/blocklistActions'; +import Blocklist from 'typings/Blocklist'; +import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; +import BlocklistDetailsModal from './BlocklistDetailsModal'; +import styles from './BlocklistRow.css'; + +interface BlocklistRowProps extends Blocklist { + isSelected: boolean; + columns: Column[]; + onSelectedChange: (options: SelectStateInputProps) => void; +} + +function BlocklistRow(props: BlocklistRowProps) { + const { + id, + seriesId, + sourceTitle, + languages, + quality, + customFormats, + date, + protocol, + indexer, + message, + isSelected, + columns, + onSelectedChange, + } = props; + + const series = useSeries(seriesId); + const dispatch = useDispatch(); + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const handleDetailsPress = useCallback(() => { + setIsDetailsModalOpen(true); + }, [setIsDetailsModalOpen]); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, [setIsDetailsModalOpen]); + + const handleRemovePress = useCallback(() => { + dispatch(removeBlocklistItem({ id })); + }, [id, dispatch]); + + if (!series) { + return null; + } + + return ( + + + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'sourceTitle') { + return {sourceTitle}; + } + + if (name === 'languages') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'date') { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore ts(2739) + return ; + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return null; + })} + + + + ); +} + +export default BlocklistRow; diff --git a/frontend/src/Activity/Blocklist/BlocklistRowConnector.js b/frontend/src/Activity/Blocklist/BlocklistRowConnector.js deleted file mode 100644 index f0b93cd25..000000000 --- a/frontend/src/Activity/Blocklist/BlocklistRowConnector.js +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { removeBlocklistItem } from 'Store/Actions/blocklistActions'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import BlocklistRow from './BlocklistRow'; - -function createMapStateToProps() { - return createSelector( - createSeriesSelector(), - (series) => { - return { - series - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onRemovePress() { - dispatch(removeBlocklistItem({ id: props.id })); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(BlocklistRow); diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js deleted file mode 100644 index 862d8707e..000000000 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ /dev/null @@ -1,354 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; -import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; -import Link from 'Components/Link/Link'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; -import translate from 'Utilities/String/translate'; -import styles from './HistoryDetails.css'; - -function HistoryDetails(props) { - const { - eventType, - sourceTitle, - data, - downloadId, - shortDateFormat, - timeFormat - } = props; - - if (eventType === 'grabbed') { - const { - indexer, - releaseGroup, - seriesMatchType, - customFormatScore, - nzbInfoUrl, - downloadClient, - downloadClientName, - age, - ageHours, - ageMinutes, - publishedDate - } = data; - - const downloadClientNameInfo = downloadClientName ?? downloadClient; - - return ( - - - - { - indexer ? - : - null - } - - { - releaseGroup ? - : - null - } - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - { - seriesMatchType ? - : - null - } - - { - nzbInfoUrl ? - - - {translate('InfoUrl')} - - - - {nzbInfoUrl} - - : - null - } - - { - downloadClientNameInfo ? - : - null - } - - { - downloadId ? - : - null - } - - { - age || ageHours || ageMinutes ? - : - null - } - - { - publishedDate ? - : - null - } - - ); - } - - if (eventType === 'downloadFailed') { - const { - message - } = data; - - return ( - - - - { - downloadId ? - : - null - } - - { - message ? - : - null - } - - ); - } - - if (eventType === 'downloadFolderImported') { - const { - customFormatScore, - droppedPath, - importedPath - } = data; - - return ( - - - - { - droppedPath ? - : - null - } - - { - importedPath ? - : - null - } - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - ); - } - - if (eventType === 'episodeFileDeleted') { - const { - reason, - customFormatScore - } = data; - - let reasonMessage = ''; - - switch (reason) { - case 'Manual': - reasonMessage = translate('DeletedReasonManual'); - break; - case 'MissingFromDisk': - reasonMessage = translate('DeletedReasonEpisodeMissingFromDisk'); - break; - case 'Upgrade': - reasonMessage = translate('DeletedReasonUpgrade'); - break; - default: - reasonMessage = ''; - } - - return ( - - - - - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - ); - } - - if (eventType === 'episodeFileRenamed') { - const { - sourcePath, - sourceRelativePath, - path, - relativePath - } = data; - - return ( - - - - - - - - - - ); - } - - if (eventType === 'downloadIgnored') { - const { - message - } = data; - - return ( - - - - { - downloadId ? - : - null - } - - { - message ? - : - null - } - - ); - } - - return ( - - - - ); -} - -HistoryDetails.propTypes = { - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - downloadId: PropTypes.string, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetails.tsx b/frontend/src/Activity/History/Details/HistoryDetails.tsx new file mode 100644 index 000000000..f460ec433 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.tsx @@ -0,0 +1,321 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import Link from 'Components/Link/Link'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { + DownloadFailedHistory, + DownloadFolderImportedHistory, + DownloadIgnoredHistory, + EpisodeFileDeletedHistory, + EpisodeFileRenamedHistory, + GrabbedHistoryData, + HistoryData, + HistoryEventType, +} from 'typings/History'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; +import styles from './HistoryDetails.css'; + +interface HistoryDetailsProps { + eventType: HistoryEventType; + sourceTitle: string; + data: HistoryData; + downloadId?: string; +} + +function HistoryDetails(props: HistoryDetailsProps) { + const { eventType, sourceTitle, data, downloadId } = props; + + const { shortDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + if (eventType === 'grabbed') { + const { + indexer, + releaseGroup, + seriesMatchType, + releaseSource, + customFormatScore, + nzbInfoUrl, + downloadClient, + downloadClientName, + age, + ageHours, + ageMinutes, + publishedDate, + } = data as GrabbedHistoryData; + + const downloadClientNameInfo = downloadClientName ?? downloadClient; + + let releaseSourceMessage = ''; + + switch (releaseSource) { + case 'Unknown': + releaseSourceMessage = translate('Unknown'); + break; + case 'Rss': + releaseSourceMessage = translate('Rss'); + break; + case 'Search': + releaseSourceMessage = translate('Search'); + break; + case 'UserInvokedSearch': + releaseSourceMessage = translate('UserInvokedSearch'); + break; + case 'InteractiveSearch': + releaseSourceMessage = translate('InteractiveSearch'); + break; + case 'ReleasePush': + releaseSourceMessage = translate('ReleasePush'); + break; + default: + releaseSourceMessage = ''; + } + + return ( + + + + {indexer ? ( + + ) : null} + + {releaseGroup ? ( + + ) : null} + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + {seriesMatchType ? ( + + ) : null} + + {releaseSource ? ( + + ) : null} + + {nzbInfoUrl ? ( + + + {translate('InfoUrl')} + + + + {nzbInfoUrl} + + + ) : null} + + {downloadClientNameInfo ? ( + + ) : null} + + {downloadId ? ( + + ) : null} + + {age || ageHours || ageMinutes ? ( + + ) : null} + + {publishedDate ? ( + + ) : null} + + ); + } + + if (eventType === 'downloadFailed') { + const { message } = data as DownloadFailedHistory; + + return ( + + + + {downloadId ? ( + + ) : null} + + {message ? ( + + ) : null} + + ); + } + + if (eventType === 'downloadFolderImported') { + const { customFormatScore, droppedPath, importedPath } = + data as DownloadFolderImportedHistory; + + return ( + + + + {droppedPath ? ( + + ) : null} + + {importedPath ? ( + + ) : null} + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + ); + } + + if (eventType === 'episodeFileDeleted') { + const { reason, customFormatScore } = data as EpisodeFileDeletedHistory; + + let reasonMessage = ''; + + switch (reason) { + case 'Manual': + reasonMessage = translate('DeletedReasonManual'); + break; + case 'MissingFromDisk': + reasonMessage = translate('DeletedReasonEpisodeMissingFromDisk'); + break; + case 'Upgrade': + reasonMessage = translate('DeletedReasonUpgrade'); + break; + default: + reasonMessage = ''; + } + + return ( + + + + + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + ); + } + + if (eventType === 'episodeFileRenamed') { + const { sourcePath, sourceRelativePath, path, relativePath } = + data as EpisodeFileRenamedHistory; + + return ( + + + + + + + + + + ); + } + + if (eventType === 'downloadIgnored') { + const { message } = data as DownloadIgnoredHistory; + + return ( + + + + {downloadId ? ( + + ) : null} + + {message ? ( + + ) : null} + + ); + } + + return ( + + + + ); +} + +export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js deleted file mode 100644 index 0848c7905..000000000 --- a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js +++ /dev/null @@ -1,19 +0,0 @@ -import _ from 'lodash'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import HistoryDetails from './HistoryDetails'; - -function createMapStateToProps() { - return createSelector( - createUISettingsSelector(), - (uiSettings) => { - return _.pick(uiSettings, [ - 'shortDateFormat', - 'timeFormat' - ]); - } - ); -} - -export default connect(createMapStateToProps)(HistoryDetails); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx similarity index 52% rename from frontend/src/Activity/History/Details/HistoryDetailsModal.js rename to frontend/src/Activity/History/Details/HistoryDetailsModal.tsx index ddeea5b78..8134a9736 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.js +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; @@ -8,11 +7,12 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; +import { HistoryData, HistoryEventType } from 'typings/History'; import translate from 'Utilities/String/translate'; import HistoryDetails from './HistoryDetails'; import styles from './HistoryDetailsModal.css'; -function getHeaderTitle(eventType) { +function getHeaderTitle(eventType: HistoryEventType) { switch (eventType) { case 'grabbed': return translate('Grabbed'); @@ -31,29 +31,33 @@ function getHeaderTitle(eventType) { } } -function HistoryDetailsModal(props) { +interface HistoryDetailsModalProps { + isOpen: boolean; + eventType: HistoryEventType; + sourceTitle: string; + data: HistoryData; + downloadId?: string; + isMarkingAsFailed: boolean; + onMarkAsFailedPress: () => void; + onModalClose: () => void; +} + +function HistoryDetailsModal(props: HistoryDetailsModalProps) { const { isOpen, eventType, sourceTitle, data, downloadId, - isMarkingAsFailed, - shortDateFormat, - timeFormat, + isMarkingAsFailed = false, onMarkAsFailedPress, - onModalClose + onModalClose, } = props; return ( - + - - {getHeaderTitle(eventType)} - + {getHeaderTitle(eventType)} - { - eventType === 'grabbed' && - - {translate('MarkAsFailed')} - - } + {eventType === 'grabbed' && ( + + {translate('MarkAsFailed')} + + )} - + ); } -HistoryDetailsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - downloadId: PropTypes.string, - isMarkingAsFailed: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onMarkAsFailedPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -HistoryDetailsModal.defaultProps = { - isMarkingAsFailed: false -}; - export default HistoryDetailsModal; diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js deleted file mode 100644 index e5cc31ecd..000000000 --- a/frontend/src/Activity/History/History.js +++ /dev/null @@ -1,180 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import TablePager from 'Components/Table/TablePager'; -import { align, icons, kinds } from 'Helpers/Props'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import translate from 'Utilities/String/translate'; -import HistoryFilterModal from './HistoryFilterModal'; -import HistoryRowConnector from './HistoryRowConnector'; - -class History extends Component { - - // - // Lifecycle - - shouldComponentUpdate(nextProps) { - // Don't update when fetching has completed if items have changed, - // before episodes start fetching or when episodes start fetching. - - if ( - ( - this.props.isFetching && - nextProps.isPopulated && - hasDifferentItems(this.props.items, nextProps.items) - ) || - (!this.props.isEpisodesFetching && nextProps.isEpisodesFetching) - ) { - return false; - } - - return true; - } - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - columns, - selectedFilterKey, - filters, - customFilters, - totalRecords, - isEpisodesFetching, - isEpisodesPopulated, - episodesError, - onFilterSelect, - onFirstPagePress, - ...otherProps - } = this.props; - - const isFetchingAny = isFetching || isEpisodesFetching; - const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); - const hasError = error || episodesError; - - return ( - - - - - - - - - - - - - - - - - { - isFetchingAny && !isAllPopulated && - - } - - { - !isFetchingAny && hasError && - - {translate('HistoryLoadError')} - - } - - { - // If history isPopulated and it's empty show no history found and don't - // wait for the episodes to populate because they are never coming. - - isPopulated && !hasError && !items.length && - - {translate('NoHistoryFound')} - - } - - { - isAllPopulated && !hasError && !!items.length && -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
- } -
-
- ); - } -} - -History.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - totalRecords: PropTypes.number, - isEpisodesFetching: PropTypes.bool.isRequired, - isEpisodesPopulated: PropTypes.bool.isRequired, - episodesError: PropTypes.object, - onFilterSelect: PropTypes.func.isRequired, - onFirstPagePress: PropTypes.func.isRequired -}; - -export default History; diff --git a/frontend/src/Activity/History/History.tsx b/frontend/src/Activity/History/History.tsx new file mode 100644 index 000000000..9f00a1ab3 --- /dev/null +++ b/frontend/src/Activity/History/History.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import usePaging from 'Components/Table/usePaging'; +import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import { align, icons, kinds } from 'Helpers/Props'; +import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; +import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import { + clearHistory, + fetchHistory, + gotoHistoryPage, + setHistoryFilter, + setHistorySort, + setHistoryTableOption, +} from 'Store/Actions/historyActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import HistoryItem from 'typings/History'; +import { TableOptionsChangePayload } from 'typings/Table'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import HistoryFilterModal from './HistoryFilterModal'; +import HistoryRow from './HistoryRow'; + +function History() { + const requestCurrentPage = useCurrentPage(); + + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + sortKey, + sortDirection, + page, + pageSize, + totalPages, + totalRecords, + } = useSelector((state: AppState) => state.history); + + const { isEpisodesFetching, isEpisodesPopulated, episodesError } = + useSelector(createEpisodesFetchingSelector()); + const customFilters = useSelector(createCustomFiltersSelector('history')); + const dispatch = useDispatch(); + + const isFetchingAny = isFetching || isEpisodesFetching; + const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); + const hasError = error || episodesError; + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoHistoryPage, + }); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string) => { + dispatch(setHistoryFilter({ selectedFilterKey })); + }, + [dispatch] + ); + + const handleSortPress = useCallback( + (sortKey: string) => { + dispatch(setHistorySort({ sortKey })); + }, + [dispatch] + ); + + const handleTableOptionChange = useCallback( + (payload: TableOptionsChangePayload) => { + dispatch(setHistoryTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoHistoryPage({ page: 1 })); + } + }, + [dispatch] + ); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchHistory()); + } else { + dispatch(gotoHistoryPage({ page: 1 })); + } + + return () => { + dispatch(clearHistory()); + dispatch(clearEpisodes()); + dispatch(clearEpisodeFiles()); + }; + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const episodeIds = selectUniqueIds(items, 'episodeId'); + + if (episodeIds.length) { + dispatch(fetchEpisodes({ episodeIds })); + } else { + dispatch(clearEpisodes()); + } + }, [items, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchHistory()); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [dispatch]); + + return ( + + + + + + + + + + + + + + + + + {isFetchingAny && !isAllPopulated ? : null} + + {!isFetchingAny && hasError ? ( + {translate('HistoryLoadError')} + ) : null} + + { + // If history isPopulated and it's empty show no history found and don't + // wait for the episodes to populate because they are never coming. + + isPopulated && !hasError && !items.length ? ( + {translate('NoHistoryFound')} + ) : null + } + + {isAllPopulated && !hasError && items.length ? ( +
+ + + {items.map((item) => { + return ( + + ); + })} + +
+ + +
+ ) : null} +
+
+ ); +} + +export default History; diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js deleted file mode 100644 index b407960bd..000000000 --- a/frontend/src/Activity/History/HistoryConnector.js +++ /dev/null @@ -1,165 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import withCurrentPage from 'Components/withCurrentPage'; -import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; -import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; -import * as historyActions from 'Store/Actions/historyActions'; -import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import History from './History'; - -function createMapStateToProps() { - return createSelector( - (state) => state.history, - (state) => state.episodes, - createCustomFiltersSelector('history'), - (history, episodes, customFilters) => { - return { - isEpisodesFetching: episodes.isFetching, - isEpisodesPopulated: episodes.isPopulated, - episodesError: episodes.error, - customFilters, - ...history - }; - } - ); -} - -const mapDispatchToProps = { - ...historyActions, - fetchEpisodes, - clearEpisodes, - clearEpisodeFiles -}; - -class HistoryConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - useCurrentPage, - fetchHistory, - gotoHistoryFirstPage - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchHistory(); - } else { - gotoHistoryFirstPage(); - } - } - - componentDidUpdate(prevProps) { - if (hasDifferentItems(prevProps.items, this.props.items)) { - const episodeIds = selectUniqueIds(this.props.items, 'episodeId'); - - if (episodeIds.length) { - this.props.fetchEpisodes({ episodeIds }); - } else { - this.props.clearEpisodes(); - } - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearHistory(); - this.props.clearEpisodes(); - this.props.clearEpisodeFiles(); - } - - // - // Control - - repopulate = () => { - this.props.fetchHistory(); - }; - - // - // Listeners - - onFirstPagePress = () => { - this.props.gotoHistoryFirstPage(); - }; - - onPreviousPagePress = () => { - this.props.gotoHistoryPreviousPage(); - }; - - onNextPagePress = () => { - this.props.gotoHistoryNextPage(); - }; - - onLastPagePress = () => { - this.props.gotoHistoryLastPage(); - }; - - onPageSelect = (page) => { - this.props.gotoHistoryPage({ page }); - }; - - onSortPress = (sortKey) => { - this.props.setHistorySort({ sortKey }); - }; - - onFilterSelect = (selectedFilterKey) => { - this.props.setHistoryFilter({ selectedFilterKey }); - }; - - onTableOptionChange = (payload) => { - this.props.setHistoryTableOption(payload); - - if (payload.pageSize) { - this.props.gotoHistoryFirstPage(); - } - }; - - // - // Render - - render() { - return ( - - ); - } -} - -HistoryConnector.propTypes = { - useCurrentPage: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchHistory: PropTypes.func.isRequired, - gotoHistoryFirstPage: PropTypes.func.isRequired, - gotoHistoryPreviousPage: PropTypes.func.isRequired, - gotoHistoryNextPage: PropTypes.func.isRequired, - gotoHistoryLastPage: PropTypes.func.isRequired, - gotoHistoryPage: PropTypes.func.isRequired, - setHistorySort: PropTypes.func.isRequired, - setHistoryFilter: PropTypes.func.isRequired, - setHistoryTableOption: PropTypes.func.isRequired, - clearHistory: PropTypes.func.isRequired, - fetchEpisodes: PropTypes.func.isRequired, - clearEpisodes: PropTypes.func.isRequired, - clearEpisodeFiles: PropTypes.func.isRequired -}; - -export default withCurrentPage( - connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector) -); diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.tsx similarity index 60% rename from frontend/src/Activity/History/HistoryEventTypeCell.js rename to frontend/src/Activity/History/HistoryEventTypeCell.tsx index 2f5ef6ee1..adedf08c0 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.js +++ b/frontend/src/Activity/History/HistoryEventTypeCell.tsx @@ -1,12 +1,17 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons, kinds } from 'Helpers/Props'; +import { + EpisodeFileDeletedHistory, + GrabbedHistoryData, + HistoryData, + HistoryEventType, +} from 'typings/History'; import translate from 'Utilities/String/translate'; import styles from './HistoryEventTypeCell.css'; -function getIconName(eventType, data) { +function getIconName(eventType: HistoryEventType, data: HistoryData) { switch (eventType) { case 'grabbed': return icons.DOWNLOADING; @@ -17,7 +22,9 @@ function getIconName(eventType, data) { case 'downloadFailed': return icons.DOWNLOADING; case 'episodeFileDeleted': - return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE; + return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk' + ? icons.FILE_MISSING + : icons.DELETE; case 'episodeFileRenamed': return icons.ORGANIZE; case 'downloadIgnored': @@ -27,7 +34,7 @@ function getIconName(eventType, data) { } } -function getIconKind(eventType) { +function getIconKind(eventType: HistoryEventType) { switch (eventType) { case 'downloadFailed': return kinds.DANGER; @@ -36,10 +43,13 @@ function getIconKind(eventType) { } } -function getTooltip(eventType, data) { +function getTooltip(eventType: HistoryEventType, data: HistoryData) { switch (eventType) { case 'grabbed': - return translate('EpisodeGrabbedTooltip', { indexer: data.indexer, downloadClient: data.downloadClient }); + return translate('EpisodeGrabbedTooltip', { + indexer: (data as GrabbedHistoryData).indexer, + downloadClient: (data as GrabbedHistoryData).downloadClient, + }); case 'seriesFolderImported': return translate('SeriesFolderImportedTooltip'); case 'downloadFolderImported': @@ -47,7 +57,9 @@ function getTooltip(eventType, data) { case 'downloadFailed': return translate('DownloadFailedEpisodeTooltip'); case 'episodeFileDeleted': - return data.reason === 'MissingFromDisk' ? translate('EpisodeFileMissingTooltip') : translate('EpisodeFileDeletedTooltip'); + return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk' + ? translate('EpisodeFileMissingTooltip') + : translate('EpisodeFileDeletedTooltip'); case 'episodeFileRenamed': return translate('EpisodeFileRenamedTooltip'); case 'downloadIgnored': @@ -57,31 +69,21 @@ function getTooltip(eventType, data) { } } -function HistoryEventTypeCell({ eventType, data }) { +interface HistoryEventTypeCellProps { + eventType: HistoryEventType; + data: HistoryData; +} + +function HistoryEventTypeCell({ eventType, data }: HistoryEventTypeCellProps) { const iconName = getIconName(eventType, data); const iconKind = getIconKind(eventType); const tooltip = getTooltip(eventType, data); return ( - - + + ); } -HistoryEventTypeCell.propTypes = { - eventType: PropTypes.string.isRequired, - data: PropTypes.object -}; - -HistoryEventTypeCell.defaultProps = { - data: {} -}; - export default HistoryEventTypeCell; diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js deleted file mode 100644 index 2b19e6970..000000000 --- a/frontend/src/Activity/History/HistoryRow.js +++ /dev/null @@ -1,312 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRow from 'Components/Table/TableRow'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import episodeEntities from 'Episode/episodeEntities'; -import EpisodeFormats from 'Episode/EpisodeFormats'; -import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; -import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; -import { icons, tooltipPositions } from 'Helpers/Props'; -import SeriesTitleLink from 'Series/SeriesTitleLink'; -import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; -import HistoryDetailsModal from './Details/HistoryDetailsModal'; -import HistoryEventTypeCell from './HistoryEventTypeCell'; -import styles from './HistoryRow.css'; - -class HistoryRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - componentDidUpdate(prevProps) { - if ( - prevProps.isMarkingAsFailed && - !this.props.isMarkingAsFailed && - !this.props.markAsFailedError - ) { - this.setState({ isDetailsModalOpen: false }); - } - } - - // - // Listeners - - onDetailsPress = () => { - this.setState({ isDetailsModalOpen: true }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - }; - - // - // Render - - render() { - const { - episodeId, - series, - episode, - languages, - quality, - customFormats, - customFormatScore, - qualityCutoffNotMet, - eventType, - sourceTitle, - date, - data, - downloadId, - isMarkingAsFailed, - columns, - shortDateFormat, - timeFormat, - onMarkAsFailedPress - } = this.props; - - if (!episode) { - return null; - } - - return ( - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'eventType') { - return ( - - ); - } - - if (name === 'series.sortTitle') { - return ( - - - - ); - } - - if (name === 'episode') { - return ( - - - - ); - } - - if (name === 'episodes.title') { - return ( - - - - ); - } - - if (name === 'languages') { - return ( - - - - ); - } - - if (name === 'quality') { - return ( - - - - ); - } - - if (name === 'customFormats') { - return ( - - - - ); - } - - if (name === 'date') { - return ( - - ); - } - - if (name === 'downloadClient') { - return ( - - {data.downloadClient} - - ); - } - - if (name === 'indexer') { - return ( - - {data.indexer} - - ); - } - - if (name === 'customFormatScore') { - return ( - - } - position={tooltipPositions.BOTTOM} - /> - - ); - } - - if (name === 'releaseGroup') { - return ( - - {data.releaseGroup} - - ); - } - - if (name === 'sourceTitle') { - return ( - - {sourceTitle} - - ); - } - - if (name === 'details') { - return ( - -
- -
-
- ); - } - - return null; - }) - } - - -
- ); - } - -} - -HistoryRow.propTypes = { - episodeId: PropTypes.number, - series: PropTypes.object.isRequired, - episode: PropTypes.object, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - customFormatScore: PropTypes.number.isRequired, - qualityCutoffNotMet: PropTypes.bool.isRequired, - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - date: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - downloadId: PropTypes.string, - isMarkingAsFailed: PropTypes.bool, - markAsFailedError: PropTypes.object, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onMarkAsFailedPress: PropTypes.func.isRequired -}; - -HistoryRow.defaultProps = { - customFormats: [] -}; - -export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRow.tsx b/frontend/src/Activity/History/HistoryRow.tsx new file mode 100644 index 000000000..d1ba279dc --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.tsx @@ -0,0 +1,270 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeFormats from 'Episode/EpisodeFormats'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import useEpisode from 'Episode/useEpisode'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import useSeries from 'Series/useSeries'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import CustomFormat from 'typings/CustomFormat'; +import { HistoryData, HistoryEventType } from 'typings/History'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import HistoryDetailsModal from './Details/HistoryDetailsModal'; +import HistoryEventTypeCell from './HistoryEventTypeCell'; +import styles from './HistoryRow.css'; + +interface HistoryRowProps { + id: number; + episodeId: number; + seriesId: number; + languages: Language[]; + quality: QualityModel; + customFormats?: CustomFormat[]; + customFormatScore: number; + qualityCutoffNotMet: boolean; + eventType: HistoryEventType; + sourceTitle: string; + date: string; + data: HistoryData; + downloadId?: string; + isMarkingAsFailed?: boolean; + markAsFailedError?: object; + columns: Column[]; +} + +function HistoryRow(props: HistoryRowProps) { + const { + id, + episodeId, + seriesId, + languages, + quality, + customFormats = [], + customFormatScore, + qualityCutoffNotMet, + eventType, + sourceTitle, + date, + data, + downloadId, + isMarkingAsFailed = false, + markAsFailedError, + columns, + } = props; + + const wasMarkingAsFailed = usePrevious(isMarkingAsFailed); + const dispatch = useDispatch(); + const series = useSeries(seriesId); + const episode = useEpisode(episodeId, 'episodes'); + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const handleDetailsPress = useCallback(() => { + setIsDetailsModalOpen(true); + }, [setIsDetailsModalOpen]); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, [setIsDetailsModalOpen]); + + const handleMarkAsFailedPress = useCallback(() => { + dispatch(markAsFailed({ id })); + }, [id, dispatch]); + + useEffect(() => { + if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) { + setIsDetailsModalOpen(false); + dispatch(fetchHistory()); + } + }, [ + wasMarkingAsFailed, + isMarkingAsFailed, + markAsFailedError, + setIsDetailsModalOpen, + dispatch, + ]); + + if (!series || !episode) { + return null; + } + + return ( + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'eventType') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'episode') { + return ( + + + + ); + } + + if (name === 'episodes.title') { + return ( + + + + ); + } + + if (name === 'languages') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'date') { + return ; + } + + if (name === 'downloadClient') { + const downloadClientName = + 'downloadClientName' in data ? data.downloadClientName : null; + const downloadClient = + 'downloadClient' in data ? data.downloadClient : null; + + return ( + + {downloadClientName ?? downloadClient ?? ''} + + ); + } + + if (name === 'indexer') { + return ( + + {'indexer' in data ? data.indexer : ''} + + ); + } + + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + + if (name === 'releaseGroup') { + return ( + + {'releaseGroup' in data ? data.releaseGroup : ''} + + ); + } + + if (name === 'sourceTitle') { + return {sourceTitle}; + } + + if (name === 'details') { + return ( + + + + ); + } + + return null; + })} + + + + ); +} + +export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js deleted file mode 100644 index b5d6223f6..000000000 --- a/frontend/src/Activity/History/HistoryRowConnector.js +++ /dev/null @@ -1,76 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; -import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import HistoryRow from './HistoryRow'; - -function createMapStateToProps() { - return createSelector( - createSeriesSelector(), - createEpisodeSelector(), - createUISettingsSelector(), - (series, episode, uiSettings) => { - return { - series, - episode, - shortDateFormat: uiSettings.shortDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -const mapDispatchToProps = { - fetchHistory, - markAsFailed -}; - -class HistoryRowConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps) { - if ( - prevProps.isMarkingAsFailed && - !this.props.isMarkingAsFailed && - !this.props.markAsFailedError - ) { - this.props.fetchHistory(); - } - } - - // - // Listeners - - onMarkAsFailedPress = () => { - this.props.markAsFailed({ id: this.props.id }); - }; - - // - // Render - - render() { - return ( - - ); - } - -} - -HistoryRowConnector.propTypes = { - id: PropTypes.number.isRequired, - isMarkingAsFailed: PropTypes.bool, - markAsFailedError: PropTypes.object, - fetchHistory: PropTypes.func.isRequired, - markAsFailed: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector); diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css index 110c7e01c..c94e383b1 100644 --- a/frontend/src/Activity/Queue/ProtocolLabel.css +++ b/frontend/src/Activity/Queue/ProtocolLabel.css @@ -11,3 +11,7 @@ border-color: var(--usenetColor); background-color: var(--usenetColor); } + +.unknown { + composes: label from '~Components/Label.css'; +} diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts b/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts index f3b389e3d..ba0cb260d 100644 --- a/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts +++ b/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'torrent': string; + 'unknown': string; 'usenet': string; } export const cssExports: CssExports; diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js deleted file mode 100644 index e8a08943c..000000000 --- a/frontend/src/Activity/Queue/ProtocolLabel.js +++ /dev/null @@ -1,20 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Label from 'Components/Label'; -import styles from './ProtocolLabel.css'; - -function ProtocolLabel({ protocol }) { - const protocolName = protocol === 'usenet' ? 'nzb' : protocol; - - return ( - - ); -} - -ProtocolLabel.propTypes = { - protocol: PropTypes.string.isRequired -}; - -export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/ProtocolLabel.tsx b/frontend/src/Activity/Queue/ProtocolLabel.tsx new file mode 100644 index 000000000..c1824452a --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import Label from 'Components/Label'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import styles from './ProtocolLabel.css'; + +interface ProtocolLabelProps { + protocol: DownloadProtocol; +} + +function ProtocolLabel({ protocol }: ProtocolLabelProps) { + const protocolName = protocol === 'usenet' ? 'nzb' : protocol; + + return ; +} + +export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js deleted file mode 100644 index 633357b7e..000000000 --- a/frontend/src/Activity/Queue/Queue.js +++ /dev/null @@ -1,364 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -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 Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import TablePager from 'Components/Table/TablePager'; -import { align, icons, kinds } from 'Helpers/Props'; -import getRemovedItems from 'Utilities/Object/getRemovedItems'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import translate from 'Utilities/String/translate'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import QueueFilterModal from './QueueFilterModal'; -import QueueOptionsConnector from './QueueOptionsConnector'; -import QueueRowConnector from './QueueRowConnector'; -import RemoveQueueItemsModal from './RemoveQueueItemsModal'; - -class Queue extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._shouldBlockRefresh = false; - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {}, - isPendingSelected: false, - isConfirmRemoveModalOpen: false, - items: props.items - }; - } - - shouldComponentUpdate() { - if (this._shouldBlockRefresh) { - return false; - } - - return true; - } - - componentDidUpdate(prevProps) { - const { - items, - isEpisodesFetching - } = this.props; - - if ( - (!isEpisodesFetching && prevProps.isEpisodesFetching) || - (hasDifferentItems(prevProps.items, items) && !items.some((e) => e.episodeId)) - ) { - this.setState((state) => { - return { - ...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)), - items - }; - }); - - return; - } - - const nextState = {}; - - if (prevProps.items !== items) { - nextState.items = items; - } - - const selectedIds = this.getSelectedIds(); - const isPendingSelected = _.some(this.props.items, (item) => { - return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; - }); - - if (isPendingSelected !== this.state.isPendingSelected) { - nextState.isPendingSelected = isPendingSelected; - } - - if (!_.isEmpty(nextState)) { - this.setState(nextState); - } - } - - // - // Control - - getSelectedIds = () => { - return getSelectedIds(this.state.selectedState); - }; - - // - // Listeners - - onQueueRowModalOpenOrClose = (isOpen) => { - this._shouldBlockRefresh = isOpen; - }; - - onSelectAllChange = ({ value }) => { - this.setState(selectAll(this.state.selectedState, value)); - }; - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - }; - - onGrabSelectedPress = () => { - this.props.onGrabSelectedPress(this.getSelectedIds()); - }; - - onRemoveSelectedPress = () => { - this.setState({ isConfirmRemoveModalOpen: true }, () => { - this._shouldBlockRefresh = true; - }); - }; - - onRemoveSelectedConfirmed = (payload) => { - this._shouldBlockRefresh = false; - this.props.onRemoveSelectedPress({ ids: this.getSelectedIds(), ...payload }); - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - onConfirmRemoveModalClose = () => { - this._shouldBlockRefresh = false; - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - isEpisodesFetching, - isEpisodesPopulated, - episodesError, - columns, - selectedFilterKey, - filters, - customFilters, - count, - totalRecords, - isGrabbing, - isRemoving, - isRefreshMonitoredDownloadsExecuting, - onRefreshPress, - onFilterSelect, - ...otherProps - } = this.props; - - const { - allSelected, - allUnselected, - selectedState, - isConfirmRemoveModalOpen, - isPendingSelected, - items - } = this.state; - - const isRefreshing = isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; - const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId)); - const hasError = error || episodesError; - const selectedIds = this.getSelectedIds(); - const selectedCount = selectedIds.length; - const disableSelectedActions = selectedCount === 0; - - return ( - - - - - - - - - - - - - - - - - - - - - - - { - isRefreshing && !isAllPopulated ? - : - null - } - - { - !isRefreshing && hasError ? - - {translate('QueueLoadError')} - : - null - } - - { - isAllPopulated && !hasError && !items.length ? - - { - selectedFilterKey !== 'all' && count > 0 ? - translate('QueueFilterHasNoItems') : - translate('QueueIsEmpty') - } - : - null - } - - { - isAllPopulated && !hasError && !!items.length ? -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
: - null - } -
- - { - const item = items.find((i) => i.id === id); - - return !!(item && item.seriesId && item.episodeId); - }) - )} - allPending={isConfirmRemoveModalOpen && ( - selectedIds.every((id) => { - const item = items.find((i) => i.id === id); - - if (!item) { - return false; - } - - return item.status === 'delay' || item.status === 'downloadClientUnavailable'; - }) - )} - onRemovePress={this.onRemoveSelectedConfirmed} - onModalClose={this.onConfirmRemoveModalClose} - /> -
- ); - } -} - -Queue.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - isEpisodesFetching: PropTypes.bool.isRequired, - isEpisodesPopulated: PropTypes.bool.isRequired, - episodesError: PropTypes.object, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - count: PropTypes.number.isRequired, - totalRecords: PropTypes.number, - isGrabbing: PropTypes.bool.isRequired, - isRemoving: PropTypes.bool.isRequired, - isRefreshMonitoredDownloadsExecuting: PropTypes.bool.isRequired, - onRefreshPress: PropTypes.func.isRequired, - onGrabSelectedPress: PropTypes.func.isRequired, - onRemoveSelectedPress: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired -}; - -Queue.defaultProps = { - count: 0 -}; - -export default Queue; diff --git a/frontend/src/Activity/Queue/Queue.tsx b/frontend/src/Activity/Queue/Queue.tsx new file mode 100644 index 000000000..bd063e69a --- /dev/null +++ b/frontend/src/Activity/Queue/Queue.tsx @@ -0,0 +1,415 @@ +import React, { + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +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 Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import usePaging from 'Components/Table/usePaging'; +import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { align, icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; +import { + clearQueue, + fetchQueue, + gotoQueuePage, + grabQueueItems, + removeQueueItems, + setQueueFilter, + setQueueSort, + setQueueTableOption, +} from 'Store/Actions/queueActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { CheckInputChanged } from 'typings/inputs'; +import { SelectStateInputProps } from 'typings/props'; +import QueueItem from 'typings/Queue'; +import { TableOptionsChangePayload } from 'typings/Table'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import QueueFilterModal from './QueueFilterModal'; +import QueueOptions from './QueueOptions'; +import QueueRow from './QueueRow'; +import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; +import createQueueStatusSelector from './Status/createQueueStatusSelector'; + +function Queue() { + const requestCurrentPage = useCurrentPage(); + const dispatch = useDispatch(); + + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + sortKey, + sortDirection, + page, + pageSize, + totalPages, + totalRecords, + isGrabbing, + isRemoving, + } = useSelector((state: AppState) => state.queue.paged); + + const { count } = useSelector(createQueueStatusSelector()); + const { isEpisodesFetching, isEpisodesPopulated, episodesError } = + useSelector(createEpisodesFetchingSelector()); + const customFilters = useSelector(createCustomFiltersSelector('queue')); + + const isRefreshMonitoredDownloadsExecuting = useSelector( + createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS) + ); + + const shouldBlockRefresh = useRef(false); + const currentQueue = useRef(null); + + const [selectState, setSelectState] = useSelectState(); + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const isPendingSelected = useMemo(() => { + return items.some((item) => { + return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; + }); + }, [items, selectedIds]); + + const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = + useState(false); + + const isRefreshing = + isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; + const isAllPopulated = + isPopulated && + (isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId)); + const hasError = error || episodesError; + const selectedCount = selectedIds.length; + const disableSelectedActions = selectedCount === 0; + + const handleSelectAllChange = useCallback( + ({ value }: CheckInputChanged) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const handleSelectedChange = useCallback( + ({ id, value, shiftKey = false }: SelectStateInputProps) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const handleRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: commandNames.REFRESH_MONITORED_DOWNLOADS, + }) + ); + }, [dispatch]); + + const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => { + shouldBlockRefresh.current = isOpen; + }, []); + + const handleGrabSelectedPress = useCallback(() => { + dispatch(grabQueueItems({ ids: selectedIds })); + }, [selectedIds, dispatch]); + + const handleRemoveSelectedPress = useCallback(() => { + shouldBlockRefresh.current = true; + setIsConfirmRemoveModalOpen(true); + }, [setIsConfirmRemoveModalOpen]); + + const handleRemoveSelectedConfirmed = useCallback( + (payload: RemovePressProps) => { + shouldBlockRefresh.current = false; + dispatch(removeQueueItems({ ids: selectedIds, ...payload })); + setIsConfirmRemoveModalOpen(false); + }, + [selectedIds, setIsConfirmRemoveModalOpen, dispatch] + ); + + const handleConfirmRemoveModalClose = useCallback(() => { + shouldBlockRefresh.current = false; + setIsConfirmRemoveModalOpen(false); + }, [setIsConfirmRemoveModalOpen]); + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoQueuePage, + }); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string) => { + dispatch(setQueueFilter({ selectedFilterKey })); + }, + [dispatch] + ); + + const handleSortPress = useCallback( + (sortKey: string) => { + dispatch(setQueueSort({ sortKey })); + }, + [dispatch] + ); + + const handleTableOptionChange = useCallback( + (payload: TableOptionsChangePayload) => { + dispatch(setQueueTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoQueuePage({ page: 1 })); + } + }, + [dispatch] + ); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchQueue()); + } else { + dispatch(gotoQueuePage({ page: 1 })); + } + + return () => { + dispatch(clearQueue()); + }; + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const episodeIds = selectUniqueIds( + items, + 'episodeId' + ); + + if (episodeIds.length) { + dispatch(fetchEpisodes({ episodeIds })); + } else { + dispatch(clearEpisodes()); + } + }, [items, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchQueue()); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [dispatch]); + + if (!shouldBlockRefresh.current) { + currentQueue.current = ( + + {isRefreshing && !isAllPopulated ? : null} + + {!isRefreshing && hasError ? ( + {translate('QueueLoadError')} + ) : null} + + {isAllPopulated && !hasError && !items.length ? ( + + {selectedFilterKey !== 'all' && count > 0 + ? translate('QueueFilterHasNoItems') + : translate('QueueIsEmpty')} + + ) : null} + + {isAllPopulated && !hasError && !!items.length ? ( +
+ + + {items.map((item) => { + return ( + + ); + })} + +
+ + +
+ ) : null} +
+ ); + } + + return ( + + + + + + + + + + + + + + + + + + + + + + {currentQueue.current} + + { + const item = items.find((i) => i.id === id); + + return !!(item && item.downloadClientHasPostImportCategory); + }) + } + canIgnore={ + isConfirmRemoveModalOpen && + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + return !!(item && item.seriesId && item.episodeId); + }) + } + isPending={ + isConfirmRemoveModalOpen && + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + if (!item) { + return false; + } + + return ( + item.status === 'delay' || + item.status === 'downloadClientUnavailable' + ); + }) + } + onRemovePress={handleRemoveSelectedConfirmed} + onModalClose={handleConfirmRemoveModalClose} + /> + + ); +} + +export default Queue; diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js deleted file mode 100644 index 178cb8e5f..000000000 --- a/frontend/src/Activity/Queue/QueueConnector.js +++ /dev/null @@ -1,203 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withCurrentPage from 'Components/withCurrentPage'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; -import * as queueActions from 'Store/Actions/queueActions'; -import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import Queue from './Queue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.episodes, - (state) => state.queue.options, - (state) => state.queue.paged, - (state) => state.queue.status.item, - createCustomFiltersSelector('queue'), - createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS), - (episodes, options, queue, status, customFilters, isRefreshMonitoredDownloadsExecuting) => { - return { - count: options.includeUnknownSeriesItems ? status.totalCount : status.count, - isEpisodesFetching: episodes.isFetching, - isEpisodesPopulated: episodes.isPopulated, - episodesError: episodes.error, - customFilters, - isRefreshMonitoredDownloadsExecuting, - ...options, - ...queue - }; - } - ); -} - -const mapDispatchToProps = { - ...queueActions, - fetchEpisodes, - clearEpisodes, - executeCommand -}; - -class QueueConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - useCurrentPage, - fetchQueue, - fetchQueueStatus, - gotoQueueFirstPage - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchQueue(); - } else { - gotoQueueFirstPage(); - } - - fetchQueueStatus(); - } - - componentDidUpdate(prevProps) { - if (hasDifferentItems(prevProps.items, this.props.items)) { - const episodeIds = selectUniqueIds(this.props.items, 'episodeId'); - - if (episodeIds.length) { - this.props.fetchEpisodes({ episodeIds }); - } else { - this.props.clearEpisodes(); - } - } - - if ( - this.props.includeUnknownSeriesItems !== - prevProps.includeUnknownSeriesItems - ) { - this.repopulate(); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearQueue(); - this.props.clearEpisodes(); - } - - // - // Control - - repopulate = () => { - this.props.fetchQueue(); - }; - - // - // Listeners - - onFirstPagePress = () => { - this.props.gotoQueueFirstPage(); - }; - - onPreviousPagePress = () => { - this.props.gotoQueuePreviousPage(); - }; - - onNextPagePress = () => { - this.props.gotoQueueNextPage(); - }; - - onLastPagePress = () => { - this.props.gotoQueueLastPage(); - }; - - onPageSelect = (page) => { - this.props.gotoQueuePage({ page }); - }; - - onSortPress = (sortKey) => { - this.props.setQueueSort({ sortKey }); - }; - - onFilterSelect = (selectedFilterKey) => { - this.props.setQueueFilter({ selectedFilterKey }); - }; - - onTableOptionChange = (payload) => { - this.props.setQueueTableOption(payload); - - if (payload.pageSize) { - this.props.gotoQueueFirstPage(); - } - }; - - onRefreshPress = () => { - this.props.executeCommand({ - name: commandNames.REFRESH_MONITORED_DOWNLOADS - }); - }; - - onGrabSelectedPress = (ids) => { - this.props.grabQueueItems({ ids }); - }; - - onRemoveSelectedPress = (payload) => { - this.props.removeQueueItems(payload); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -QueueConnector.propTypes = { - includeUnknownSeriesItems: PropTypes.bool.isRequired, - useCurrentPage: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchQueue: PropTypes.func.isRequired, - fetchQueueStatus: PropTypes.func.isRequired, - gotoQueueFirstPage: PropTypes.func.isRequired, - gotoQueuePreviousPage: PropTypes.func.isRequired, - gotoQueueNextPage: PropTypes.func.isRequired, - gotoQueueLastPage: PropTypes.func.isRequired, - gotoQueuePage: PropTypes.func.isRequired, - setQueueSort: PropTypes.func.isRequired, - setQueueFilter: PropTypes.func.isRequired, - setQueueTableOption: PropTypes.func.isRequired, - clearQueue: PropTypes.func.isRequired, - grabQueueItems: PropTypes.func.isRequired, - removeQueueItems: PropTypes.func.isRequired, - fetchEpisodes: PropTypes.func.isRequired, - clearEpisodes: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired -}; - -export default withCurrentPage( - connect(createMapStateToProps, mapDispatchToProps)(QueueConnector) -); diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.tsx similarity index 60% rename from frontend/src/Activity/Queue/QueueDetails.js rename to frontend/src/Activity/Queue/QueueDetails.tsx index abc97b75c..be70ceead 100644 --- a/frontend/src/Activity/Queue/QueueDetails.js +++ b/frontend/src/Activity/Queue/QueueDetails.tsx @@ -1,36 +1,49 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import Popover from 'Components/Tooltip/Popover'; import { icons, tooltipPositions } from 'Helpers/Props'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; import translate from 'Utilities/String/translate'; import QueueStatus from './QueueStatus'; import styles from './QueueDetails.css'; -function QueueDetails(props) { +interface QueueDetailsProps { + title: string; + size: number; + sizeleft: number; + estimatedCompletionTime?: string; + status: string; + trackedDownloadState?: QueueTrackedDownloadState; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + statusMessages?: StatusMessage[]; + errorMessage?: string; + progressBar: React.ReactNode; +} + +function QueueDetails(props: QueueDetailsProps) { const { title, size, sizeleft, status, - trackedDownloadState, - trackedDownloadStatus, + trackedDownloadState = 'downloading', + trackedDownloadStatus = 'ok', statusMessages, errorMessage, - progressBar + progressBar, } = props; - const progress = (100 - sizeleft / size * 100); + const progress = 100 - (sizeleft / size) * 100; const isDownloading = status === 'downloading'; const isPaused = status === 'paused'; const hasWarning = trackedDownloadStatus === 'warning'; const hasError = trackedDownloadStatus === 'error'; - if ( - (isDownloading || isPaused) && - !hasWarning && - !hasError - ) { + if ((isDownloading || isPaused) && !hasWarning && !hasError) { const state = isPaused ? translate('Paused') : translate('Downloading'); if (progress < 5) { @@ -45,11 +58,9 @@ function QueueDetails(props) { return ( {title} - } + body={
{title}
} position={tooltipPositions.LEFT} /> ); @@ -68,22 +79,4 @@ function QueueDetails(props) { ); } -QueueDetails.propTypes = { - title: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - estimatedCompletionTime: PropTypes.string, - status: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string, - progressBar: PropTypes.node.isRequired -}; - -QueueDetails.defaultProps = { - trackedDownloadStatus: 'ok', - trackedDownloadState: 'downloading' -}; - export default QueueDetails; diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js deleted file mode 100644 index 573b3d9c2..000000000 --- a/frontend/src/Activity/Queue/QueueOptions.js +++ /dev/null @@ -1,78 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class QueueOptions extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - includeUnknownSeriesItems: props.includeUnknownSeriesItems - }; - } - - componentDidUpdate(prevProps) { - const { - includeUnknownSeriesItems - } = this.props; - - if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) { - this.setState({ - includeUnknownSeriesItems - }); - } - } - - // - // Listeners - - onOptionChange = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onOptionChange({ - [name]: value - }); - }); - }; - - // - // Render - - render() { - const { - includeUnknownSeriesItems - } = this.state; - - return ( - - - {translate('ShowUnknownSeriesItems')} - - - - - ); - } -} - -QueueOptions.propTypes = { - includeUnknownSeriesItems: PropTypes.bool.isRequired, - onOptionChange: PropTypes.func.isRequired -}; - -export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptions.tsx b/frontend/src/Activity/Queue/QueueOptions.tsx new file mode 100644 index 000000000..17a6ac1fe --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FormGroup from 'Components/Form/FormGroup'; +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 translate from 'Utilities/String/translate'; + +function QueueOptions() { + const dispatch = useDispatch(); + const { includeUnknownSeriesItems } = useSelector( + (state: AppState) => state.queue.options + ); + + const handleOptionChange = useCallback( + ({ name, value }: CheckInputChanged) => { + dispatch( + setQueueOption({ + [name]: value, + }) + ); + + if (name === 'includeUnknownSeriesItems') { + dispatch(gotoQueuePage({ page: 1 })); + } + }, + [dispatch] + ); + + return ( + + {translate('ShowUnknownSeriesItems')} + + + + ); +} + +export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js deleted file mode 100644 index b2c99511c..000000000 --- a/frontend/src/Activity/Queue/QueueOptionsConnector.js +++ /dev/null @@ -1,19 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setQueueOption } from 'Store/Actions/queueActions'; -import QueueOptions from './QueueOptions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.queue.options, - (options) => { - return options; - } - ); -} - -const mapDispatchToProps = { - onOptionChange: setQueueOption -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions); diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css index 4a9ff08b9..459cdad8e 100644 --- a/frontend/src/Activity/Queue/QueueRow.css +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -26,4 +26,5 @@ composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 70px; + text-align: right; } diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js deleted file mode 100644 index 95ff2527e..000000000 --- a/frontend/src/Activity/Queue/QueueRow.js +++ /dev/null @@ -1,478 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; -import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import ProgressBar from 'Components/ProgressBar'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; -import TableRow from 'Components/Table/TableRow'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import EpisodeFormats from 'Episode/EpisodeFormats'; -import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; -import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; -import SeriesTitleLink from 'Series/SeriesTitleLink'; -import formatBytes from 'Utilities/Number/formatBytes'; -import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; -import translate from 'Utilities/String/translate'; -import QueueStatusCell from './QueueStatusCell'; -import RemoveQueueItemModal from './RemoveQueueItemModal'; -import TimeleftCell from './TimeleftCell'; -import styles from './QueueRow.css'; - -class QueueRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isRemoveQueueItemModalOpen: false, - isInteractiveImportModalOpen: false - }; - } - - // - // Listeners - - onRemoveQueueItemPress = () => { - this.setState({ isRemoveQueueItemModalOpen: true }); - }; - - onRemoveQueueItemModalConfirmed = (blocklist, skipRedownload) => { - const { - onRemoveQueueItemPress, - onQueueRowModalOpenOrClose - } = this.props; - - onQueueRowModalOpenOrClose(false); - onRemoveQueueItemPress(blocklist, skipRedownload); - - this.setState({ isRemoveQueueItemModalOpen: false }); - }; - - onRemoveQueueItemModalClose = () => { - this.props.onQueueRowModalOpenOrClose(false); - - this.setState({ isRemoveQueueItemModalOpen: false }); - }; - - onInteractiveImportPress = () => { - this.props.onQueueRowModalOpenOrClose(true); - - this.setState({ isInteractiveImportModalOpen: true }); - }; - - onInteractiveImportModalClose = () => { - this.props.onQueueRowModalOpenOrClose(false); - - this.setState({ isInteractiveImportModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - downloadId, - title, - status, - trackedDownloadStatus, - trackedDownloadState, - statusMessages, - errorMessage, - series, - episode, - languages, - quality, - customFormats, - customFormatScore, - protocol, - indexer, - outputPath, - downloadClient, - estimatedCompletionTime, - added, - timeleft, - size, - sizeleft, - showRelativeDates, - shortDateFormat, - timeFormat, - isGrabbing, - grabError, - isRemoving, - isSelected, - columns, - onSelectedChange, - onGrabPress - } = this.props; - - const { - isRemoveQueueItemModalOpen, - isInteractiveImportModalOpen - } = this.state; - - const progress = 100 - (sizeleft / size * 100); - const showInteractiveImport = status === 'completed' && trackedDownloadStatus === 'warning'; - const isPending = status === 'delay' || status === 'downloadClientUnavailable'; - - return ( - - - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'status') { - return ( - - ); - } - - if (name === 'series.sortTitle') { - return ( - - { - series ? - : - title - } - - ); - } - - if (name === 'episode') { - return ( - - { - episode ? - : - '-' - } - - ); - } - - if (name === 'episodes.title') { - return ( - - { - episode ? - : - '-' - } - - ); - } - - if (name === 'episodes.airDateUtc') { - if (episode) { - return ( - - ); - } - - return ( - - - - - ); - } - - if (name === 'languages') { - return ( - - - - ); - } - - if (name === 'quality') { - return ( - - { - quality ? - : - null - } - - ); - } - - if (name === 'customFormats') { - return ( - - - - ); - } - - if (name === 'customFormatScore') { - return ( - - } - position={tooltipPositions.BOTTOM} - /> - - ); - } - - if (name === 'protocol') { - return ( - - - - ); - } - - if (name === 'indexer') { - return ( - - {indexer} - - ); - } - - if (name === 'downloadClient') { - return ( - - {downloadClient} - - ); - } - - if (name === 'title') { - return ( - - {title} - - ); - } - - if (name === 'size') { - return ( - {formatBytes(size)} - ); - } - - if (name === 'outputPath') { - return ( - - {outputPath} - - ); - } - - if (name === 'estimatedCompletionTime') { - return ( - - ); - } - - if (name === 'progress') { - return ( - - { - !!progress && - - } - - ); - } - - if (name === 'added') { - return ( - - ); - } - - if (name === 'actions') { - return ( - - { - showInteractiveImport && - - } - - { - isPending && - - } - - - - ); - } - - return null; - }) - } - - - - - - ); - } - -} - -QueueRow.propTypes = { - id: PropTypes.number.isRequired, - downloadId: PropTypes.string, - title: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string, - trackedDownloadState: PropTypes.string, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string, - series: PropTypes.object, - episode: PropTypes.object, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - customFormatScore: PropTypes.number.isRequired, - protocol: PropTypes.string.isRequired, - indexer: PropTypes.string, - outputPath: PropTypes.string, - downloadClient: PropTypes.string, - estimatedCompletionTime: PropTypes.string, - added: PropTypes.string, - timeleft: PropTypes.string, - size: PropTypes.number, - sizeleft: PropTypes.number, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isGrabbing: PropTypes.bool.isRequired, - grabError: PropTypes.object, - isRemoving: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onSelectedChange: PropTypes.func.isRequired, - onGrabPress: PropTypes.func.isRequired, - onRemoveQueueItemPress: PropTypes.func.isRequired, - onQueueRowModalOpenOrClose: PropTypes.func.isRequired -}; - -QueueRow.defaultProps = { - customFormats: [], - isGrabbing: false, - isRemoving: false -}; - -export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRow.tsx b/frontend/src/Activity/Queue/QueueRow.tsx new file mode 100644 index 000000000..25f5cb410 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.tsx @@ -0,0 +1,411 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import { Error } from 'App/State/AppSectionState'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import EpisodeFormats from 'Episode/EpisodeFormats'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import useEpisode from 'Episode/useEpisode'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import useSeries from 'Series/useSeries'; +import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CustomFormat from 'typings/CustomFormat'; +import { SelectStateInputProps } from 'typings/props'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; +import formatBytes from 'Utilities/Number/formatBytes'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; +import QueueStatusCell from './QueueStatusCell'; +import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; +import TimeleftCell from './TimeleftCell'; +import styles from './QueueRow.css'; + +interface QueueRowProps { + id: number; + seriesId?: number; + episodeId?: number; + downloadId?: string; + title: string; + status: string; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + trackedDownloadState?: QueueTrackedDownloadState; + statusMessages?: StatusMessage[]; + errorMessage?: string; + languages: Language[]; + quality: QualityModel; + customFormats?: CustomFormat[]; + customFormatScore: number; + protocol: DownloadProtocol; + indexer?: string; + outputPath?: string; + downloadClient?: string; + downloadClientHasPostImportCategory?: boolean; + estimatedCompletionTime?: string; + added?: string; + timeleft?: string; + size: number; + sizeleft: number; + isGrabbing?: boolean; + grabError?: Error; + isRemoving?: boolean; + isSelected?: boolean; + columns: Column[]; + onSelectedChange: (options: SelectStateInputProps) => void; + onQueueRowModalOpenOrClose: (isOpen: boolean) => void; +} + +function QueueRow(props: QueueRowProps) { + const { + id, + seriesId, + episodeId, + downloadId, + title, + status, + trackedDownloadStatus, + trackedDownloadState, + statusMessages, + errorMessage, + languages, + quality, + customFormats = [], + customFormatScore, + protocol, + indexer, + outputPath, + downloadClient, + downloadClientHasPostImportCategory, + estimatedCompletionTime, + added, + timeleft, + size, + sizeleft, + isGrabbing = false, + grabError, + isRemoving = false, + isSelected, + columns, + onSelectedChange, + onQueueRowModalOpenOrClose, + } = props; + + const dispatch = useDispatch(); + const series = useSeries(seriesId); + const episode = useEpisode(episodeId, 'episodes'); + const { showRelativeDates, shortDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] = + useState(false); + + const [isInteractiveImportModalOpen, setIsInteractiveImportModalOpen] = + useState(false); + + const handleGrabPress = useCallback(() => { + dispatch(grabQueueItem({ id })); + }, [id, dispatch]); + + const handleInteractiveImportPress = useCallback(() => { + onQueueRowModalOpenOrClose(true); + setIsInteractiveImportModalOpen(true); + }, [setIsInteractiveImportModalOpen, onQueueRowModalOpenOrClose]); + + const handleInteractiveImportModalClose = useCallback(() => { + onQueueRowModalOpenOrClose(false); + setIsInteractiveImportModalOpen(false); + }, [setIsInteractiveImportModalOpen, onQueueRowModalOpenOrClose]); + + const handleRemoveQueueItemPress = useCallback(() => { + onQueueRowModalOpenOrClose(true); + setIsRemoveQueueItemModalOpen(true); + }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); + + const handleRemoveQueueItemModalConfirmed = useCallback( + (payload: RemovePressProps) => { + onQueueRowModalOpenOrClose(false); + dispatch(removeQueueItem({ id, ...payload })); + setIsRemoveQueueItemModalOpen(false); + }, + [id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch] + ); + + const handleRemoveQueueItemModalClose = useCallback(() => { + onQueueRowModalOpenOrClose(false); + setIsRemoveQueueItemModalOpen(false); + }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); + + const progress = 100 - (sizeleft / size) * 100; + const showInteractiveImport = + status === 'completed' && trackedDownloadStatus === 'warning'; + const isPending = + status === 'delay' || status === 'downloadClientUnavailable'; + + return ( + + + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + {series ? ( + + ) : ( + title + )} + + ); + } + + if (name === 'episode') { + return ( + + {episode ? ( + + ) : ( + '-' + )} + + ); + } + + if (name === 'episodes.title') { + return ( + + {series && episode ? ( + + ) : ( + '-' + )} + + ); + } + + if (name === 'episodes.airDateUtc') { + if (episode) { + return ; + } + + return -; + } + + if (name === 'languages') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + {quality ? : null} + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'indexer') { + return {indexer}; + } + + if (name === 'downloadClient') { + return {downloadClient}; + } + + if (name === 'title') { + return {title}; + } + + if (name === 'size') { + return {formatBytes(size)}; + } + + if (name === 'outputPath') { + return {outputPath}; + } + + if (name === 'estimatedCompletionTime') { + return ( + + ); + } + + if (name === 'progress') { + return ( + + {!!progress && ( + + )} + + ); + } + + if (name === 'added') { + return ; + } + + if (name === 'actions') { + return ( + + {showInteractiveImport ? ( + + ) : null} + + {isPending ? ( + + ) : null} + + + + ); + } + + return null; + })} + + + + + + ); +} + +export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js deleted file mode 100644 index e1e469a70..000000000 --- a/frontend/src/Activity/Queue/QueueRowConnector.js +++ /dev/null @@ -1,70 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; -import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import QueueRow from './QueueRow'; - -function createMapStateToProps() { - return createSelector( - createSeriesSelector(), - createEpisodeSelector(), - createUISettingsSelector(), - (series, episode, uiSettings) => { - const result = { - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - timeFormat: uiSettings.timeFormat - }; - - result.series = series; - result.episode = episode; - - return result; - } - ); -} - -const mapDispatchToProps = { - grabQueueItem, - removeQueueItem -}; - -class QueueRowConnector extends Component { - - // - // Listeners - - onGrabPress = () => { - this.props.grabQueueItem({ id: this.props.id }); - }; - - onRemoveQueueItemPress = (payload) => { - this.props.removeQueueItem({ id: this.props.id, ...payload }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -QueueRowConnector.propTypes = { - id: PropTypes.number.isRequired, - episode: PropTypes.object, - grabQueueItem: PropTypes.func.isRequired, - removeQueueItem: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector); diff --git a/frontend/src/Activity/Queue/QueueStatus.js b/frontend/src/Activity/Queue/QueueStatus.tsx similarity index 57% rename from frontend/src/Activity/Queue/QueueStatus.js rename to frontend/src/Activity/Queue/QueueStatus.tsx index c6e8cf5dd..31a28f35c 100644 --- a/frontend/src/Activity/Queue/QueueStatus.js +++ b/frontend/src/Activity/Queue/QueueStatus.tsx @@ -1,51 +1,59 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import Icon from 'Components/Icon'; +import Icon, { IconProps } from 'Components/Icon'; import Popover from 'Components/Tooltip/Popover'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; +import { TooltipPosition } from 'Helpers/Props/tooltipPositions'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; import translate from 'Utilities/String/translate'; import styles from './QueueStatus.css'; -function getDetailedPopoverBody(statusMessages) { +function getDetailedPopoverBody(statusMessages: StatusMessage[]) { return (
- { - statusMessages.map(({ title, messages }) => { - return ( -
- {title} -
    - { - messages.map((message) => { - return ( -
  • - {message} -
  • - ); - }) - } -
-
- ); - }) - } + {statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + {messages.map((message) => { + return
  • {message}
  • ; + })} +
+
+ ); + })}
); } -function QueueStatus(props) { +interface QueueStatusProps { + sourceTitle: string; + status: string; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + trackedDownloadState?: QueueTrackedDownloadState; + statusMessages?: StatusMessage[]; + errorMessage?: string; + position: TooltipPosition; + canFlip?: boolean; +} + +function QueueStatus(props: QueueStatusProps) { const { sourceTitle, status, - trackedDownloadStatus, - trackedDownloadState, - statusMessages, + trackedDownloadStatus = 'ok', + trackedDownloadState = 'downloading', + statusMessages = [], errorMessage, position, - canFlip + canFlip = false, } = props; const hasWarning = trackedDownloadStatus === 'warning'; @@ -53,7 +61,7 @@ function QueueStatus(props) { // status === 'downloading' let iconName = icons.DOWNLOADING; - let iconKind = kinds.DEFAULT; + let iconKind: IconProps['kind'] = kinds.DEFAULT; let title = translate('Downloading'); if (status === 'paused') { @@ -70,6 +78,11 @@ function QueueStatus(props) { iconName = icons.DOWNLOADED; title = translate('Downloaded'); + if (trackedDownloadState === 'importBlocked') { + title += ` - ${translate('UnableToImportAutomatically')}`; + iconKind = kinds.WARNING; + } + if (trackedDownloadState === 'importPending') { title += ` - ${translate('WaitingToImport')}`; iconKind = kinds.PURPLE; @@ -110,7 +123,8 @@ function QueueStatus(props) { if (status === 'warning') { iconName = icons.DOWNLOADING; iconKind = kinds.WARNING; - const warningMessage = errorMessage || translate('CheckDownloadClientForDetails'); + const warningMessage = + errorMessage || translate('CheckDownloadClientForDetails'); title = translate('DownloadWarning', { warningMessage }); } @@ -128,35 +142,17 @@ function QueueStatus(props) { return ( - } + anchor={} title={title} - body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} + body={ + hasWarning || hasError + ? getDetailedPopoverBody(statusMessages) + : sourceTitle + } position={position} canFlip={canFlip} /> ); } -QueueStatus.propTypes = { - sourceTitle: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string, - position: PropTypes.oneOf(tooltipPositions.all).isRequired, - canFlip: PropTypes.bool.isRequired -}; - -QueueStatus.defaultProps = { - trackedDownloadStatus: 'ok', - trackedDownloadState: 'downloading', - canFlip: false -}; - export default QueueStatus; diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js deleted file mode 100644 index 4e8b9658c..000000000 --- a/frontend/src/Activity/Queue/QueueStatusCell.js +++ /dev/null @@ -1,47 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import { tooltipPositions } from 'Helpers/Props'; -import QueueStatus from './QueueStatus'; -import styles from './QueueStatusCell.css'; - -function QueueStatusCell(props) { - const { - sourceTitle, - status, - trackedDownloadStatus, - trackedDownloadState, - statusMessages, - errorMessage - } = props; - - return ( - - - - ); -} - -QueueStatusCell.propTypes = { - sourceTitle: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string -}; - -QueueStatusCell.defaultProps = { - trackedDownloadStatus: 'ok', - trackedDownloadState: 'downloading' -}; - -export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/QueueStatusCell.tsx b/frontend/src/Activity/Queue/QueueStatusCell.tsx new file mode 100644 index 000000000..634e33164 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; +import QueueStatus from './QueueStatus'; +import styles from './QueueStatusCell.css'; + +interface QueueStatusCellProps { + sourceTitle: string; + status: string; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + trackedDownloadState?: QueueTrackedDownloadState; + statusMessages?: StatusMessage[]; + errorMessage?: string; +} + +function QueueStatusCell(props: QueueStatusCellProps) { + const { + sourceTitle, + status, + trackedDownloadStatus = 'ok', + trackedDownloadState = 'downloading', + statusMessages, + errorMessage, + } = props; + + return ( + + + + ); +} + +export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css similarity index 100% rename from frontend/src/Activity/Queue/RemoveQueueItemsModal.css rename to frontend/src/Activity/Queue/RemoveQueueItemModal.css diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css.d.ts b/frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts similarity index 100% rename from frontend/src/Activity/Queue/RemoveQueueItemsModal.css.d.ts rename to frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js deleted file mode 100644 index 0cf7af855..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemModal.js +++ /dev/null @@ -1,171 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class RemoveQueueItemModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - remove: true, - blocklist: false, - skipRedownload: false - }; - } - - // - // Control - - resetState = function() { - this.setState({ - remove: true, - blocklist: false, - skipRedownload: false - }); - }; - - // - // Listeners - - onRemoveChange = ({ value }) => { - this.setState({ remove: value }); - }; - - onBlocklistChange = ({ value }) => { - this.setState({ blocklist: value }); - }; - - onSkipRedownloadChange = ({ value }) => { - this.setState({ skipRedownload: value }); - }; - - onRemoveConfirmed = () => { - const state = this.state; - - this.resetState(); - this.props.onRemovePress(state); - }; - - onModalClose = () => { - this.resetState(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - const { - isOpen, - sourceTitle, - canIgnore, - isPending - } = this.props; - - const { remove, blocklist, skipRedownload } = this.state; - - return ( - - - - {translate('RemoveQueueItem', { sourceTitle })} - - - -
- {translate('RemoveQueueItemConfirmation', { sourceTitle })} -
- - { - isPending ? - null : - - {translate('RemoveFromDownloadClient')} - - - - } - - - {translate('BlocklistRelease')} - - - - - { - blocklist ? - - {translate('SkipRedownload')} - - : - null - } -
- - - - - - -
-
- ); - } -} - -RemoveQueueItemModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - sourceTitle: PropTypes.string.isRequired, - canIgnore: PropTypes.bool.isRequired, - isPending: PropTypes.bool.isRequired, - onRemovePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx new file mode 100644 index 000000000..461fa57ad --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './RemoveQueueItemModal.css'; + +export interface RemovePressProps { + remove: boolean; + changeCategory: boolean; + blocklist: boolean; + skipRedownload: boolean; +} + +interface RemoveQueueItemModalProps { + isOpen: boolean; + sourceTitle?: string; + canChangeCategory: boolean; + canIgnore: boolean; + isPending: boolean; + selectedCount?: number; + onRemovePress(props: RemovePressProps): void; + onModalClose: () => void; +} + +type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore'; +type BlocklistMethod = + | 'doNotBlocklist' + | 'blocklistAndSearch' + | 'blocklistOnly'; + +function RemoveQueueItemModal(props: RemoveQueueItemModalProps) { + const { + isOpen, + sourceTitle = '', + canIgnore, + canChangeCategory, + isPending, + selectedCount, + onRemovePress, + onModalClose, + } = props; + + const multipleSelected = selectedCount && selectedCount > 1; + + const [removalMethod, setRemovalMethod] = + useState('removeFromClient'); + const [blocklistMethod, setBlocklistMethod] = + useState('doNotBlocklist'); + + const { title, message } = useMemo(() => { + if (!selectedCount) { + return { + title: translate('RemoveQueueItem', { sourceTitle }), + message: translate('RemoveQueueItemConfirmation', { sourceTitle }), + }; + } + + if (selectedCount === 1) { + return { + title: translate('RemoveSelectedItem'), + message: translate('RemoveSelectedItemQueueMessageText'), + }; + } + + return { + title: translate('RemoveSelectedItems'), + message: translate('RemoveSelectedItemsQueueMessageText', { + selectedCount, + }), + }; + }, [sourceTitle, selectedCount]); + + const removalMethodOptions = useMemo(() => { + return [ + { + key: 'removeFromClient', + value: translate('RemoveFromDownloadClient'), + hint: multipleSelected + ? translate('RemoveMultipleFromDownloadClientHint') + : translate('RemoveFromDownloadClientHint'), + }, + { + key: 'changeCategory', + value: translate('ChangeCategory'), + isDisabled: !canChangeCategory, + hint: multipleSelected + ? translate('ChangeCategoryMultipleHint') + : translate('ChangeCategoryHint'), + }, + { + key: 'ignore', + value: multipleSelected + ? translate('IgnoreDownloads') + : translate('IgnoreDownload'), + isDisabled: !canIgnore, + hint: multipleSelected + ? translate('IgnoreDownloadsHint') + : translate('IgnoreDownloadHint'), + }, + ]; + }, [canChangeCategory, canIgnore, multipleSelected]); + + const blocklistMethodOptions = useMemo(() => { + return [ + { + key: 'doNotBlocklist', + value: translate('DoNotBlocklist'), + hint: translate('DoNotBlocklistHint'), + }, + { + key: 'blocklistAndSearch', + value: translate('BlocklistAndSearch'), + isDisabled: isPending, + hint: multipleSelected + ? translate('BlocklistAndSearchMultipleHint') + : translate('BlocklistAndSearchHint'), + }, + { + key: 'blocklistOnly', + value: translate('BlocklistOnly'), + hint: multipleSelected + ? translate('BlocklistMultipleOnlyHint') + : translate('BlocklistOnlyHint'), + }, + ]; + }, [isPending, multipleSelected]); + + const handleRemovalMethodChange = useCallback( + ({ value }: { value: RemovalMethod }) => { + setRemovalMethod(value); + }, + [setRemovalMethod] + ); + + const handleBlocklistMethodChange = useCallback( + ({ value }: { value: BlocklistMethod }) => { + setBlocklistMethod(value); + }, + [setBlocklistMethod] + ); + + const handleConfirmRemove = useCallback(() => { + onRemovePress({ + remove: removalMethod === 'removeFromClient', + changeCategory: removalMethod === 'changeCategory', + blocklist: blocklistMethod !== 'doNotBlocklist', + skipRedownload: blocklistMethod === 'blocklistOnly', + }); + + setRemovalMethod('removeFromClient'); + setBlocklistMethod('doNotBlocklist'); + }, [ + removalMethod, + blocklistMethod, + setRemovalMethod, + setBlocklistMethod, + onRemovePress, + ]); + + const handleModalClose = useCallback(() => { + setRemovalMethod('removeFromClient'); + setBlocklistMethod('doNotBlocklist'); + + onModalClose(); + }, [setRemovalMethod, setBlocklistMethod, onModalClose]); + + return ( + + + {title} + + +
{message}
+ + {isPending ? null : ( + + {translate('RemoveQueueItemRemovalMethod')} + + + + )} + + + + {multipleSelected + ? translate('BlocklistReleases') + : translate('BlocklistRelease')} + + + + +
+ + + + + + +
+
+ ); +} + +export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js deleted file mode 100644 index 18ea39aea..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js +++ /dev/null @@ -1,174 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './RemoveQueueItemsModal.css'; - -class RemoveQueueItemsModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - remove: true, - blocklist: false, - skipRedownload: false - }; - } - - // - // Control - - resetState = function() { - this.setState({ - remove: true, - blocklist: false, - skipRedownload: false - }); - }; - - // - // Listeners - - onRemoveChange = ({ value }) => { - this.setState({ remove: value }); - }; - - onBlocklistChange = ({ value }) => { - this.setState({ blocklist: value }); - }; - - onSkipRedownloadChange = ({ value }) => { - this.setState({ skipRedownload: value }); - }; - - onRemoveConfirmed = () => { - const state = this.state; - - this.resetState(); - this.props.onRemovePress(state); - }; - - onModalClose = () => { - this.resetState(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - const { - isOpen, - selectedCount, - canIgnore, - allPending - } = this.props; - - const { remove, blocklist, skipRedownload } = this.state; - - return ( - - - - {selectedCount > 1 ? translate('RemoveSelectedItems') : translate('RemoveSelectedItem')} - - - -
- {selectedCount > 1 ? translate('RemoveSelectedItemsQueueMessageText', { selectedCount }) : translate('RemoveSelectedItemQueueMessageText')} -
- - { - allPending ? - null : - - {translate('RemoveFromDownloadClient')} - - - - } - - - - {selectedCount > 1 ? translate('BlocklistReleases') : translate('BlocklistRelease')} - - - - - - { - blocklist ? - - {translate('SkipRedownload')} - - : - null - } -
- - - - - - -
-
- ); - } -} - -RemoveQueueItemsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - selectedCount: PropTypes.number.isRequired, - canIgnore: PropTypes.bool.isRequired, - allPending: PropTypes.bool.isRequired, - onRemovePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RemoveQueueItemsModal; diff --git a/frontend/src/Activity/Queue/Status/QueueStatus.tsx b/frontend/src/Activity/Queue/Status/QueueStatus.tsx new file mode 100644 index 000000000..894434e07 --- /dev/null +++ b/frontend/src/Activity/Queue/Status/QueueStatus.tsx @@ -0,0 +1,37 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { fetchQueueStatus } from 'Store/Actions/queueActions'; +import createQueueStatusSelector from './createQueueStatusSelector'; + +function QueueStatus() { + const dispatch = useDispatch(); + const { isConnected, isReconnecting } = useSelector( + (state: AppState) => state.app + ); + const { isPopulated, count, errors, warnings } = useSelector( + createQueueStatusSelector() + ); + + const wasReconnecting = usePrevious(isReconnecting); + + useEffect(() => { + if (!isPopulated) { + dispatch(fetchQueueStatus()); + } + }, [isPopulated, dispatch]); + + useEffect(() => { + if (isConnected && wasReconnecting) { + dispatch(fetchQueueStatus()); + } + }, [isConnected, wasReconnecting, dispatch]); + + return ( + + ); +} + +export default QueueStatus; diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js deleted file mode 100644 index 9412d7952..000000000 --- a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js +++ /dev/null @@ -1,76 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; -import { fetchQueueStatus } from 'Store/Actions/queueActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app, - (state) => state.queue.status, - (state) => state.queue.options.includeUnknownSeriesItems, - (app, status, includeUnknownSeriesItems) => { - const { - errors, - warnings, - unknownErrors, - unknownWarnings, - count, - totalCount - } = status.item; - - return { - isConnected: app.isConnected, - isReconnecting: app.isReconnecting, - isPopulated: status.isPopulated, - ...status.item, - count: includeUnknownSeriesItems ? totalCount : count, - errors: includeUnknownSeriesItems ? errors || unknownErrors : errors, - warnings: includeUnknownSeriesItems ? warnings || unknownWarnings : warnings - }; - } - ); -} - -const mapDispatchToProps = { - fetchQueueStatus -}; - -class QueueStatusConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - if (!this.props.isPopulated) { - this.props.fetchQueueStatus(); - } - } - - componentDidUpdate(prevProps) { - if (this.props.isConnected && prevProps.isReconnecting) { - this.props.fetchQueueStatus(); - } - } - - // - // Render - - render() { - return ( - - ); - } -} - -QueueStatusConnector.propTypes = { - isConnected: PropTypes.bool.isRequired, - isReconnecting: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - fetchQueueStatus: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector); diff --git a/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts b/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts new file mode 100644 index 000000000..4fd37b28c --- /dev/null +++ b/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createQueueStatusSelector() { + return createSelector( + (state: AppState) => state.queue.status.isPopulated, + (state: AppState) => state.queue.status.item, + (state: AppState) => state.queue.options.includeUnknownSeriesItems, + (isPopulated, status, includeUnknownSeriesItems) => { + const { + errors, + warnings, + unknownErrors, + unknownWarnings, + count, + totalCount, + } = status; + + return { + ...status, + isPopulated, + count: includeUnknownSeriesItems ? totalCount : count, + errors: includeUnknownSeriesItems ? errors || unknownErrors : errors, + warnings: includeUnknownSeriesItems + ? warnings || unknownWarnings + : warnings, + }; + } + ); +} + +export default createQueueStatusSelector; diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.tsx similarity index 70% rename from frontend/src/Activity/Queue/TimeleftCell.js rename to frontend/src/Activity/Queue/TimeleftCell.tsx index b280b5a06..917a6ad0d 100644 --- a/frontend/src/Activity/Queue/TimeleftCell.js +++ b/frontend/src/Activity/Queue/TimeleftCell.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; @@ -11,7 +10,18 @@ import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; import styles from './TimeleftCell.css'; -function TimeleftCell(props) { +interface TimeleftCellProps { + estimatedCompletionTime?: string; + timeleft?: string; + status: string; + size: number; + sizeleft: number; + showRelativeDates: boolean; + shortDateFormat: string; + timeFormat: string; +} + +function TimeleftCell(props: TimeleftCellProps) { const { estimatedCompletionTime, timeleft, @@ -20,12 +30,18 @@ function TimeleftCell(props) { sizeleft, showRelativeDates, shortDateFormat, - timeFormat + timeFormat, } = props; if (status === 'delay') { - const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); - const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + const date = getRelativeDate({ + date: estimatedCompletionTime, + shortDateFormat, + showRelativeDates, + }); + const time = formatTime(estimatedCompletionTime, timeFormat, { + includeMinuteZero: true, + }); return ( @@ -40,8 +56,14 @@ function TimeleftCell(props) { } if (status === 'downloadClientUnavailable') { - const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); - const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + const date = getRelativeDate({ + date: estimatedCompletionTime, + shortDateFormat, + showRelativeDates, + }); + const time = formatTime(estimatedCompletionTime, timeFormat, { + includeMinuteZero: true, + }); return ( @@ -56,11 +78,7 @@ function TimeleftCell(props) { } if (!timeleft || status === 'completed' || status === 'failed') { - return ( - - - - - ); + return -; } const totalSize = formatBytes(size); @@ -76,15 +94,4 @@ function TimeleftCell(props) { ); } -TimeleftCell.propTypes = { - estimatedCompletionTime: PropTypes.string, - timeleft: PropTypes.string, - status: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - export default TimeleftCell; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js index 331d4849e..18cbffddb 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js @@ -1,5 +1,6 @@ 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'; @@ -129,7 +130,8 @@ class AddNewSeries extends Component {
{translate('AddNewSeriesError')}
-
{getErrorMessage(error)}
+ + {getErrorMessage(error)} : null } diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css index 469385630..dcf3f6de3 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css @@ -69,6 +69,16 @@ height: 55px; } +.originalLanguageName, +.network, +.genres { + margin-left: 8px; +} + +.genres { + pointer-events: all; +} + .tvdbLink { composes: link from '~Components/Link/Link.css'; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts index 1380d41f3..b6fcfe361 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts @@ -3,7 +3,10 @@ interface CssExports { 'alreadyExistsIcon': string; 'content': string; + 'genres': string; 'icons': string; + 'network': string; + 'originalLanguageName': string; 'overlay': string; 'overview': string; 'poster': string; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js index 815447ca8..8ce556456 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js @@ -6,6 +6,7 @@ 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'; @@ -55,6 +56,8 @@ class AddNewSeriesSearchResult extends Component { titleSlug, year, network, + originalLanguage, + genres, status, overview, statistics, @@ -145,14 +148,49 @@ class AddNewSeriesSearchResult extends Component { + { + originalLanguage?.name ? + : + null + } + { network ? : + null + } + + { + genres.length > 0 ? + : null } @@ -218,6 +256,8 @@ AddNewSeriesSearchResult.propTypes = { 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, @@ -229,4 +269,8 @@ AddNewSeriesSearchResult.propTypes = { isSmallScreen: PropTypes.bool.isRequired }; +AddNewSeriesSearchResult.defaultProps = { + genres: [] +}; + export default AddNewSeriesSearchResult; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css index 415155274..d0c6e98ae 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css @@ -1,18 +1,10 @@ .inputContainer { margin-right: 20px; min-width: 150px; - - div { - margin-top: 10px; - - &:first-child { - margin-top: 0; - } - } } .label { - margin-bottom: 3px; + margin-bottom: 10px; font-weight: bold; } diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js deleted file mode 100644 index 781b2ca10..000000000 --- a/frontend/src/App/App.js +++ /dev/null @@ -1,31 +0,0 @@ -import { ConnectedRouter } from 'connected-react-router'; -import PropTypes from 'prop-types'; -import React from 'react'; -import DocumentTitle from 'react-document-title'; -import { Provider } from 'react-redux'; -import PageConnector from 'Components/Page/PageConnector'; -import ApplyTheme from './ApplyTheme'; -import AppRoutes from './AppRoutes'; - -function App({ store, history }) { - return ( - - - - - - - - - - - - ); -} - -App.propTypes = { - store: PropTypes.object.isRequired, - history: PropTypes.object.isRequired -}; - -export default App; diff --git a/frontend/src/App/App.tsx b/frontend/src/App/App.tsx new file mode 100644 index 000000000..b71199bb3 --- /dev/null +++ b/frontend/src/App/App.tsx @@ -0,0 +1,35 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router'; +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 ApplyTheme from './ApplyTheme'; +import AppRoutes from './AppRoutes'; + +interface AppProps { + store: Store; + history: ConnectedRouterProps['history']; +} + +const queryClient = new QueryClient(); + +function App({ store, history }: AppProps) { + return ( + + + + + + + + + + + + + ); +} + +export default App; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js deleted file mode 100644 index c2c95f96d..000000000 --- a/frontend/src/App/AppRoutes.js +++ /dev/null @@ -1,280 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Redirect, Route } from 'react-router-dom'; -import BlocklistConnector from 'Activity/Blocklist/BlocklistConnector'; -import HistoryConnector from 'Activity/History/HistoryConnector'; -import QueueConnector from 'Activity/Queue/QueueConnector'; -import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector'; -import ImportSeries from 'AddSeries/ImportSeries/ImportSeries'; -import CalendarPageConnector from 'Calendar/CalendarPageConnector'; -import NotFound from 'Components/NotFound'; -import Switch from 'Components/Router/Switch'; -import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; -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 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 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 Logs from 'System/Logs/Logs'; -import Status from 'System/Status/Status'; -import Tasks from 'System/Tasks/Tasks'; -import UpdatesConnector from 'System/Updates/UpdatesConnector'; -import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; -import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; -import MissingConnector from 'Wanted/Missing/MissingConnector'; - -function AppRoutes(props) { - const { - app - } = props; - - return ( - - {/* - Series - */} - - - - { - window.Sonarr.urlBase && - { - return ( - - ); - }} - /> - } - - - - - - { - return ( - - ); - }} - /> - - { - return ( - - ); - }} - /> - - - - {/* - Calendar - */} - - - - {/* - Activity - */} - - - - - - - - {/* - Wanted - */} - - - - - - {/* - Settings - */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* - System - */} - - - - - - - - - - - - - - {/* - Not Found - */} - - - - ); -} - -AppRoutes.propTypes = { - app: PropTypes.func.isRequired -}; - -export default AppRoutes; diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx new file mode 100644 index 000000000..fbe4a15bb --- /dev/null +++ b/frontend/src/App/AppRoutes.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +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 CalendarPage from 'Calendar/CalendarPage'; +import NotFound from 'Components/NotFound'; +import Switch from 'Components/Router/Switch'; +import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; +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 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 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 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'; + +function RedirectWithUrlBase() { + return ; +} + +function AppRoutes() { + return ( + + {/* + Series + */} + + + + {window.Sonarr.urlBase && ( + + )} + + + + + + + + + + + + {/* + Calendar + */} + + + + {/* + Activity + */} + + + + + + + + {/* + Wanted + */} + + + + + + {/* + Settings + */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* + System + */} + + + + + + + + + + + + + + {/* + Not Found + */} + + + + ); +} + +export default AppRoutes; diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js deleted file mode 100644 index abc7f8832..000000000 --- a/frontend/src/App/AppUpdatedModal.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector'; - -function AppUpdatedModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -AppUpdatedModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModal.tsx b/frontend/src/App/AppUpdatedModal.tsx new file mode 100644 index 000000000..696d36fb2 --- /dev/null +++ b/frontend/src/App/AppUpdatedModal.tsx @@ -0,0 +1,28 @@ +import React, { useCallback } from 'react'; +import Modal from 'Components/Modal/Modal'; +import AppUpdatedModalContent from './AppUpdatedModalContent'; + +interface AppUpdatedModalProps { + isOpen: boolean; + onModalClose: (...args: unknown[]) => unknown; +} + +function AppUpdatedModal(props: AppUpdatedModalProps) { + const { isOpen, onModalClose } = props; + + const handleModalClose = useCallback(() => { + location.reload(); + }, []); + + return ( + + + + ); +} + +export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js deleted file mode 100644 index a21afbc5a..000000000 --- a/frontend/src/App/AppUpdatedModalConnector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import AppUpdatedModal from './AppUpdatedModal'; - -function createMapDispatchToProps(dispatch, props) { - return { - onModalClose() { - location.reload(); - } - }; -} - -export default connect(null, createMapDispatchToProps)(AppUpdatedModal); diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js deleted file mode 100644 index 8cce1bc16..000000000 --- a/frontend/src/App/AppUpdatedModalContent.js +++ /dev/null @@ -1,139 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { kinds } from 'Helpers/Props'; -import UpdateChanges from 'System/Updates/UpdateChanges'; -import translate from 'Utilities/String/translate'; -import styles from './AppUpdatedModalContent.css'; - -function mergeUpdates(items, version, prevVersion) { - let installedIndex = items.findIndex((u) => u.version === version); - let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion); - - if (installedIndex === -1) { - installedIndex = 0; - } - - if (installedPreviouslyIndex === -1) { - installedPreviouslyIndex = items.length; - } else if (installedPreviouslyIndex === installedIndex && items.length) { - installedPreviouslyIndex += 1; - } - - const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); - - if (!appliedUpdates.length) { - return null; - } - - const appliedChanges = { new: [], fixed: [] }; - appliedUpdates.forEach((u) => { - if (u.changes) { - appliedChanges.new.push(... u.changes.new); - appliedChanges.fixed.push(... u.changes.fixed); - } - }); - - const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges }); - - if (!appliedChanges.new.length && !appliedChanges.fixed.length) { - mergedUpdate.changes = null; - } - - return mergedUpdate; -} - -function AppUpdatedModalContent(props) { - const { - version, - prevVersion, - isPopulated, - error, - items, - onSeeChangesPress, - onModalClose - } = props; - - const update = mergeUpdates(items, version, prevVersion); - - return ( - - - {translate('AppUpdated')} - - - -
- -
- - { - isPopulated && !error && !!update && -
- { - !update.changes && -
{translate('MaintenanceRelease')}
- } - - { - !!update.changes && -
-
- {translate('WhatsNew')} -
- - - - -
- } -
- } - - { - !isPopulated && !error && - - } -
- - - - - - -
- ); -} - -AppUpdatedModalContent.propTypes = { - version: PropTypes.string.isRequired, - prevVersion: PropTypes.string, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - onSeeChangesPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContent.tsx b/frontend/src/App/AppUpdatedModalContent.tsx new file mode 100644 index 000000000..6553d6270 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +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 { kinds } from 'Helpers/Props'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import UpdateChanges from 'System/Updates/UpdateChanges'; +import Update from 'typings/Update'; +import translate from 'Utilities/String/translate'; +import AppState from './State/AppState'; +import styles from './AppUpdatedModalContent.css'; + +function mergeUpdates(items: Update[], version: string, prevVersion?: string) { + let installedIndex = items.findIndex((u) => u.version === version); + let installedPreviouslyIndex = items.findIndex( + (u) => u.version === prevVersion + ); + + if (installedIndex === -1) { + installedIndex = 0; + } + + if (installedPreviouslyIndex === -1) { + installedPreviouslyIndex = items.length; + } else if (installedPreviouslyIndex === installedIndex && items.length) { + installedPreviouslyIndex += 1; + } + + const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); + + if (!appliedUpdates.length) { + return null; + } + + const appliedChanges: Update['changes'] = { new: [], fixed: [] }; + + appliedUpdates.forEach((u: Update) => { + if (u.changes) { + appliedChanges.new.push(...u.changes.new); + appliedChanges.fixed.push(...u.changes.fixed); + } + }); + + const mergedUpdate: Update = Object.assign({}, appliedUpdates[0], { + changes: appliedChanges, + }); + + if (!appliedChanges.new.length && !appliedChanges.fixed.length) { + mergedUpdate.changes = null; + } + + return mergedUpdate; +} + +interface AppUpdatedModalContentProps { + onModalClose: () => void; +} + +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 previousVersion = usePrevious(version); + + const { onModalClose } = props; + + const update = mergeUpdates(items, version, prevVersion); + + const handleSeeChangesPress = useCallback(() => { + window.location.href = `${window.Sonarr.urlBase}/system/updates`; + }, []); + + useEffect(() => { + dispatch(fetchUpdates()); + }, [dispatch]); + + useEffect(() => { + if (version !== previousVersion) { + dispatch(fetchUpdates()); + } + }, [version, previousVersion, dispatch]); + + return ( + + {translate('AppUpdated')} + + +
+ +
+ + {isPopulated && !error && !!update ? ( +
+ {update.changes ? ( +
+ {translate('MaintenanceRelease')} +
+ ) : null} + + {update.changes ? ( +
+
{translate('WhatsNew')}
+ + + + +
+ ) : null} +
+ ) : null} + + {!isPopulated && !error ? : null} +
+ + + + + + +
+ ); +} + +export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js deleted file mode 100644 index 4100ee674..000000000 --- a/frontend/src/App/AppUpdatedModalContentConnector.js +++ /dev/null @@ -1,78 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchUpdates } from 'Store/Actions/systemActions'; -import AppUpdatedModalContent from './AppUpdatedModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.version, - (state) => state.app.prevVersion, - (state) => state.system.updates, - (version, prevVersion, updates) => { - const { - isPopulated, - error, - items - } = updates; - - return { - version, - prevVersion, - isPopulated, - error, - items - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchFetchUpdates() { - dispatch(fetchUpdates()); - }, - - onSeeChangesPress() { - window.location = `${window.Sonarr.urlBase}/system/updates`; - } - }; -} - -class AppUpdatedModalContentConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchUpdates(); - } - - componentDidUpdate(prevProps) { - if (prevProps.version !== this.props.version) { - this.props.dispatchFetchUpdates(); - } - } - - // - // Render - - render() { - const { - dispatchFetchUpdates, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -AppUpdatedModalContentConnector.propTypes = { - version: PropTypes.string.isRequired, - dispatchFetchUpdates: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector); diff --git a/frontend/src/App/ApplyTheme.js b/frontend/src/App/ApplyTheme.js deleted file mode 100644 index ef177749f..000000000 --- a/frontend/src/App/ApplyTheme.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Fragment, useCallback, useEffect } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import themes from 'Styles/Themes'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.ui.item.theme || window.Sonarr.theme, - ( - theme - ) => { - return { - theme - }; - } - ); -} - -function ApplyTheme({ theme, children }) { - // Update the CSS Variables - - const updateCSSVariables = useCallback(() => { - const arrayOfVariableKeys = Object.keys(themes[theme]); - const arrayOfVariableValues = Object.values(themes[theme]); - - // Loop through each array key and set the CSS Variables - arrayOfVariableKeys.forEach((cssVariableKey, index) => { - // Based on our snippet from MDN - document.documentElement.style.setProperty( - `--${cssVariableKey}`, - arrayOfVariableValues[index] - ); - }); - }, [theme]); - - // On Component Mount and Component Update - useEffect(() => { - updateCSSVariables(theme); - }, [updateCSSVariables, theme]); - - return {children}; -} - -ApplyTheme.propTypes = { - theme: PropTypes.string.isRequired, - children: PropTypes.object.isRequired -}; - -export default connect(createMapStateToProps)(ApplyTheme); diff --git a/frontend/src/App/ApplyTheme.tsx b/frontend/src/App/ApplyTheme.tsx new file mode 100644 index 000000000..ce598f2dc --- /dev/null +++ b/frontend/src/App/ApplyTheme.tsx @@ -0,0 +1,33 @@ +import { useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import themes from 'Styles/Themes'; +import AppState from './State/AppState'; + +function createThemeSelector() { + return createSelector( + (state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme, + (theme) => { + return theme; + } + ); +} + +function ApplyTheme() { + const theme = useSelector(createThemeSelector()); + + const updateCSSVariables = useCallback(() => { + Object.entries(themes[theme]).forEach(([key, value]) => { + document.documentElement.style.setProperty(`--${key}`, value); + }); + }, [theme]); + + // On Component Mount and Component Update + useEffect(() => { + updateCSSVariables(); + }, [updateCSSVariables, theme]); + + return null; +} + +export default ApplyTheme; diff --git a/frontend/src/App/ColorImpairedContext.js b/frontend/src/App/ColorImpairedContext.ts similarity index 100% rename from frontend/src/App/ColorImpairedContext.js rename to frontend/src/App/ColorImpairedContext.ts diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.tsx similarity index 54% rename from frontend/src/App/ConnectionLostModal.js rename to frontend/src/App/ConnectionLostModal.tsx index 5c08f491f..f08f2c0e2 100644 --- a/frontend/src/App/ConnectionLostModal.js +++ b/frontend/src/App/ConnectionLostModal.tsx @@ -1,5 +1,4 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; import Button from 'Components/Link/Button'; import Modal from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; @@ -10,36 +9,31 @@ import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './ConnectionLostModal.css'; -function ConnectionLostModal(props) { - const { - isOpen, - onModalClose - } = props; +interface ConnectionLostModalProps { + isOpen: boolean; +} + +function ConnectionLostModal(props: ConnectionLostModalProps) { + const { isOpen } = props; + + const handleModalClose = useCallback(() => { + location.reload(); + }, []); return ( - - - - {translate('ConnectionLost')} - + + + {translate('ConnectionLost')} -
- {translate('ConnectionLostToBackend')} -
+
{translate('ConnectionLostToBackend')}
{translate('ConnectionLostReconnect')}
- @@ -48,9 +42,4 @@ function ConnectionLostModal(props) { ); } -ConnectionLostModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - export default ConnectionLostModal; diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js deleted file mode 100644 index 8ab8e3cd0..000000000 --- a/frontend/src/App/ConnectionLostModalConnector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import ConnectionLostModal from './ConnectionLostModal'; - -function createMapDispatchToProps(dispatch, props) { - return { - onModalClose() { - location.reload(); - } - }; -} - -export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal); diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts index cabc39b1c..4e9dbe7a0 100644 --- a/frontend/src/App/State/AppSectionState.ts +++ b/frontend/src/App/State/AppSectionState.ts @@ -1,10 +1,16 @@ -import SortDirection from 'Helpers/Props/SortDirection'; -import { FilterBuilderProp } from './AppState'; +import Column from 'Components/Table/Column'; +import { SortDirection } from 'Helpers/Props/sortDirections'; +import { ValidationFailure } from 'typings/pending'; +import { FilterBuilderProp, PropertyFilter } from './AppState'; export interface Error { - responseJSON: { - message: string; - }; + status?: number; + responseJSON: + | { + message: string | undefined; + } + | ValidationFailure[] + | undefined; } export interface AppSectionDeleteState { @@ -18,10 +24,18 @@ export interface AppSectionSaveState { } export interface PagedAppSectionState { + page: number; pageSize: number; + totalPages: number; + totalRecords?: number; +} +export interface TableAppSectionState { + columns: Column[]; } export interface AppSectionFilterState { + selectedFilterKey: string; + filters: PropertyFilter[]; filterBuilderProps: FilterBuilderProp[]; } @@ -34,13 +48,31 @@ export interface AppSectionSchemaState { }; } +export interface AppSectionItemSchemaState { + isSchemaFetching: boolean; + isSchemaPopulated: boolean; + schemaError: Error; + schema: T; +} + export interface AppSectionItemState { isFetching: boolean; isPopulated: boolean; error: Error; + pendingChanges: Partial; item: T; } +export interface AppSectionProviderState + extends AppSectionDeleteState, + AppSectionSaveState { + isFetching: boolean; + isPopulated: boolean; + error: Error; + items: T[]; + pendingChanges: Partial; +} + interface AppSectionState { isFetching: boolean; isPopulated: boolean; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 72aa0d7f0..84bd5d0b4 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,16 +1,23 @@ -import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; +import BlocklistAppState from './BlocklistAppState'; import CalendarAppState from './CalendarAppState'; +import CaptchaAppState from './CaptchaAppState'; import CommandAppState from './CommandAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodesAppState from './EpisodesAppState'; import HistoryAppState from './HistoryAppState'; +import InteractiveImportAppState from './InteractiveImportAppState'; +import OAuthAppState from './OAuthAppState'; import ParseAppState from './ParseAppState'; +import PathsAppState from './PathsAppState'; +import ProviderOptionsAppState from './ProviderOptionsAppState'; import QueueAppState from './QueueAppState'; +import ReleasesAppState from './ReleasesAppState'; import RootFolderAppState from './RootFolderAppState'; import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState'; import SettingsAppState from './SettingsAppState'; import SystemAppState from './SystemAppState'; import TagsAppState from './TagsAppState'; +import WantedAppState from './WantedAppState'; interface FilterBuilderPropOption { id: string; @@ -34,19 +41,25 @@ export interface PropertyFilter { export interface Filter { key: string; label: string; - filers: PropertyFilter[]; + filters: PropertyFilter[]; } export interface CustomFilter { id: number; type: string; label: string; - filers: PropertyFilter[]; + filters: PropertyFilter[]; } export interface AppSectionState { + isConnected: boolean; + isReconnecting: boolean; + isSidebarVisible: boolean; + version: string; + prevVersion?: string; dimensions: { isSmallScreen: boolean; + isLargeScreen: boolean; width: number; height: number; }; @@ -54,20 +67,29 @@ export interface AppSectionState { interface AppState { app: AppSectionState; + blocklist: BlocklistAppState; calendar: CalendarAppState; + captcha: CaptchaAppState; commands: CommandAppState; episodeFiles: EpisodeFilesAppState; + episodeHistory: HistoryAppState; + episodes: EpisodesAppState; episodesSelection: EpisodesAppState; history: HistoryAppState; interactiveImport: InteractiveImportAppState; + oAuth: OAuthAppState; parse: ParseAppState; + paths: PathsAppState; + providerOptions: ProviderOptionsAppState; queue: QueueAppState; + releases: ReleasesAppState; rootFolders: RootFolderAppState; series: SeriesAppState; seriesIndex: SeriesIndexAppState; settings: SettingsAppState; system: SystemAppState; tags: TagsAppState; + wanted: WantedAppState; } export default AppState; diff --git a/frontend/src/App/State/BlocklistAppState.ts b/frontend/src/App/State/BlocklistAppState.ts new file mode 100644 index 000000000..004a30732 --- /dev/null +++ b/frontend/src/App/State/BlocklistAppState.ts @@ -0,0 +1,16 @@ +import Blocklist from 'typings/Blocklist'; +import AppSectionState, { + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState, +} from './AppSectionState'; + +interface BlocklistAppState + extends AppSectionState, + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState { + isRemoving: boolean; +} + +export default BlocklistAppState; diff --git a/frontend/src/App/State/CalendarAppState.ts b/frontend/src/App/State/CalendarAppState.ts index de6a523b3..75c8b5e50 100644 --- a/frontend/src/App/State/CalendarAppState.ts +++ b/frontend/src/App/State/CalendarAppState.ts @@ -1,10 +1,29 @@ +import moment from 'moment'; import AppSectionState, { AppSectionFilterState, } from 'App/State/AppSectionState'; -import Episode from 'Episode/Episode'; +import { CalendarView } from 'Calendar/calendarViews'; +import { CalendarItem } from 'typings/Calendar'; + +interface CalendarOptions { + showEpisodeInformation: boolean; + showFinaleIcon: boolean; + showSpecialIcon: boolean; + showCutoffUnmetIcon: boolean; + collapseMultipleEpisodes: boolean; + fullColorEvents: boolean; +} interface CalendarAppState - extends AppSectionState, - AppSectionFilterState {} + extends AppSectionState, + AppSectionFilterState { + searchMissingCommandId: number | null; + start: moment.Moment; + end: moment.Moment; + dates: string[]; + time: string; + view: CalendarView; + options: CalendarOptions; +} export default CalendarAppState; diff --git a/frontend/src/App/State/CaptchaAppState.ts b/frontend/src/App/State/CaptchaAppState.ts new file mode 100644 index 000000000..7252937eb --- /dev/null +++ b/frontend/src/App/State/CaptchaAppState.ts @@ -0,0 +1,11 @@ +interface CaptchaAppState { + refreshing: false; + token: string; + siteKey: unknown; + secretToken: unknown; + ray: unknown; + stoken: unknown; + responseUrl: unknown; +} + +export default CaptchaAppState; diff --git a/frontend/src/App/State/HistoryAppState.ts b/frontend/src/App/State/HistoryAppState.ts index e368ff86e..632b82179 100644 --- a/frontend/src/App/State/HistoryAppState.ts +++ b/frontend/src/App/State/HistoryAppState.ts @@ -1,10 +1,14 @@ import AppSectionState, { AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState, } from 'App/State/AppSectionState'; import History from 'typings/History'; interface HistoryAppState extends AppSectionState, - AppSectionFilterState {} + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState {} export default HistoryAppState; diff --git a/frontend/src/App/State/InteractiveImportAppState.ts b/frontend/src/App/State/InteractiveImportAppState.ts index cf86f620d..84fd9f4c1 100644 --- a/frontend/src/App/State/InteractiveImportAppState.ts +++ b/frontend/src/App/State/InteractiveImportAppState.ts @@ -1,11 +1,20 @@ import AppSectionState from 'App/State/AppSectionState'; -import RecentFolder from 'InteractiveImport/Folder/RecentFolder'; import ImportMode from 'InteractiveImport/ImportMode'; import InteractiveImport from 'InteractiveImport/InteractiveImport'; +interface FavoriteFolder { + folder: string; +} + +interface RecentFolder { + folder: string; + lastUsed: string; +} + interface InteractiveImportAppState extends AppSectionState { originalItems: InteractiveImport[]; importMode: ImportMode; + favoriteFolders: FavoriteFolder[]; recentFolders: RecentFolder[]; } diff --git a/frontend/src/App/State/MetadataAppState.ts b/frontend/src/App/State/MetadataAppState.ts new file mode 100644 index 000000000..495f109d8 --- /dev/null +++ b/frontend/src/App/State/MetadataAppState.ts @@ -0,0 +1,6 @@ +import { AppSectionProviderState } from 'App/State/AppSectionState'; +import Metadata from 'typings/Metadata'; + +type MetadataAppState = AppSectionProviderState; + +export default MetadataAppState; diff --git a/frontend/src/App/State/OAuthAppState.ts b/frontend/src/App/State/OAuthAppState.ts new file mode 100644 index 000000000..619767929 --- /dev/null +++ b/frontend/src/App/State/OAuthAppState.ts @@ -0,0 +1,9 @@ +import { Error } from './AppSectionState'; + +interface OAuthAppState { + authorizing: boolean; + result: Record | null; + error: Error; +} + +export default OAuthAppState; diff --git a/frontend/src/App/State/PathsAppState.ts b/frontend/src/App/State/PathsAppState.ts new file mode 100644 index 000000000..068a48dc0 --- /dev/null +++ b/frontend/src/App/State/PathsAppState.ts @@ -0,0 +1,29 @@ +interface BasePath { + name: string; + path: string; + size: number; + lastModified: string; +} + +interface File extends BasePath { + type: 'file'; +} + +interface Folder extends BasePath { + type: 'folder'; +} + +export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent'; +export type Path = File | Folder; + +interface PathsAppState { + currentPath: string; + isFetching: boolean; + isPopulated: boolean; + error: Error; + directories: Folder[]; + files: File[]; + parent: string | null; +} + +export default PathsAppState; diff --git a/frontend/src/App/State/ProviderOptionsAppState.ts b/frontend/src/App/State/ProviderOptionsAppState.ts new file mode 100644 index 000000000..7fb5df02b --- /dev/null +++ b/frontend/src/App/State/ProviderOptionsAppState.ts @@ -0,0 +1,22 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Field, { FieldSelectOption } from 'typings/Field'; + +export interface ProviderOptions { + fields?: Field[]; +} + +interface ProviderOptionsDevice { + id: string; + name: string; +} + +interface ProviderOptionsAppState { + devices: AppSectionState; + servers: AppSectionState>; + newznabCategories: AppSectionState>; + getProfiles: AppSectionState>; + getTags: AppSectionState>; + getRootFolders: AppSectionState>; +} + +export default ProviderOptionsAppState; diff --git a/frontend/src/App/State/QueueAppState.ts b/frontend/src/App/State/QueueAppState.ts index 05d74acac..954d649a2 100644 --- a/frontend/src/App/State/QueueAppState.ts +++ b/frontend/src/App/State/QueueAppState.ts @@ -3,15 +3,29 @@ import AppSectionState, { AppSectionFilterState, AppSectionItemState, Error, + PagedAppSectionState, + TableAppSectionState, } from './AppSectionState'; +export interface QueueStatus { + totalCount: number; + count: number; + unknownCount: number; + errors: boolean; + warnings: boolean; + unknownErrors: boolean; + unknownWarnings: boolean; +} + export interface QueueDetailsAppState extends AppSectionState { params: unknown; } export interface QueuePagedAppState extends AppSectionState, - AppSectionFilterState { + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState { isGrabbing: boolean; grabError: Error; isRemoving: boolean; @@ -19,9 +33,12 @@ export interface QueuePagedAppState } interface QueueAppState { - status: AppSectionItemState; + status: AppSectionItemState; details: QueueDetailsAppState; paged: QueuePagedAppState; + options: { + includeUnknownSeriesItems: boolean; + }; } export default QueueAppState; diff --git a/frontend/src/App/State/ReleasesAppState.ts b/frontend/src/App/State/ReleasesAppState.ts new file mode 100644 index 000000000..350f6eac8 --- /dev/null +++ b/frontend/src/App/State/ReleasesAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; +import Release from 'typings/Release'; + +interface ReleasesAppState + extends AppSectionState, + AppSectionFilterState {} + +export default ReleasesAppState; diff --git a/frontend/src/App/State/SeriesAppState.ts b/frontend/src/App/State/SeriesAppState.ts index f9c216bdc..5da5987dd 100644 --- a/frontend/src/App/State/SeriesAppState.ts +++ b/frontend/src/App/State/SeriesAppState.ts @@ -3,7 +3,7 @@ import AppSectionState, { AppSectionSaveState, } from 'App/State/AppSectionState'; import Column from 'Components/Table/Column'; -import SortDirection from 'Helpers/Props/SortDirection'; +import { SortDirection } from 'Helpers/Props/sortDirections'; import Series from 'Series/Series'; import { Filter, FilterBuilderProp } from './AppState'; @@ -20,6 +20,7 @@ export interface SeriesIndexAppState { showTitle: boolean; showMonitored: boolean; showQualityProfile: boolean; + showTags: boolean; showSearchAction: boolean; }; @@ -34,6 +35,7 @@ export interface SeriesIndexAppState { showSeasonCount: boolean; showPath: boolean; showSizeOnDisk: boolean; + showTags: boolean; showSearchAction: boolean; }; @@ -57,6 +59,8 @@ interface SeriesAppState deleteOptions: { addImportListExclusion: boolean; }; + + pendingChanges: Partial; } export default SeriesAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index e249f2d20..b8e6f4954 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,22 +1,44 @@ import AppSectionState, { AppSectionDeleteState, + AppSectionItemSchemaState, AppSectionItemState, AppSectionSaveState, - AppSectionSchemaState, + PagedAppSectionState, } from 'App/State/AppSectionState'; import Language from 'Language/Language'; +import CustomFormat from 'typings/CustomFormat'; import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; +import ImportListExclusion from 'typings/ImportListExclusion'; +import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; import Indexer from 'typings/Indexer'; +import IndexerFlag from 'typings/IndexerFlag'; import Notification from 'typings/Notification'; import QualityProfile from 'typings/QualityProfile'; -import { UiSettings } from 'typings/UiSettings'; +import General from 'typings/Settings/General'; +import NamingConfig from 'typings/Settings/NamingConfig'; +import NamingExample from 'typings/Settings/NamingExample'; +import ReleaseProfile from 'typings/Settings/ReleaseProfile'; +import UiSettings from 'typings/Settings/UiSettings'; +import MetadataAppState from './MetadataAppState'; export interface DownloadClientAppState extends AppSectionState, AppSectionDeleteState, + AppSectionSaveState { + isTestingAll: boolean; +} + +export interface GeneralAppState + extends AppSectionItemState, AppSectionSaveState {} +export interface NamingAppState + extends AppSectionItemState, + AppSectionSaveState {} + +export type NamingExamplesAppState = AppSectionItemState; + export interface ImportListAppState extends AppSectionState, AppSectionDeleteState, @@ -25,7 +47,9 @@ export interface ImportListAppState export interface IndexerAppState extends AppSectionState, AppSectionDeleteState, - AppSectionSaveState {} + AppSectionSaveState { + isTestingAll: boolean; +} export interface NotificationAppState extends AppSectionState, @@ -33,18 +57,52 @@ export interface NotificationAppState export interface QualityProfilesAppState extends AppSectionState, - AppSectionSchemaState {} + AppSectionItemSchemaState {} +export interface ReleaseProfilesAppState + extends AppSectionState, + AppSectionSaveState { + pendingChanges: Partial; +} + +export interface CustomFormatAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface ImportListOptionsSettingsAppState + extends AppSectionItemState, + AppSectionSaveState {} + +export interface ImportListExclusionsSettingsAppState + extends AppSectionState, + AppSectionSaveState, + PagedAppSectionState, + AppSectionDeleteState { + pendingChanges: Partial; +} + +export type IndexerFlagSettingsAppState = AppSectionState; export type LanguageSettingsAppState = AppSectionState; export type UiSettingsAppState = AppSectionItemState; interface SettingsAppState { + advancedSettings: boolean; + customFormats: CustomFormatAppState; downloadClients: DownloadClientAppState; + general: GeneralAppState; + importListExclusions: ImportListExclusionsSettingsAppState; + importListOptions: ImportListOptionsSettingsAppState; importLists: ImportListAppState; + indexerFlags: IndexerFlagSettingsAppState; indexers: IndexerAppState; languages: LanguageSettingsAppState; + metadata: MetadataAppState; + naming: NamingAppState; + namingExamples: NamingExamplesAppState; notifications: NotificationAppState; qualityProfiles: QualityProfilesAppState; + releaseProfiles: ReleaseProfilesAppState; ui: UiSettingsAppState; } diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts index d43c1d0ee..1161f0e1e 100644 --- a/frontend/src/App/State/SystemAppState.ts +++ b/frontend/src/App/State/SystemAppState.ts @@ -1,10 +1,22 @@ +import DiskSpace from 'typings/DiskSpace'; +import Health from 'typings/Health'; import SystemStatus from 'typings/SystemStatus'; -import { AppSectionItemState } from './AppSectionState'; +import Task from 'typings/Task'; +import Update from 'typings/Update'; +import AppSectionState, { AppSectionItemState } from './AppSectionState'; +export type DiskSpaceAppState = AppSectionState; +export type HealthAppState = AppSectionState; export type SystemStatusAppState = AppSectionItemState; +export type TaskAppState = AppSectionState; +export type UpdateAppState = AppSectionState; interface SystemAppState { + diskSpace: DiskSpaceAppState; + health: HealthAppState; status: SystemStatusAppState; + tasks: TaskAppState; + updates: UpdateAppState; } export default SystemAppState; diff --git a/frontend/src/App/State/WantedAppState.ts b/frontend/src/App/State/WantedAppState.ts new file mode 100644 index 000000000..b543d3879 --- /dev/null +++ b/frontend/src/App/State/WantedAppState.ts @@ -0,0 +1,13 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Episode from 'Episode/Episode'; + +type WantedCutoffUnmetAppState = AppSectionState; + +type WantedMissingAppState = AppSectionState; + +interface WantedAppState { + cutoffUnmet: WantedCutoffUnmetAppState; + missing: WantedMissingAppState; +} + +export default WantedAppState; diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js deleted file mode 100644 index 89472301d..000000000 --- a/frontend/src/Calendar/Agenda/Agenda.js +++ /dev/null @@ -1,38 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React from 'react'; -import AgendaEventConnector from './AgendaEventConnector'; -import styles from './Agenda.css'; - -function Agenda(props) { - const { - items - } = props; - - return ( -
- { - items.map((item, index) => { - const momentDate = moment(item.airDateUtc); - const showDate = index === 0 || - !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day'); - - return ( - - ); - }) - } -
- ); -} - -Agenda.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default Agenda; diff --git a/frontend/src/Calendar/Agenda/Agenda.tsx b/frontend/src/Calendar/Agenda/Agenda.tsx new file mode 100644 index 000000000..fdef40466 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.tsx @@ -0,0 +1,25 @@ +import moment from 'moment'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import AgendaEvent from './AgendaEvent'; +import styles from './Agenda.css'; + +function Agenda() { + const { items } = useSelector((state: AppState) => state.calendar); + + return ( +
+ {items.map((item, index) => { + const momentDate = moment(item.airDateUtc); + const showDate = + index === 0 || + !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day'); + + return ; + })} +
+ ); +} + +export default Agenda; diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js deleted file mode 100644 index b6f238873..000000000 --- a/frontend/src/Calendar/Agenda/AgendaConnector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import Agenda from './Agenda'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (calendar) => { - return calendar; - } - ); -} - -export default connect(createMapStateToProps)(Agenda); diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js deleted file mode 100644 index 608528478..000000000 --- a/frontend/src/Calendar/Agenda/AgendaEvent.js +++ /dev/null @@ -1,254 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; -import episodeEntities from 'Episode/episodeEntities'; -import getFinaleTypeName from 'Episode/getFinaleTypeName'; -import { icons, kinds } from 'Helpers/Props'; -import formatTime from 'Utilities/Date/formatTime'; -import padNumber from 'Utilities/Number/padNumber'; -import translate from 'Utilities/String/translate'; -import styles from './AgendaEvent.css'; - -class AgendaEvent extends Component { - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onPress = () => { - this.setState({ isDetailsModalOpen: true }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - series, - episodeFile, - title, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - airDateUtc, - monitored, - unverifiedSceneNumbering, - finaleType, - hasFile, - grabbed, - queueItem, - showDate, - showEpisodeInformation, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - timeFormat, - longDateFormat, - colorImpairedMode - } = this.props; - - const startTime = moment(airDateUtc); - const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); - const downloading = !!(queueItem || grabbed); - const isMonitored = series.monitored && monitored; - const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored); - const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; - - return ( -
- - -
-
- { - showDate && - startTime.format(longDateFormat) - } -
- -
-
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} -
- -
- {series.title} -
- - { - showEpisodeInformation && -
- {seasonNumber}x{padNumber(episodeNumber, 2)} - - { - series.seriesType === 'anime' && absoluteEpisodeNumber && - ({absoluteEpisodeNumber}) - } - -
-
-
- } - -
- { - showEpisodeInformation && - title - } -
- - { - missingAbsoluteNumber && - - } - - { - unverifiedSceneNumbering && !missingAbsoluteNumber ? - : - null - } - - { - !!queueItem && - - - - } - - { - !queueItem && grabbed && - - } - - { - showCutoffUnmetIcon && - !!episodeFile && - episodeFile.qualityCutoffNotMet && - - } - - { - episodeNumber === 1 && seasonNumber > 0 && - - } - - { - showFinaleIcon && - finaleType ? - : - null - } - - { - showSpecialIcon && - (episodeNumber === 0 || seasonNumber === 0) && - - } -
-
- - -
- ); - } -} - -AgendaEvent.propTypes = { - id: PropTypes.number.isRequired, - series: PropTypes.object.isRequired, - episodeFile: PropTypes.object, - title: PropTypes.string.isRequired, - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, - airDateUtc: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - unverifiedSceneNumbering: PropTypes.bool, - finaleType: PropTypes.string, - hasFile: PropTypes.bool.isRequired, - grabbed: PropTypes.bool, - queueItem: PropTypes.object, - showDate: PropTypes.bool.isRequired, - showEpisodeInformation: PropTypes.bool.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - -export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx new file mode 100644 index 000000000..2fd2d7c54 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -0,0 +1,227 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import episodeEntities from 'Episode/episodeEntities'; +import getFinaleTypeName from 'Episode/getFinaleTypeName'; +import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { icons, kinds } from 'Helpers/Props'; +import useSeries from 'Series/useSeries'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import styles from './AgendaEvent.css'; + +interface AgendaEventProps { + id: number; + seriesId: number; + episodeFileId: number; + title: string; + seasonNumber: number; + episodeNumber: number; + absoluteEpisodeNumber?: number; + airDateUtc: string; + monitored: boolean; + unverifiedSceneNumbering?: boolean; + finaleType?: string; + hasFile: boolean; + grabbed?: boolean; + showDate: boolean; +} + +function AgendaEvent(props: AgendaEventProps) { + const { + id, + seriesId, + episodeFileId, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + unverifiedSceneNumbering, + finaleType, + hasFile, + grabbed, + showDate, + } = props; + + const series = useSeries(seriesId)!; + const episodeFile = useEpisodeFile(episodeFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + } = useSelector((state: AppState) => state.calendar.options); + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const downloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle( + hasFile, + downloading, + startTime, + endTime, + isMonitored + ); + const missingAbsoluteNumber = + series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + + const handlePress = useCallback(() => { + setIsDetailsModalOpen(true); + }, []); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, []); + + return ( +
+ + +
+
+ {showDate && startTime.format(longDateFormat)} +
+ +
+
+ {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(endTime.toISOString(), timeFormat, { + includeMinuteZero: true, + })} +
+ +
{series.title}
+ + {showEpisodeInformation ? ( +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + {series.seriesType === 'anime' && absoluteEpisodeNumber && ( + + ({absoluteEpisodeNumber}) + + )} +
-
+
+ ) : null} + +
+ {showEpisodeInformation ? title : null} +
+ + {missingAbsoluteNumber ? ( + + ) : null} + + {unverifiedSceneNumbering && !missingAbsoluteNumber ? ( + + ) : null} + + {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && + episodeFile && + episodeFile.qualityCutoffNotMet ? ( + + ) : null} + + {episodeNumber === 1 && seasonNumber > 0 && ( + + )} + + {showFinaleIcon && finaleType ? ( + + ) : null} + + {showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? ( + + ) : null} +
+
+ + +
+ ); +} + +export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js deleted file mode 100644 index d476acf80..000000000 --- a/frontend/src/Calendar/Agenda/AgendaEventConnector.js +++ /dev/null @@ -1,30 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import AgendaEvent from './AgendaEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createSeriesSelector(), - createEpisodeFileSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, series, episodeFile, queueItem, uiSettings) => { - return { - series, - episodeFile, - queueItem, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - longDateFormat: uiSettings.longDateFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(AgendaEvent); diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js deleted file mode 100644 index 0a2fd671d..000000000 --- a/frontend/src/Calendar/Calendar.js +++ /dev/null @@ -1,67 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import { kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import AgendaConnector from './Agenda/AgendaConnector'; -import * as calendarViews from './calendarViews'; -import CalendarDaysConnector from './Day/CalendarDaysConnector'; -import DaysOfWeekConnector from './Day/DaysOfWeekConnector'; -import CalendarHeaderConnector from './Header/CalendarHeaderConnector'; -import styles from './Calendar.css'; - -class Calendar extends Component { - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - view - } = this.props; - - return ( -
- { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && - {translate('CalendarLoadError')} - } - - { - !error && isPopulated && view === calendarViews.AGENDA && -
- - -
- } - - { - !error && isPopulated && view !== calendarViews.AGENDA && -
- - - -
- } -
- ); - } -} - -Calendar.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - view: PropTypes.string.isRequired -}; - -export default Calendar; diff --git a/frontend/src/Calendar/Calendar.tsx b/frontend/src/Calendar/Calendar.tsx new file mode 100644 index 000000000..caa337cf0 --- /dev/null +++ b/frontend/src/Calendar/Calendar.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Episode from 'Episode/Episode'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds } from 'Helpers/Props'; +import { + clearCalendar, + fetchCalendar, + gotoCalendarToday, +} from 'Store/Actions/calendarActions'; +import { + clearEpisodeFiles, + fetchEpisodeFiles, +} from 'Store/Actions/episodeFileActions'; +import { + clearQueueDetails, + fetchQueueDetails, +} from 'Store/Actions/queueActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import Agenda from './Agenda/Agenda'; +import CalendarDays from './Day/CalendarDays'; +import DaysOfWeek from './Day/DaysOfWeek'; +import CalendarHeader from './Header/CalendarHeader'; +import styles from './Calendar.css'; + +const UPDATE_DELAY = 3600000; // 1 hour + +function Calendar() { + const dispatch = useDispatch(); + const requestCurrentPage = useCurrentPage(); + const updateTimeout = useRef>(); + + const { isFetching, isPopulated, error, items, time, view } = useSelector( + (state: AppState) => state.calendar + ); + + const isRefreshingSeries = useSelector( + createCommandExecutingSelector(commandNames.REFRESH_SERIES) + ); + + const firstDayOfWeek = useSelector( + (state: AppState) => state.settings.ui.item.firstDayOfWeek + ); + + const wasRefreshingSeries = usePrevious(isRefreshingSeries); + const previousFirstDayOfWeek = usePrevious(firstDayOfWeek); + const previousItems = usePrevious(items); + + const handleScheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + function updateCalendar() { + dispatch(gotoCalendarToday()); + updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY); + } + + updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY); + }, [dispatch]); + + useEffect(() => { + handleScheduleUpdate(); + + return () => { + dispatch(clearCalendar()); + dispatch(clearQueueDetails()); + dispatch(clearEpisodeFiles()); + clearTimeout(updateTimeout.current); + }; + }, [dispatch, handleScheduleUpdate]); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchCalendar()); + } else { + dispatch(gotoCalendarToday()); + } + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchQueueDetails({ time, view })); + dispatch(fetchCalendar({ time, view })); + }; + + registerPagePopulator(repopulate, [ + 'episodeFileUpdated', + 'episodeFileDeleted', + ]); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [time, view, dispatch]); + + useEffect(() => { + handleScheduleUpdate(); + }, [time, handleScheduleUpdate]); + + useEffect(() => { + if ( + previousFirstDayOfWeek != null && + firstDayOfWeek !== previousFirstDayOfWeek + ) { + dispatch(fetchCalendar({ time, view })); + } + }, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]); + + useEffect(() => { + if (wasRefreshingSeries && !isRefreshingSeries) { + dispatch(fetchCalendar({ time, view })); + } + }, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]); + + useEffect(() => { + if (!previousItems || hasDifferentItems(items, previousItems)) { + const episodeIds = selectUniqueIds(items, 'id'); + const episodeFileIds = selectUniqueIds( + items, + 'episodeFileId' + ); + + if (items.length) { + dispatch(fetchQueueDetails({ episodeIds })); + } + + if (episodeFileIds.length) { + dispatch(fetchEpisodeFiles({ episodeFileIds })); + } + } + }, [items, previousItems, dispatch]); + + return ( +
+ {isFetching && !isPopulated ? : null} + + {!isFetching && error ? ( + {translate('CalendarLoadError')} + ) : null} + + {!error && isPopulated && view === 'agenda' ? ( +
+ + +
+ ) : null} + + {!error && isPopulated && view !== 'agenda' ? ( +
+ + + +
+ ) : null} +
+ ); +} + +export default Calendar; diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js deleted file mode 100644 index 47c769126..000000000 --- a/frontend/src/Calendar/CalendarConnector.js +++ /dev/null @@ -1,196 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import * as calendarActions from 'Store/Actions/calendarActions'; -import { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions'; -import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import Calendar from './Calendar'; - -const UPDATE_DELAY = 3600000; // 1 hour - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (state) => state.settings.ui.item.firstDayOfWeek, - createCommandExecutingSelector(commandNames.REFRESH_SERIES), - (calendar, firstDayOfWeek, isRefreshingSeries) => { - return { - ...calendar, - isRefreshingSeries, - firstDayOfWeek - }; - } - ); -} - -const mapDispatchToProps = { - ...calendarActions, - fetchEpisodeFiles, - clearEpisodeFiles, - fetchQueueDetails, - clearQueueDetails -}; - -class CalendarConnector extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.updateTimeoutId = null; - } - - componentDidMount() { - const { - useCurrentPage, - fetchCalendar, - gotoCalendarToday - } = this.props; - - registerPagePopulator(this.repopulate, ['episodeFileUpdated', 'episodeFileDeleted']); - - if (useCurrentPage) { - fetchCalendar(); - } else { - gotoCalendarToday(); - } - - this.scheduleUpdate(); - } - - componentDidUpdate(prevProps) { - const { - items, - time, - view, - isRefreshingSeries, - firstDayOfWeek - } = this.props; - - if (hasDifferentItems(prevProps.items, items)) { - const episodeIds = selectUniqueIds(items, 'id'); - const episodeFileIds = selectUniqueIds(items, 'episodeFileId'); - - if (items.length) { - this.props.fetchQueueDetails({ episodeIds }); - } - - if (episodeFileIds.length) { - this.props.fetchEpisodeFiles({ episodeFileIds }); - } - } - - if (prevProps.time !== time) { - this.scheduleUpdate(); - } - - if (prevProps.firstDayOfWeek !== firstDayOfWeek) { - this.props.fetchCalendar({ time, view }); - } - - if (prevProps.isRefreshingSeries && !isRefreshingSeries) { - this.props.fetchCalendar({ time, view }); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearCalendar(); - this.props.clearQueueDetails(); - this.props.clearEpisodeFiles(); - this.clearUpdateTimeout(); - } - - // - // Control - - repopulate = () => { - const { - time, - view - } = this.props; - - this.props.fetchQueueDetails({ time, view }); - this.props.fetchCalendar({ time, view }); - }; - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - - this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - updateCalendar = () => { - this.props.gotoCalendarToday(); - this.scheduleUpdate(); - }; - - // - // Listeners - - onCalendarViewChange = (view) => { - this.props.setCalendarView({ view }); - }; - - onTodayPress = () => { - this.props.gotoCalendarToday(); - }; - - onPreviousPress = () => { - this.props.gotoCalendarPreviousRange(); - }; - - onNextPress = () => { - this.props.gotoCalendarNextRange(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarConnector.propTypes = { - useCurrentPage: PropTypes.bool.isRequired, - time: PropTypes.string, - view: PropTypes.string.isRequired, - firstDayOfWeek: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - isRefreshingSeries: PropTypes.bool.isRequired, - setCalendarView: PropTypes.func.isRequired, - gotoCalendarToday: PropTypes.func.isRequired, - gotoCalendarPreviousRange: PropTypes.func.isRequired, - gotoCalendarNextRange: PropTypes.func.isRequired, - clearCalendar: PropTypes.func.isRequired, - fetchCalendar: PropTypes.func.isRequired, - fetchEpisodeFiles: PropTypes.func.isRequired, - clearEpisodeFiles: PropTypes.func.isRequired, - fetchQueueDetails: PropTypes.func.isRequired, - clearQueueDetails: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector); diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js deleted file mode 100644 index 2e4d56b6b..000000000 --- a/frontend/src/Calendar/CalendarPage.js +++ /dev/null @@ -1,197 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Measure from 'Components/Measure'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -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 { align, icons } from 'Helpers/Props'; -import NoSeries from 'Series/NoSeries'; -import translate from 'Utilities/String/translate'; -import CalendarConnector from './CalendarConnector'; -import CalendarFilterModal from './CalendarFilterModal'; -import CalendarLinkModal from './iCal/CalendarLinkModal'; -import LegendConnector from './Legend/LegendConnector'; -import CalendarOptionsModal from './Options/CalendarOptionsModal'; -import styles from './CalendarPage.css'; - -const MINIMUM_DAY_WIDTH = 120; - -class CalendarPage extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isCalendarLinkModalOpen: false, - isOptionsModalOpen: false, - width: 0 - }; - } - - // - // Listeners - - onMeasure = ({ width }) => { - this.setState({ width }); - const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))); - - this.props.onDaysCountChange(days); - }; - - onGetCalendarLinkPress = () => { - this.setState({ isCalendarLinkModalOpen: true }); - }; - - onGetCalendarLinkModalClose = () => { - this.setState({ isCalendarLinkModalOpen: false }); - }; - - onOptionsPress = () => { - this.setState({ isOptionsModalOpen: true }); - }; - - onOptionsModalClose = () => { - this.setState({ isOptionsModalOpen: false }); - }; - - onSearchMissingPress = () => { - const { - missingEpisodeIds, - onSearchMissingPress - } = this.props; - - onSearchMissingPress(missingEpisodeIds); - }; - - // - // Render - - render() { - const { - selectedFilterKey, - filters, - customFilters, - hasSeries, - missingEpisodeIds, - isRssSyncExecuting, - isSearchingForMissing, - useCurrentPage, - onRssSyncPress, - onFilterSelect - } = this.props; - - const { - isCalendarLinkModalOpen, - isOptionsModalOpen - } = this.state; - - const isMeasured = this.state.width > 0; - const PageComponent = hasSeries ? CalendarConnector : NoSeries; - - return ( - - - - - - - - - - - - - - - - - - - - - - { - isMeasured ? - : -
- } - - - { - hasSeries && - - } - - - - - - - ); - } -} - -CalendarPage.propTypes = { - selectedFilterKey: PropTypes.string.isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - hasSeries: PropTypes.bool.isRequired, - missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired, - isRssSyncExecuting: PropTypes.bool.isRequired, - isSearchingForMissing: PropTypes.bool.isRequired, - useCurrentPage: PropTypes.bool.isRequired, - onSearchMissingPress: PropTypes.func.isRequired, - onDaysCountChange: PropTypes.func.isRequired, - onRssSyncPress: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired -}; - -export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPage.tsx b/frontend/src/Calendar/CalendarPage.tsx new file mode 100644 index 000000000..f408b6a60 --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.tsx @@ -0,0 +1,226 @@ +import moment from 'moment'; +import React, { useCallback, 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'; +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 { align, icons } from 'Helpers/Props'; +import NoSeries from 'Series/NoSeries'; +import { + searchMissing, + setCalendarDaysCount, + setCalendarFilter, +} from 'Store/Actions/calendarActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; +import { isCommandExecuting } from 'Utilities/Command'; +import isBefore from 'Utilities/Date/isBefore'; +import translate from 'Utilities/String/translate'; +import Calendar from './Calendar'; +import CalendarFilterModal from './CalendarFilterModal'; +import CalendarLinkModal from './iCal/CalendarLinkModal'; +import Legend from './Legend/Legend'; +import CalendarOptionsModal from './Options/CalendarOptionsModal'; +import styles from './CalendarPage.css'; + +const MINIMUM_DAY_WIDTH = 120; + +function createMissingEpisodeIdsSelector() { + return createSelector( + (state: AppState) => state.calendar.start, + (state: AppState) => state.calendar.end, + (state: AppState) => state.calendar.items, + (state: AppState) => state.queue.details.items, + (start, end, episodes, queueDetails) => { + return episodes.reduce((acc, episode) => { + const airDateUtc = episode.airDateUtc; + + if ( + !episode.episodeFileId && + moment(airDateUtc).isAfter(start) && + moment(airDateUtc).isBefore(end) && + isBefore(episode.airDateUtc) && + !queueDetails.some( + (details) => !!details.episode && details.episode.id === episode.id + ) + ) { + acc.push(episode.id); + } + + return acc; + }, []); + } + ); +} + +function createIsSearchingSelector() { + return createSelector( + (state: AppState) => state.calendar.searchMissingCommandId, + createCommandsSelector(), + (searchMissingCommandId, commands) => { + if (searchMissingCommandId == null) { + return false; + } + + return isCommandExecuting( + commands.find((command) => { + return command.id === searchMissingCommandId; + }) + ); + } + ); +} + +function CalendarPage() { + const dispatch = useDispatch(); + + const { selectedFilterKey, filters } = useSelector( + (state: AppState) => state.calendar + ); + const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector()); + const isSearchingForMissing = useSelector(createIsSearchingSelector()); + const isRssSyncExecuting = useSelector( + createCommandExecutingSelector(commandNames.RSS_SYNC) + ); + const customFilters = useSelector(createCustomFiltersSelector('calendar')); + const hasSeries = !!useSelector(createSeriesCountSelector()); + + 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); + }, []); + + const handleGetCalendarLinkModalClose = useCallback(() => { + setIsCalendarLinkModalOpen(false); + }, []); + + const handleOptionsPress = useCallback(() => { + setIsOptionsModalOpen(true); + }, []); + + const handleOptionsModalClose = useCallback(() => { + setIsOptionsModalOpen(false); + }, []); + + const handleRssSyncPress = useCallback(() => { + dispatch( + executeCommand({ + name: commandNames.RSS_SYNC, + }) + ); + }, [dispatch]); + + const handleSearchMissingPress = useCallback(() => { + dispatch(searchMissing({ episodeIds: missingEpisodeIds })); + }, [missingEpisodeIds, dispatch]); + + const handleFilterSelect = useCallback( + (key: string) => { + dispatch(setCalendarFilter({ selectedFilterKey: key })); + }, + [dispatch] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + {isMeasured ? :
} + + + {hasSeries && } + + + + + + + ); +} + +export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js deleted file mode 100644 index b47142b64..000000000 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ /dev/null @@ -1,117 +0,0 @@ -import moment from 'moment'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withCurrentPage from 'Components/withCurrentPage'; -import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import { isCommandExecuting } from 'Utilities/Command'; -import isBefore from 'Utilities/Date/isBefore'; -import CalendarPage from './CalendarPage'; - -function createMissingEpisodeIdsSelector() { - return createSelector( - (state) => state.calendar.start, - (state) => state.calendar.end, - (state) => state.calendar.items, - (state) => state.queue.details.items, - (start, end, episodes, queueDetails) => { - return episodes.reduce((acc, episode) => { - const airDateUtc = episode.airDateUtc; - - if ( - !episode.episodeFileId && - moment(airDateUtc).isAfter(start) && - moment(airDateUtc).isBefore(end) && - isBefore(episode.airDateUtc) && - !queueDetails.some((details) => !!details.episode && details.episode.id === episode.id) - ) { - acc.push(episode.id); - } - - return acc; - }, []); - } - ); -} - -function createIsSearchingSelector() { - return createSelector( - (state) => state.calendar.searchMissingCommandId, - createCommandsSelector(), - (searchMissingCommandId, commands) => { - if (searchMissingCommandId == null) { - return false; - } - - return isCommandExecuting(commands.find((command) => { - return command.id === searchMissingCommandId; - })); - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.selectedFilterKey, - (state) => state.calendar.filters, - createCustomFiltersSelector('calendar'), - createSeriesCountSelector(), - createUISettingsSelector(), - createMissingEpisodeIdsSelector(), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createIsSearchingSelector(), - ( - selectedFilterKey, - filters, - customFilters, - seriesCount, - uiSettings, - missingEpisodeIds, - isRssSyncExecuting, - isSearchingForMissing - ) => { - return { - selectedFilterKey, - filters, - customFilters, - colorImpairedMode: uiSettings.enableColorImpairedMode, - hasSeries: !!seriesCount, - missingEpisodeIds, - isRssSyncExecuting, - isSearchingForMissing - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - }, - - onSearchMissingPress(episodeIds) { - dispatch(searchMissing({ episodeIds })); - }, - - onDaysCountChange(dayCount) { - dispatch(setCalendarDaysCount({ dayCount })); - }, - - onFilterSelect(selectedFilterKey) { - dispatch(setCalendarFilter({ selectedFilterKey })); - } - }; -} - -export default withCurrentPage( - connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage) -); diff --git a/frontend/src/Calendar/Day/CalendarDay.tsx b/frontend/src/Calendar/Day/CalendarDay.tsx index 7538b0467..a619109ca 100644 --- a/frontend/src/Calendar/Day/CalendarDay.tsx +++ b/frontend/src/Calendar/Day/CalendarDay.tsx @@ -1,25 +1,104 @@ import classNames from 'classnames'; import moment from 'moment'; import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import * as calendarViews from 'Calendar/calendarViews'; -import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; -import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector'; -import Series from 'Series/Series'; -import CalendarEventGroup, { CalendarEvent } from 'typings/CalendarEventGroup'; +import CalendarEvent from 'Calendar/Events/CalendarEvent'; +import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup'; +import { + CalendarEvent as CalendarEventModel, + CalendarEventGroup as CalendarEventGroupModel, + CalendarItem, +} from 'typings/Calendar'; import styles from './CalendarDay.css'; +function sort(items: (CalendarEventModel | CalendarEventGroupModel)[]) { + return items.sort((a, b) => { + const aDate = a.isGroup + ? moment(a.events[0].airDateUtc).unix() + : moment(a.airDateUtc).unix(); + + const bDate = b.isGroup + ? moment(b.events[0].airDateUtc).unix() + : moment(b.airDateUtc).unix(); + + return aDate - bDate; + }); +} + +function createCalendarEventsConnector(date: string) { + return createSelector( + (state: AppState) => state.calendar.items, + (state: AppState) => state.calendar.options.collapseMultipleEpisodes, + (items, collapseMultipleEpisodes) => { + const momentDate = moment(date); + + const filtered = items.filter((item) => { + return momentDate.isSame(moment(item.airDateUtc), 'day'); + }); + + if (!collapseMultipleEpisodes) { + return sort( + filtered.map((item) => ({ + isGroup: false, + ...item, + })) + ); + } + + const groupedObject = Object.groupBy( + filtered, + (item: CalendarItem) => `${item.seriesId}-${item.seasonNumber}` + ); + + const grouped = Object.entries(groupedObject).reduce< + (CalendarEventModel | CalendarEventGroupModel)[] + >((acc, [, events]) => { + if (!events) { + return acc; + } + + if (events.length === 1) { + acc.push({ + isGroup: false, + ...events[0], + }); + } else { + acc.push({ + isGroup: true, + seriesId: events[0].seriesId, + seasonNumber: events[0].seasonNumber, + episodeIds: events.map((event) => event.id), + events: events.sort( + (a, b) => + moment(a.airDateUtc).unix() - moment(b.airDateUtc).unix() + ), + }); + } + + return acc; + }, []); + + return sort(grouped); + } + ); +} + interface CalendarDayProps { date: string; - time: string; isTodaysDate: boolean; - events: (CalendarEvent | CalendarEventGroup)[]; - view: string; - onEventModalOpenToggle(...args: unknown[]): unknown; + onEventModalOpenToggle(isOpen: boolean): unknown; } -function CalendarDay(props: CalendarDayProps) { - const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } = - props; +function CalendarDay({ + date, + isTodaysDate, + onEventModalOpenToggle, +}: CalendarDayProps) { + const { time, view } = useSelector((state: AppState) => state.calendar); + const events = useSelector(createCalendarEventsConnector(date)); const ref = React.useRef(null); @@ -53,7 +132,7 @@ function CalendarDay(props: CalendarDayProps) { {events.map((event) => { if (event.isGroup) { return ( - diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js deleted file mode 100644 index 8fd6cc5a1..000000000 --- a/frontend/src/Calendar/Day/CalendarDayConnector.js +++ /dev/null @@ -1,91 +0,0 @@ -import _ from 'lodash'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import CalendarDay from './CalendarDay'; - -function sort(items) { - return _.sortBy(items, (item) => { - if (item.isGroup) { - return moment(item.events[0].airDateUtc).unix(); - } - - return moment(item.airDateUtc).unix(); - }); -} - -function createCalendarEventsConnector() { - return createSelector( - (state, { date }) => date, - (state) => state.calendar.items, - (state) => state.calendar.options.collapseMultipleEpisodes, - (date, items, collapseMultipleEpisodes) => { - const filtered = _.filter(items, (item) => { - return moment(date).isSame(moment(item.airDateUtc), 'day'); - }); - - if (!collapseMultipleEpisodes) { - return sort(filtered); - } - - const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`); - const grouped = []; - - Object.keys(groupedObject).forEach((key) => { - const events = groupedObject[key]; - - if (events.length === 1) { - grouped.push(events[0]); - } else { - grouped.push({ - isGroup: true, - seriesId: events[0].seriesId, - seasonNumber: events[0].seasonNumber, - episodeIds: events.map((event) => event.id), - events: _.sortBy(events, (item) => moment(item.airDateUtc).unix()) - }); - } - }); - - const sorted = sort(grouped); - - return sorted; - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createCalendarEventsConnector(), - (calendar, events) => { - return { - time: calendar.time, - view: calendar.view, - events - }; - } - ); -} - -class CalendarDayConnector extends Component { - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarDayConnector.propTypes = { - date: PropTypes.string.isRequired -}; - -export default connect(createMapStateToProps)(CalendarDayConnector); diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js deleted file mode 100644 index f2bb4c8d4..000000000 --- a/frontend/src/Calendar/Day/CalendarDays.js +++ /dev/null @@ -1,164 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import isToday from 'Utilities/Date/isToday'; -import CalendarDayConnector from './CalendarDayConnector'; -import styles from './CalendarDays.css'; - -class CalendarDays extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._touchStart = null; - - this.state = { - todaysDate: moment().startOf('day').toISOString(), - isEventModalOpen: false - }; - - this.updateTimeoutId = null; - } - - // Lifecycle - - componentDidMount() { - const view = this.props.view; - - if (view === calendarViews.MONTH) { - this.scheduleUpdate(); - } - - window.addEventListener('touchstart', this.onTouchStart); - window.addEventListener('touchend', this.onTouchEnd); - window.addEventListener('touchcancel', this.onTouchCancel); - window.addEventListener('touchmove', this.onTouchMove); - } - - componentWillUnmount() { - this.clearUpdateTimeout(); - - window.removeEventListener('touchstart', this.onTouchStart); - window.removeEventListener('touchend', this.onTouchEnd); - window.removeEventListener('touchcancel', this.onTouchCancel); - window.removeEventListener('touchmove', this.onTouchMove); - } - - // - // Control - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - const todaysDate = moment().startOf('day'); - const diff = moment().diff(todaysDate.clone().add(1, 'day')); - - this.setState({ todaysDate: todaysDate.toISOString() }); - - this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - // - // Listeners - - onEventModalOpenToggle = (isEventModalOpen) => { - this.setState({ isEventModalOpen }); - }; - - onTouchStart = (event) => { - const touches = event.touches; - const touchStart = touches[0].pageX; - - if (touches.length !== 1) { - return; - } - - if ( - touchStart < 50 || - this.props.isSidebarVisible || - this.state.isEventModalOpen - ) { - return; - } - - this._touchStart = touchStart; - }; - - onTouchEnd = (event) => { - const touches = event.changedTouches; - const currentTouch = touches[0].pageX; - - if (!this._touchStart) { - return; - } - - if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) { - this.props.onNavigatePrevious(); - } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) { - this.props.onNavigateNext(); - } - - this._touchStart = null; - }; - - onTouchCancel = (event) => { - this._touchStart = null; - }; - - onTouchMove = (event) => { - if (!this._touchStart) { - return; - } - }; - - // - // Render - - render() { - const { - dates, - view - } = this.props; - - return ( -
- { - dates.map((date) => { - return ( - - ); - }) - } -
- ); - } -} - -CalendarDays.propTypes = { - dates: PropTypes.arrayOf(PropTypes.string).isRequired, - view: PropTypes.string.isRequired, - isSidebarVisible: PropTypes.bool.isRequired, - onNavigatePrevious: PropTypes.func.isRequired, - onNavigateNext: PropTypes.func.isRequired -}; - -export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDays.tsx b/frontend/src/Calendar/Day/CalendarDays.tsx new file mode 100644 index 000000000..149dc1455 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.tsx @@ -0,0 +1,135 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as calendarViews from 'Calendar/calendarViews'; +import { + gotoCalendarNextRange, + gotoCalendarPreviousRange, +} from 'Store/Actions/calendarActions'; +import CalendarDay from './CalendarDay'; +import styles from './CalendarDays.css'; + +function CalendarDays() { + const dispatch = useDispatch(); + const { dates, view } = useSelector((state: AppState) => state.calendar); + const isSidebarVisible = useSelector( + (state: AppState) => state.app.isSidebarVisible + ); + + const updateTimeout = useRef>(); + const touchStart = useRef(null); + const isEventModalOpen = useRef(false); + const [todaysDate, setTodaysDate] = useState( + moment().startOf('day').toISOString() + ); + + const handleEventModalOpenToggle = useCallback((isOpen: boolean) => { + isEventModalOpen.current = isOpen; + }, []); + + const scheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + setTodaysDate(todaysDate.toISOString()); + + updateTimeout.current = setTimeout(scheduleUpdate, diff); + }, []); + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + const touches = event.touches; + const currentTouch = touches[0].pageX; + + if (touches.length !== 1) { + return; + } + + if (currentTouch < 50 || isSidebarVisible || isEventModalOpen.current) { + return; + } + + touchStart.current = currentTouch; + }, + [isSidebarVisible] + ); + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!touchStart.current) { + return; + } + + if ( + currentTouch > touchStart.current && + currentTouch - touchStart.current > 100 + ) { + dispatch(gotoCalendarPreviousRange()); + } else if ( + currentTouch < touchStart.current && + touchStart.current - currentTouch > 100 + ) { + dispatch(gotoCalendarNextRange()); + } + + touchStart.current = null; + }, + [dispatch] + ); + + const handleTouchCancel = useCallback(() => { + touchStart.current = null; + }, []); + + const handleTouchMove = useCallback(() => { + if (!touchStart.current) { + return; + } + }, []); + + useEffect(() => { + if (view === calendarViews.MONTH) { + scheduleUpdate(); + } + }, [view, scheduleUpdate]); + + useEffect(() => { + window.addEventListener('touchstart', handleTouchStart); + window.addEventListener('touchend', handleTouchEnd); + window.addEventListener('touchcancel', handleTouchCancel); + window.addEventListener('touchmove', handleTouchMove); + + return () => { + window.removeEventListener('touchstart', handleTouchStart); + window.removeEventListener('touchend', handleTouchEnd); + window.removeEventListener('touchcancel', handleTouchCancel); + window.removeEventListener('touchmove', handleTouchMove); + }; + }, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]); + + return ( +
+ {dates.map((date) => { + return ( + + ); + })} +
+ ); +} + +export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js deleted file mode 100644 index 0acce70b9..000000000 --- a/frontend/src/Calendar/Day/CalendarDaysConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions'; -import CalendarDays from './CalendarDays'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (state) => state.app.isSidebarVisible, - (calendar, isSidebarVisible) => { - return { - dates: calendar.dates, - view: calendar.view, - isSidebarVisible - }; - } - ); -} - -const mapDispatchToProps = { - onNavigatePrevious: gotoCalendarPreviousRange, - onNavigateNext: gotoCalendarNextRange -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays); diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js deleted file mode 100644 index 39e40fce8..000000000 --- a/frontend/src/Calendar/Day/DayOfWeek.js +++ /dev/null @@ -1,56 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import styles from './DayOfWeek.css'; - -class DayOfWeek extends Component { - - // - // Render - - render() { - const { - date, - view, - isTodaysDate, - calendarWeekColumnHeader, - shortDateFormat, - showRelativeDates - } = this.props; - - const highlightToday = view !== calendarViews.MONTH && isTodaysDate; - const momentDate = moment(date); - let formatedDate = momentDate.format('dddd'); - - if (view === calendarViews.WEEK) { - formatedDate = momentDate.format(calendarWeekColumnHeader); - } else if (view === calendarViews.FORECAST) { - formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates); - } - - return ( -
- {formatedDate} -
- ); - } -} - -DayOfWeek.propTypes = { - date: PropTypes.string.isRequired, - view: PropTypes.string.isRequired, - isTodaysDate: PropTypes.bool.isRequired, - calendarWeekColumnHeader: PropTypes.string.isRequired, - shortDateFormat: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired -}; - -export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DayOfWeek.tsx b/frontend/src/Calendar/Day/DayOfWeek.tsx new file mode 100644 index 000000000..c8b493b7c --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.tsx @@ -0,0 +1,54 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React from 'react'; +import * as calendarViews from 'Calendar/calendarViews'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import styles from './DayOfWeek.css'; + +interface DayOfWeekProps { + date: string; + view: string; + isTodaysDate: boolean; + calendarWeekColumnHeader: string; + shortDateFormat: string; + showRelativeDates: boolean; +} + +function DayOfWeek(props: DayOfWeekProps) { + const { + date, + view, + isTodaysDate, + calendarWeekColumnHeader, + shortDateFormat, + showRelativeDates, + } = props; + + const highlightToday = view !== calendarViews.MONTH && isTodaysDate; + const momentDate = moment(date); + let formatedDate = momentDate.format('dddd'); + + if (view === calendarViews.WEEK) { + formatedDate = momentDate.format(calendarWeekColumnHeader); + } else if (view === calendarViews.FORECAST) { + formatedDate = getRelativeDate({ + date, + shortDateFormat, + showRelativeDates, + }); + } + + return ( +
+ {formatedDate} +
+ ); +} + +export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js deleted file mode 100644 index 9f94b1079..000000000 --- a/frontend/src/Calendar/Day/DaysOfWeek.js +++ /dev/null @@ -1,97 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import DayOfWeek from './DayOfWeek'; -import styles from './DaysOfWeek.css'; - -class DaysOfWeek extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - todaysDate: moment().startOf('day').toISOString() - }; - - this.updateTimeoutId = null; - } - - // Lifecycle - - componentDidMount() { - const view = this.props.view; - - if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) { - this.scheduleUpdate(); - } - } - - componentWillUnmount() { - this.clearUpdateTimeout(); - } - - // - // Control - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - const todaysDate = moment().startOf('day'); - const diff = todaysDate.clone().add(1, 'day').diff(moment()); - - this.setState({ - todaysDate: todaysDate.toISOString() - }); - - this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - // - // Render - - render() { - const { - dates, - view, - ...otherProps - } = this.props; - - if (view === calendarViews.AGENDA) { - return null; - } - - return ( -
- { - dates.map((date) => { - return ( - - ); - }) - } -
- ); - } -} - -DaysOfWeek.propTypes = { - dates: PropTypes.arrayOf(PropTypes.string), - view: PropTypes.string.isRequired -}; - -export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.tsx b/frontend/src/Calendar/Day/DaysOfWeek.tsx new file mode 100644 index 000000000..64bc886cc --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.tsx @@ -0,0 +1,60 @@ +import moment from 'moment'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as calendarViews from 'Calendar/calendarViews'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import DayOfWeek from './DayOfWeek'; +import styles from './DaysOfWeek.css'; + +function DaysOfWeek() { + const { dates, view } = useSelector((state: AppState) => state.calendar); + const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } = + useSelector(createUISettingsSelector()); + + const updateTimeout = useRef>(); + const [todaysDate, setTodaysDate] = useState( + moment().startOf('day').toISOString() + ); + + const scheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + setTodaysDate(todaysDate.toISOString()); + + updateTimeout.current = setTimeout(scheduleUpdate, diff); + }, []); + + useEffect(() => { + if (view !== calendarViews.AGENDA && view !== calendarViews.MONTH) { + scheduleUpdate(); + } + }, [view, scheduleUpdate]); + + if (view === calendarViews.AGENDA) { + return null; + } + + return ( +
+ {dates.map((date) => { + return ( + + ); + })} +
+ ); +} + +export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js deleted file mode 100644 index 7f5cdef19..000000000 --- a/frontend/src/Calendar/Day/DaysOfWeekConnector.js +++ /dev/null @@ -1,22 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import DaysOfWeek from './DaysOfWeek'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createUISettingsSelector(), - (calendar, UiSettings) => { - return { - dates: calendar.dates.slice(0, 7), - view: calendar.view, - calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader, - shortDateFormat: UiSettings.shortDateFormat, - showRelativeDates: UiSettings.showRelativeDates - }; - } - ); -} - -export default connect(createMapStateToProps)(DaysOfWeek); diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js deleted file mode 100644 index 1f9d59b2b..000000000 --- a/frontend/src/Calendar/Events/CalendarEvent.js +++ /dev/null @@ -1,267 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; -import episodeEntities from 'Episode/episodeEntities'; -import getFinaleTypeName from 'Episode/getFinaleTypeName'; -import { icons, kinds } from 'Helpers/Props'; -import formatTime from 'Utilities/Date/formatTime'; -import padNumber from 'Utilities/Number/padNumber'; -import translate from 'Utilities/String/translate'; -import CalendarEventQueueDetails from './CalendarEventQueueDetails'; -import styles from './CalendarEvent.css'; - -class CalendarEvent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onPress = () => { - this.setState({ isDetailsModalOpen: true }, () => { - this.props.onEventModalOpenToggle(true); - }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }, () => { - this.props.onEventModalOpenToggle(false); - }); - }; - - // - // Render - - render() { - const { - id, - series, - episodeFile, - title, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - airDateUtc, - monitored, - unverifiedSceneNumbering, - finaleType, - hasFile, - grabbed, - queueItem, - showEpisodeInformation, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - fullColorEvents, - timeFormat, - colorImpairedMode - } = this.props; - - if (!series) { - return null; - } - - const startTime = moment(airDateUtc); - const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); - const isDownloading = !!(queueItem || grabbed); - const isMonitored = series.monitored && monitored; - const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored); - const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; - - return ( -
- - -
-
-
- {series.title} -
- -
- { - missingAbsoluteNumber ? - : - null - } - - { - unverifiedSceneNumbering && !missingAbsoluteNumber ? - : - null - } - - { - queueItem ? - - - : - null - } - - { - !queueItem && grabbed ? - : - null - } - - { - showCutoffUnmetIcon && - !!episodeFile && - episodeFile.qualityCutoffNotMet ? - : - null - } - - { - episodeNumber === 1 && seasonNumber > 0 ? - : - null - } - - { - showFinaleIcon && - finaleType ? - : - null - } - - { - showSpecialIcon && - (episodeNumber === 0 || seasonNumber === 0) ? - : - null - } -
-
- - { - showEpisodeInformation ? -
-
- {title} -
- -
- {seasonNumber}x{padNumber(episodeNumber, 2)} - - { - series.seriesType === 'anime' && absoluteEpisodeNumber ? - ({absoluteEpisodeNumber}) : null - } -
-
: - null - } - -
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} -
-
- - -
- ); - } -} - -CalendarEvent.propTypes = { - id: PropTypes.number.isRequired, - episodeId: PropTypes.number.isRequired, - series: PropTypes.object.isRequired, - episodeFile: PropTypes.object, - title: PropTypes.string.isRequired, - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, - airDateUtc: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - unverifiedSceneNumbering: PropTypes.bool, - finaleType: PropTypes.string, - hasFile: PropTypes.bool.isRequired, - grabbed: PropTypes.bool, - queueItem: PropTypes.object, - // These props come from the connector, not marked as required to appease TS for now. - showEpisodeInformation: PropTypes.bool, - showFinaleIcon: PropTypes.bool, - showSpecialIcon: PropTypes.bool, - showCutoffUnmetIcon: PropTypes.bool, - fullColorEvents: PropTypes.bool, - timeFormat: PropTypes.string, - colorImpairedMode: PropTypes.bool, - onEventModalOpenToggle: PropTypes.func -}; - -export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx new file mode 100644 index 000000000..079256a0e --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.tsx @@ -0,0 +1,240 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import episodeEntities from 'Episode/episodeEntities'; +import getFinaleTypeName from 'Episode/getFinaleTypeName'; +import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { icons, kinds } from 'Helpers/Props'; +import useSeries from 'Series/useSeries'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import CalendarEventQueueDetails from './CalendarEventQueueDetails'; +import styles from './CalendarEvent.css'; + +interface CalendarEventProps { + id: number; + episodeId: number; + seriesId: number; + episodeFileId?: number; + title: string; + seasonNumber: number; + episodeNumber: number; + absoluteEpisodeNumber?: number; + airDateUtc: string; + monitored: boolean; + unverifiedSceneNumbering?: boolean; + finaleType?: string; + hasFile: boolean; + grabbed?: boolean; + onEventModalOpenToggle: (isOpen: boolean) => void; +} + +function CalendarEvent(props: CalendarEventProps) { + const { + id, + seriesId, + episodeFileId, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + unverifiedSceneNumbering, + finaleType, + hasFile, + grabbed, + onEventModalOpenToggle, + } = props; + + const series = useSeries(seriesId); + const episodeFile = useEpisodeFile(episodeFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + + const { timeFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + fullColorEvents, + } = useSelector((state: AppState) => state.calendar.options); + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const handlePress = useCallback(() => { + setIsDetailsModalOpen(true); + onEventModalOpenToggle(true); + }, [onEventModalOpenToggle]); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + onEventModalOpenToggle(false); + }, [onEventModalOpenToggle]); + + if (!series) { + return null; + } + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const isDownloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle( + hasFile, + isDownloading, + startTime, + endTime, + isMonitored + ); + const missingAbsoluteNumber = + series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + + return ( +
+ + +
+
+
{series.title}
+ +
+ {missingAbsoluteNumber ? ( + + ) : null} + + {unverifiedSceneNumbering && !missingAbsoluteNumber ? ( + + ) : null} + + {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && + !!episodeFile && + episodeFile.qualityCutoffNotMet ? ( + + ) : null} + + {episodeNumber === 1 && seasonNumber > 0 ? ( + + ) : null} + + {showFinaleIcon && finaleType ? ( + + ) : null} + + {showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? ( + + ) : null} +
+
+ + {showEpisodeInformation ? ( +
+
{title}
+ +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + {series.seriesType === 'anime' && absoluteEpisodeNumber ? ( + + ({absoluteEpisodeNumber}) + + ) : null} +
+
+ ) : null} + +
+ {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(endTime.toISOString(), timeFormat, { + includeMinuteZero: true, + })} +
+
+ + +
+ ); +} + +export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js deleted file mode 100644 index e1ac2096d..000000000 --- a/frontend/src/Calendar/Events/CalendarEventConnector.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarEvent from './CalendarEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createSeriesSelector(), - createEpisodeFileSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, series, episodeFile, queueItem, uiSettings) => { - return { - series, - episodeFile, - queueItem, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarEvent); diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css b/frontend/src/Calendar/Events/CalendarEventGroup.css index c52e0192d..990d994ec 100644 --- a/frontend/src/Calendar/Events/CalendarEventGroup.css +++ b/frontend/src/Calendar/Events/CalendarEventGroup.css @@ -43,6 +43,7 @@ .expandContainer, .collapseContainer { display: flex; + align-items: center; justify-content: center; } diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js deleted file mode 100644 index 2130c11f9..000000000 --- a/frontend/src/Calendar/Events/CalendarEventGroup.js +++ /dev/null @@ -1,256 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import getFinaleTypeName from 'Episode/getFinaleTypeName'; -import { icons, kinds } from 'Helpers/Props'; -import formatTime from 'Utilities/Date/formatTime'; -import padNumber from 'Utilities/Number/padNumber'; -import translate from 'Utilities/String/translate'; -import styles from './CalendarEventGroup.css'; - -function getEventsInfo(series, events) { - let files = 0; - let queued = 0; - let monitored = 0; - let absoluteEpisodeNumbers = 0; - - events.forEach((event) => { - if (event.episodeFileId) { - files++; - } - - if (event.queued) { - queued++; - } - - if (series.monitored && event.monitored) { - monitored++; - } - - if (event.absoluteEpisodeNumber) { - absoluteEpisodeNumbers++; - } - }); - - return { - allDownloaded: files === events.length, - anyQueued: queued > 0, - anyMonitored: monitored > 0, - allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length - }; -} - -class CalendarEventGroup extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isExpanded: false - }; - } - - // - // Listeners - - onExpandPress = () => { - this.setState({ isExpanded: !this.state.isExpanded }); - }; - - // - // Render - - render() { - const { - series, - events, - isDownloading, - showEpisodeInformation, - showFinaleIcon, - timeFormat, - fullColorEvents, - colorImpairedMode, - onEventModalOpenToggle - } = this.props; - - const { isExpanded } = this.state; - const { - allDownloaded, - anyQueued, - anyMonitored, - allAbsoluteEpisodeNumbers - } = getEventsInfo(series, events); - const anyDownloading = isDownloading || anyQueued; - const firstEpisode = events[0]; - const lastEpisode = events[events.length -1]; - const airDateUtc = firstEpisode.airDateUtc; - const startTime = moment(airDateUtc); - const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); - const seasonNumber = firstEpisode.seasonNumber; - const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored); - const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers; - - if (isExpanded) { - return ( -
- { - events.map((event) => { - if (event.isGroup) { - return null; - } - - return ( - - ); - }) - } - - - - -
- ); - } - - return ( -
-
-
- {series.title} -
- -
- { - isMissingAbsoluteNumber && - - } - - { - anyDownloading && - - } - - { - firstEpisode.episodeNumber === 1 && seasonNumber > 0 && - - } - - { - showFinaleIcon && - lastEpisode.finaleType ? - : null - } -
-
- -
-
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} -
- - { - showEpisodeInformation ? -
- {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)} - - { - series.seriesType === 'anime' && - firstEpisode.absoluteEpisodeNumber && - lastEpisode.absoluteEpisodeNumber && - - ({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber}) - - } -
: - - - - } -
- - { - showEpisodeInformation && - - - - } -
- ); - } -} - -CalendarEventGroup.propTypes = { - // Most of these props come from the connector and are required, but TS is confused. - series: PropTypes.object, - events: PropTypes.arrayOf(PropTypes.object).isRequired, - isDownloading: PropTypes.bool, - showEpisodeInformation: PropTypes.bool, - showFinaleIcon: PropTypes.bool, - fullColorEvents: PropTypes.bool, - timeFormat: PropTypes.string, - colorImpairedMode: PropTypes.bool, - onEventModalOpenToggle: PropTypes.func.isRequired -}; - -export default CalendarEventGroup; diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.tsx b/frontend/src/Calendar/Events/CalendarEventGroup.tsx new file mode 100644 index 000000000..1ee981cfd --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.tsx @@ -0,0 +1,253 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import getFinaleTypeName from 'Episode/getFinaleTypeName'; +import { icons, kinds } from 'Helpers/Props'; +import useSeries from 'Series/useSeries'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { CalendarItem } from 'typings/Calendar'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import CalendarEvent from './CalendarEvent'; +import styles from './CalendarEventGroup.css'; + +function createIsDownloadingSelector(episodeIds: number[]) { + return createSelector( + (state: AppState) => state.queue.details, + (details) => { + return details.items.some((item) => { + return !!(item.episodeId && episodeIds.includes(item.episodeId)); + }); + } + ); +} + +interface CalendarEventGroupProps { + episodeIds: number[]; + seriesId: number; + events: CalendarItem[]; + onEventModalOpenToggle: (isOpen: boolean) => void; +} + +function CalendarEventGroup({ + episodeIds, + seriesId, + events, + onEventModalOpenToggle, +}: CalendarEventGroupProps) { + const isDownloading = useSelector(createIsDownloadingSelector(episodeIds)); + const series = useSeries(seriesId)!; + + const { timeFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { showEpisodeInformation, showFinaleIcon, fullColorEvents } = + useSelector((state: AppState) => state.calendar.options); + + const [isExpanded, setIsExpanded] = useState(false); + + const firstEpisode = events[0]; + const lastEpisode = events[events.length - 1]; + const airDateUtc = firstEpisode.airDateUtc; + const startTime = moment(airDateUtc); + const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); + const seasonNumber = firstEpisode.seasonNumber; + + const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } = + useMemo(() => { + let files = 0; + let queued = 0; + let monitored = 0; + let absoluteEpisodeNumbers = 0; + + events.forEach((event) => { + if (event.episodeFileId) { + files++; + } + + if (event.queued) { + queued++; + } + + if (series.monitored && event.monitored) { + monitored++; + } + + if (event.absoluteEpisodeNumber) { + absoluteEpisodeNumbers++; + } + }); + + return { + allDownloaded: files === events.length, + anyQueued: queued > 0, + anyMonitored: monitored > 0, + allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length, + }; + }, [series, events]); + + const anyDownloading = isDownloading || anyQueued; + + const statusStyle = getStatusStyle( + allDownloaded, + anyDownloading, + startTime, + endTime, + anyMonitored + ); + const isMissingAbsoluteNumber = + series.seriesType === 'anime' && + seasonNumber > 0 && + !allAbsoluteEpisodeNumbers; + + const handleExpandPress = useCallback(() => { + setIsExpanded((state) => !state); + }, []); + + if (isExpanded) { + return ( +
+ {events.map((event) => { + return ( + + ); + })} + + + + +
+ ); + } + + return ( +
+
+
{series.title}
+ +
+ {isMissingAbsoluteNumber ? ( + + ) : null} + + {anyDownloading ? ( + + ) : null} + + {firstEpisode.episodeNumber === 1 && seasonNumber > 0 ? ( + + ) : null} + + {showFinaleIcon && lastEpisode.finaleType ? ( + + ) : null} +
+
+ +
+
+ {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(endTime.toISOString(), timeFormat, { + includeMinuteZero: true, + })} +
+ + {showEpisodeInformation ? ( +
+ {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}- + {padNumber(lastEpisode.episodeNumber, 2)} + {series.seriesType === 'anime' && + firstEpisode.absoluteEpisodeNumber && + lastEpisode.absoluteEpisodeNumber ? ( + + ({firstEpisode.absoluteEpisodeNumber}- + {lastEpisode.absoluteEpisodeNumber}) + + ) : null} +
+ ) : ( + + + + )} +
+ + {showEpisodeInformation ? ( + +   + +   + + ) : null} +
+ ); +} + +export default CalendarEventGroup; diff --git a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js deleted file mode 100644 index dbd967784..000000000 --- a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js +++ /dev/null @@ -1,37 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarEventGroup from './CalendarEventGroup'; - -function createIsDownloadingSelector() { - return createSelector( - (state, { episodeIds }) => episodeIds, - (state) => state.queue.details, - (episodeIds, details) => { - return details.items.some((item) => { - return item.episode && episodeIds.includes(item.episode.id); - }); - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createSeriesSelector(), - createIsDownloadingSelector(), - createUISettingsSelector(), - (calendarOptions, series, isDownloading, uiSettings) => { - return { - series, - isDownloading, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarEventGroup); diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js deleted file mode 100644 index db26eb1d2..000000000 --- a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js +++ /dev/null @@ -1,56 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import QueueDetails from 'Activity/Queue/QueueDetails'; -import CircularProgressBar from 'Components/CircularProgressBar'; - -function CalendarEventQueueDetails(props) { - const { - title, - size, - sizeleft, - estimatedCompletionTime, - status, - trackedDownloadState, - trackedDownloadStatus, - statusMessages, - errorMessage - } = props; - - const progress = size ? (100 - sizeleft / size * 100) : 0; - - return ( - - } - /> - ); -} - -CalendarEventQueueDetails.propTypes = { - title: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - estimatedCompletionTime: PropTypes.string, - status: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string -}; - -export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx new file mode 100644 index 000000000..2372bc78e --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import QueueDetails from 'Activity/Queue/QueueDetails'; +import CircularProgressBar from 'Components/CircularProgressBar'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; + +interface CalendarEventQueueDetailsProps { + title: string; + size: number; + sizeleft: number; + estimatedCompletionTime?: string; + status: string; + trackedDownloadState: QueueTrackedDownloadState; + trackedDownloadStatus: QueueTrackedDownloadStatus; + statusMessages?: StatusMessage[]; + errorMessage?: string; +} + +function CalendarEventQueueDetails({ + title, + size, + sizeleft, + estimatedCompletionTime, + status, + trackedDownloadState, + trackedDownloadStatus, + statusMessages, + errorMessage, +}: CalendarEventQueueDetailsProps) { + const progress = size ? 100 - (sizeleft / size) * 100 : 0; + + return ( + + } + /> + ); +} + +export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js deleted file mode 100644 index 4555fc63b..000000000 --- a/frontend/src/Calendar/Header/CalendarHeader.js +++ /dev/null @@ -1,268 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Menu from 'Components/Menu/Menu'; -import MenuButton from 'Components/Menu/MenuButton'; -import MenuContent from 'Components/Menu/MenuContent'; -import ViewMenuItem from 'Components/Menu/ViewMenuItem'; -import { align, icons } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import CalendarHeaderViewButton from './CalendarHeaderViewButton'; -import styles from './CalendarHeader.css'; - -function getTitle(time, start, end, view, longDateFormat) { - const timeMoment = moment(time); - const startMoment = moment(start); - const endMoment = moment(end); - - if (view === 'day') { - return timeMoment.format(longDateFormat); - } else if (view === 'month') { - return timeMoment.format('MMMM YYYY'); - } else if (view === 'agenda') { - return translate('Agenda'); - } - - let startFormat = 'MMM D YYYY'; - let endFormat = 'MMM D YYYY'; - - if (startMoment.isSame(endMoment, 'month')) { - startFormat = 'MMM D'; - endFormat = 'D YYYY'; - } else if (startMoment.isSame(endMoment, 'year')) { - startFormat = 'MMM D'; - endFormat = 'MMM D YYYY'; - } - - return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`; -} - -// TODO Convert to a stateful Component so we can track view internally when changed - -class CalendarHeader extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - view: props.view - }; - } - - componentDidUpdate(prevProps) { - const view = this.props.view; - - if (prevProps.view !== view) { - this.setState({ view }); - } - } - - // - // Listeners - - onViewChange = (view) => { - this.setState({ view }, () => { - this.props.onViewChange(view); - }); - }; - - // - // Render - - render() { - const { - isFetching, - time, - start, - end, - longDateFormat, - isSmallScreen, - collapseViewButtons, - onTodayPress, - onPreviousPress, - onNextPress - } = this.props; - - const view = this.state.view; - - const title = getTitle(time, start, end, view, longDateFormat); - - return ( -
- { - isSmallScreen && -
- {title} -
- } - -
-
- - - - - -
- - { - !isSmallScreen && -
- {title} -
- } - -
- { - isFetching && - - } - - { - collapseViewButtons ? - - - - - - - { - isSmallScreen ? - null : - - {translate('Month')} - - } - - - {translate('Week')} - - - - {translate('Forecast')} - - - - {translate('Day')} - - - - {translate('Agenda')} - - - : - -
- - - - - - - - - -
- } -
-
-
- ); - } -} - -CalendarHeader.propTypes = { - isFetching: PropTypes.bool.isRequired, - time: PropTypes.string.isRequired, - start: PropTypes.string.isRequired, - end: PropTypes.string.isRequired, - view: PropTypes.oneOf(calendarViews.all).isRequired, - isSmallScreen: PropTypes.bool.isRequired, - collapseViewButtons: PropTypes.bool.isRequired, - longDateFormat: PropTypes.string.isRequired, - onViewChange: PropTypes.func.isRequired, - onTodayPress: PropTypes.func.isRequired, - onPreviousPress: PropTypes.func.isRequired, - onNextPress: PropTypes.func.isRequired -}; - -export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeader.tsx b/frontend/src/Calendar/Header/CalendarHeader.tsx new file mode 100644 index 000000000..2faaca25e --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.tsx @@ -0,0 +1,221 @@ +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'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; +import { align, icons } from 'Helpers/Props'; +import { + gotoCalendarNextRange, + gotoCalendarPreviousRange, + gotoCalendarToday, + setCalendarView, +} from 'Store/Actions/calendarActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import CalendarHeaderViewButton from './CalendarHeaderViewButton'; +import styles from './CalendarHeader.css'; + +function CalendarHeader() { + const dispatch = useDispatch(); + + const { isFetching, view, time, start, end } = useSelector( + (state: AppState) => state.calendar + ); + + const { isSmallScreen, isLargeScreen } = useSelector( + createDimensionsSelector() + ); + + const { longDateFormat } = useSelector(createUISettingsSelector()); + + const handleViewChange = useCallback( + (newView: CalendarView) => { + dispatch(setCalendarView({ view: newView })); + }, + [dispatch] + ); + + const handleTodayPress = useCallback(() => { + dispatch(gotoCalendarToday()); + }, [dispatch]); + + const handlePreviousPress = useCallback(() => { + dispatch(gotoCalendarPreviousRange()); + }, [dispatch]); + + const handleNextPress = useCallback(() => { + dispatch(gotoCalendarNextRange()); + }, [dispatch]); + + const title = useMemo(() => { + const timeMoment = moment(time); + const startMoment = moment(start); + const endMoment = moment(end); + + if (view === 'day') { + return timeMoment.format(longDateFormat); + } else if (view === 'month') { + return timeMoment.format('MMMM YYYY'); + } else if (view === 'agenda') { + return translate('Agenda'); + } + + let startFormat = 'MMM D YYYY'; + let endFormat = 'MMM D YYYY'; + + if (startMoment.isSame(endMoment, 'month')) { + startFormat = 'MMM D'; + endFormat = 'D YYYY'; + } else if (startMoment.isSame(endMoment, 'year')) { + startFormat = 'MMM D'; + endFormat = 'MMM D YYYY'; + } + + return `${startMoment.format(startFormat)} \u2014 ${endMoment.format( + endFormat + )}`; + }, [time, start, end, view, longDateFormat]); + + return ( +
+ {isSmallScreen ?
{title}
: null} + +
+
+ + + + + +
+ + {isSmallScreen ? null : ( +
{title}
+ )} + +
+ {isFetching ? ( + + ) : null} + + {isLargeScreen ? ( + + + + + + + {isSmallScreen ? null : ( + + {translate('Month')} + + )} + + + {translate('Week')} + + + + {translate('Forecast')} + + + + {translate('Day')} + + + + {translate('Agenda')} + + + + ) : ( + <> + + + + + + + + + + + )} +
+
+
+ ); +} + +export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js deleted file mode 100644 index 616e48650..000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderConnector.js +++ /dev/null @@ -1,85 +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 { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarHeader from './CalendarHeader'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createDimensionsSelector(), - createUISettingsSelector(), - (calendar, dimensions, uiSettings) => { - const result = _.pick(calendar, [ - 'isFetching', - 'view', - 'time', - 'start', - 'end' - ]); - - result.isSmallScreen = dimensions.isSmallScreen; - result.collapseViewButtons = dimensions.isLargeScreen; - result.longDateFormat = uiSettings.longDateFormat; - - return result; - } - ); -} - -const mapDispatchToProps = { - setCalendarView, - gotoCalendarToday, - gotoCalendarPreviousRange, - gotoCalendarNextRange -}; - -class CalendarHeaderConnector extends Component { - - // - // Listeners - - onViewChange = (view) => { - this.props.setCalendarView({ view }); - }; - - onTodayPress = () => { - this.props.gotoCalendarToday(); - }; - - onPreviousPress = () => { - this.props.gotoCalendarPreviousRange(); - }; - - onNextPress = () => { - this.props.gotoCalendarNextRange(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarHeaderConnector.propTypes = { - setCalendarView: PropTypes.func.isRequired, - gotoCalendarToday: PropTypes.func.isRequired, - gotoCalendarPreviousRange: PropTypes.func.isRequired, - gotoCalendarNextRange: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector); diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js deleted file mode 100644 index 98958af03..000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js +++ /dev/null @@ -1,45 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import Button from 'Components/Link/Button'; -import titleCase from 'Utilities/String/titleCase'; -// import styles from './CalendarHeaderViewButton.css'; - -class CalendarHeaderViewButton extends Component { - - // - // Listeners - - onPress = () => { - this.props.onPress(this.props.view); - }; - - // - // Render - - render() { - const { - view, - selectedView, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -CalendarHeaderViewButton.propTypes = { - view: PropTypes.oneOf(calendarViews.all).isRequired, - selectedView: PropTypes.oneOf(calendarViews.all).isRequired, - onPress: PropTypes.func.isRequired -}; - -export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx new file mode 100644 index 000000000..c9366f9ef --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; +import { CalendarView } from 'Calendar/calendarViews'; +import Button, { ButtonProps } from 'Components/Link/Button'; +import titleCase from 'Utilities/String/titleCase'; + +interface CalendarHeaderViewButtonProps + extends Omit { + view: CalendarView; + selectedView: CalendarView; + onPress: (view: CalendarView) => void; +} + +function CalendarHeaderViewButton({ + view, + selectedView, + onPress, + ...otherProps +}: CalendarHeaderViewButtonProps) { + const handlePress = useCallback(() => { + onPress(view); + }, [view, onPress]); + + return ( + + ); +} + +export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.tsx similarity index 76% rename from frontend/src/Calendar/Legend/Legend.js rename to frontend/src/Calendar/Legend/Legend.tsx index f6e970e8b..b9887f856 100644 --- a/frontend/src/Calendar/Legend/Legend.js +++ b/frontend/src/Calendar/Legend/Legend.tsx @@ -1,20 +1,22 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; import { icons, kinds } from 'Helpers/Props'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import translate from 'Utilities/String/translate'; import LegendIconItem from './LegendIconItem'; import LegendItem from './LegendItem'; import styles from './Legend.css'; -function Legend(props) { +function Legend() { + const view = useSelector((state: AppState) => state.calendar.view); const { - view, showFinaleIcon, showSpecialIcon, showCutoffUnmetIcon, fullColorEvents, - colorImpairedMode - } = props; + } = useSelector((state: AppState) => state.calendar.options); + const { enableColorImpairedMode } = useSelector(createUISettingsSelector()); const iconsToShow = []; const isAgendaView = view === 'agenda'; @@ -56,7 +58,7 @@ function Legend(props) { if (showCutoffUnmetIcon) { iconsToShow.push(
@@ -92,7 +94,7 @@ function Legend(props) { tooltip={translate('CalendarLegendEpisodeOnAirTooltip')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} />
@@ -110,7 +112,7 @@ function Legend(props) { tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} /> @@ -134,30 +136,15 @@ function Legend(props) { {iconsToShow[0]} - { - iconsToShow.length > 1 && -
- {iconsToShow[1]} - {iconsToShow[2]} -
- } - { - iconsToShow.length > 3 && -
- {iconsToShow[3]} -
- } + {iconsToShow.length > 1 ? ( +
+ {iconsToShow[1]} + {iconsToShow[2]} +
+ ) : null} + {iconsToShow.length > 3 ?
{iconsToShow[3]}
: null} ); } -Legend.propTypes = { - view: PropTypes.string.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - export default Legend; diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js deleted file mode 100644 index 889b7a002..000000000 --- a/frontend/src/Calendar/Legend/LegendConnector.js +++ /dev/null @@ -1,21 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import Legend from './Legend'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - (state) => state.calendar.view, - createUISettingsSelector(), - (calendarOptions, view, uiSettings) => { - return { - ...calendarOptions, - view, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(Legend); diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js deleted file mode 100644 index b6bdeeff7..000000000 --- a/frontend/src/Calendar/Legend/LegendIconItem.js +++ /dev/null @@ -1,43 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import styles from './LegendIconItem.css'; - -function LegendIconItem(props) { - const { - name, - fullColorEvents, - icon, - kind, - tooltip - } = props; - - return ( -
- - - {name} -
- ); -} - -LegendIconItem.propTypes = { - name: PropTypes.string.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - icon: PropTypes.object.isRequired, - kind: PropTypes.string.isRequired, - tooltip: PropTypes.string.isRequired -}; - -export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendIconItem.tsx b/frontend/src/Calendar/Legend/LegendIconItem.tsx new file mode 100644 index 000000000..88a758c44 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.tsx @@ -0,0 +1,33 @@ +import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome'; +import classNames from 'classnames'; +import React from 'react'; +import Icon, { IconProps } from 'Components/Icon'; +import styles from './LegendIconItem.css'; + +interface LegendIconItemProps extends Pick { + name: string; + fullColorEvents: boolean; + icon: FontAwesomeIconProps['icon']; + tooltip: string; +} + +function LegendIconItem(props: LegendIconItemProps) { + const { name, fullColorEvents, icon, kind, tooltip } = props; + + return ( +
+ + + {name} +
+ ); +} + +export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.tsx similarity index 61% rename from frontend/src/Calendar/Legend/LegendItem.js rename to frontend/src/Calendar/Legend/LegendItem.tsx index f0304b9e6..40466ab9d 100644 --- a/frontend/src/Calendar/Legend/LegendItem.js +++ b/frontend/src/Calendar/Legend/LegendItem.tsx @@ -1,17 +1,26 @@ import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; +import { CalendarStatus } from 'typings/Calendar'; import titleCase from 'Utilities/String/titleCase'; import styles from './LegendItem.css'; -function LegendItem(props) { +interface LegendItemProps { + name?: string; + status: CalendarStatus; + tooltip: string; + isAgendaView: boolean; + fullColorEvents: boolean; + colorImpairedMode: boolean; +} + +function LegendItem(props: LegendItemProps) { const { name, status, tooltip, isAgendaView, fullColorEvents, - colorImpairedMode + colorImpairedMode, } = props; return ( @@ -29,13 +38,4 @@ function LegendItem(props) { ); } -LegendItem.propTypes = { - name: PropTypes.string, - status: PropTypes.string.isRequired, - tooltip: PropTypes.string.isRequired, - isAgendaView: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - export default LegendItem; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js deleted file mode 100644 index b68c83f30..000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector'; - -function CalendarOptionsModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -CalendarOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.tsx b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx new file mode 100644 index 000000000..ae782a684 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarOptionsModalContent from './CalendarOptionsModalContent'; + +interface CalendarOptionsModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function CalendarOptionsModal({ + isOpen, + onModalClose, +}: CalendarOptionsModalProps) { + return ( + + + + ); +} + +export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js deleted file mode 100644 index c34401315..000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js +++ /dev/null @@ -1,276 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FieldSet from 'Components/FieldSet'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -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 { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings'; -import translate from 'Utilities/String/translate'; - -class CalendarOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode, - fullColorEvents - } = props; - - this.state = { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode, - fullColorEvents - }; - } - - componentDidUpdate(prevProps) { - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - } = this.props; - - if ( - prevProps.firstDayOfWeek !== firstDayOfWeek || - prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader || - prevProps.timeFormat !== timeFormat || - prevProps.enableColorImpairedMode !== enableColorImpairedMode - ) { - this.setState({ - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - }); - } - } - - // - // Listeners - - onOptionInputChange = ({ name, value }) => { - const { - dispatchSetCalendarOption - } = this.props; - - dispatchSetCalendarOption({ [name]: value }); - }; - - onGlobalInputChange = ({ name, value }) => { - const { - dispatchSaveUISettings - } = this.props; - - const setting = { [name]: value }; - - this.setState(setting, () => { - dispatchSaveUISettings(setting); - }); - }; - - onLinkFocus = (event) => { - event.target.select(); - }; - - // - // Render - - render() { - const { - collapseMultipleEpisodes, - showEpisodeInformation, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - fullColorEvents, - onModalClose - } = this.props; - - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - } = this.state; - - return ( - - - {translate('CalendarOptions')} - - - -
-
- - {translate('CollapseMultipleEpisodes')} - - - - - - {translate('ShowEpisodeInformation')} - - - - - - {translate('IconForFinales')} - - - - - - {translate('IconForSpecials')} - - - - - - {translate('IconForCutoffUnmet')} - - - - - - {translate('FullColorEvents')} - - - -
-
- -
-
- - {translate('FirstDayOfWeek')} - - - - - - {translate('WeekColumnHeader')} - - - - - - {translate('TimeFormat')} - - - - - - {translate('EnableColorImpairedMode')} - - - -
-
-
- - - - -
- ); - } -} - -CalendarOptionsModalContent.propTypes = { - collapseMultipleEpisodes: PropTypes.bool.isRequired, - showEpisodeInformation: PropTypes.bool.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - firstDayOfWeek: PropTypes.number.isRequired, - calendarWeekColumnHeader: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - enableColorImpairedMode: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - dispatchSetCalendarOption: PropTypes.func.isRequired, - dispatchSaveUISettings: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx new file mode 100644 index 000000000..4f974dda3 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx @@ -0,0 +1,228 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +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 { + firstDayOfWeekOptions, + timeFormatOptions, + weekColumnOptions, +} from 'Settings/UI/UISettings'; +import { setCalendarOption } from 'Store/Actions/calendarActions'; +import { saveUISettings } from 'Store/Actions/settingsActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { InputChanged } from 'typings/inputs'; +import UiSettings from 'typings/Settings/UiSettings'; +import translate from 'Utilities/String/translate'; + +interface CalendarOptionsModalContentProps { + onModalClose: () => void; +} + +function CalendarOptionsModalContent({ + onModalClose, +}: CalendarOptionsModalContentProps) { + const dispatch = useDispatch(); + + const { + collapseMultipleEpisodes, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + fullColorEvents, + } = useSelector((state: AppState) => state.calendar.options); + + const uiSettings = useSelector(createUISettingsSelector()); + + const [state, setState] = useState>({ + firstDayOfWeek: uiSettings.firstDayOfWeek, + calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader, + timeFormat: uiSettings.timeFormat, + enableColorImpairedMode: uiSettings.enableColorImpairedMode, + }); + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode, + } = state; + + const handleOptionInputChange = useCallback( + ({ name, value }: InputChanged) => { + dispatch(setCalendarOption({ [name]: value })); + }, + [dispatch] + ); + + const handleGlobalInputChange = useCallback( + ({ name, value }: InputChanged) => { + setState((prevState) => ({ ...prevState, [name]: value })); + + dispatch(saveUISettings({ [name]: value })); + }, + [dispatch] + ); + + useEffect(() => { + setState({ + firstDayOfWeek: uiSettings.firstDayOfWeek, + calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader, + timeFormat: uiSettings.timeFormat, + enableColorImpairedMode: uiSettings.enableColorImpairedMode, + }); + }, [uiSettings]); + + return ( + + {translate('CalendarOptions')} + + +
+
+ + {translate('CollapseMultipleEpisodes')} + + + + + + {translate('ShowEpisodeInformation')} + + + + + + {translate('IconForFinales')} + + + + + + {translate('IconForSpecials')} + + + + + + {translate('IconForCutoffUnmet')} + + + + + + {translate('FullColorEvents')} + + + +
+
+ +
+
+ + {translate('FirstDayOfWeek')} + + + + + + {translate('WeekColumnHeader')} + + + + + + {translate('TimeFormat')} + + + + + + {translate('EnableColorImpairedMode')} + + + +
+
+
+ + + + +
+ ); +} + +export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js deleted file mode 100644 index 1f517b698..000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setCalendarOption } from 'Store/Actions/calendarActions'; -import { saveUISettings } from 'Store/Actions/settingsActions'; -import CalendarOptionsModalContent from './CalendarOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - (state) => state.settings.ui.item, - (options, uiSettings) => { - return { - ...options, - ...uiSettings - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetCalendarOption: setCalendarOption, - dispatchSaveUISettings: saveUISettings -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent); diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.ts similarity index 72% rename from frontend/src/Calendar/calendarViews.js rename to frontend/src/Calendar/calendarViews.ts index 929958b66..4f5549dbd 100644 --- a/frontend/src/Calendar/calendarViews.js +++ b/frontend/src/Calendar/calendarViews.ts @@ -5,3 +5,5 @@ export const FORECAST = 'forecast'; export const AGENDA = 'agenda'; export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA]; + +export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week'; diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.ts similarity index 67% rename from frontend/src/Calendar/getStatusStyle.js rename to frontend/src/Calendar/getStatusStyle.ts index b149a8aab..678e6c2a1 100644 --- a/frontend/src/Calendar/getStatusStyle.js +++ b/frontend/src/Calendar/getStatusStyle.ts @@ -1,7 +1,13 @@ -/* eslint max-params: 0 */ import moment from 'moment'; +import { CalendarStatus } from 'typings/Calendar'; -function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) { +function getStatusStyle( + hasFile: boolean, + downloading: boolean, + startTime: moment.Moment, + endTime: moment.Moment, + isMonitored: boolean +): CalendarStatus { const currentTime = moment(); if (hasFile) { diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js deleted file mode 100644 index 8cc487c16..000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector'; - -function CalendarLinkModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -CalendarLinkModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.tsx b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx new file mode 100644 index 000000000..f0eecbd4a --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarLinkModalContent from './CalendarLinkModalContent'; + +interface CalendarLinkModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function CalendarLinkModal(props: CalendarLinkModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js deleted file mode 100644 index eb64cb207..000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js +++ /dev/null @@ -1,222 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputButton from 'Components/Form/FormInputButton'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import ClipboardButton from 'Components/Link/ClipboardButton'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -function getUrls(state) { - const { - unmonitored, - premieresOnly, - asAllDay, - tags - } = state; - - let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`; - - if (unmonitored) { - icalUrl += 'unmonitored=true&'; - } - - if (premieresOnly) { - icalUrl += 'premieresOnly=true&'; - } - - if (asAllDay) { - icalUrl += 'asAllDay=true&'; - } - - if (tags.length) { - icalUrl += `tags=${tags.toString()}&`; - } - - icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`; - - const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`; - const iCalWebCalUrl = `webcal://${icalUrl}`; - - return { - iCalHttpUrl, - iCalWebCalUrl - }; -} - -class CalendarLinkModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const defaultState = { - unmonitored: false, - premieresOnly: false, - asAllDay: false, - tags: [] - }; - - const urls = getUrls(defaultState); - - this.state = { - ...defaultState, - ...urls - }; - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - const state = { - ...this.state, - [name]: value - }; - - const urls = getUrls(state); - - this.setState({ - [name]: value, - ...urls - }); - }; - - onLinkFocus = (event) => { - event.target.select(); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - unmonitored, - premieresOnly, - asAllDay, - tags, - iCalHttpUrl, - iCalWebCalUrl - } = this.state; - - return ( - - - {translate('CalendarFeed')} - - - -
- - {translate('IncludeUnmonitored')} - - - - - - {translate('SeasonPremieresOnly')} - - - - - - {translate('ICalShowAsAllDayEvents')} - - - - - - {translate('Tags')} - - - - - - {translate('ICalFeed')} - - , - - - - - ]} - onChange={this.onInputChange} - onFocus={this.onLinkFocus} - /> - -
-
- - - - -
- ); - } -} - -CalendarLinkModalContent.propTypes = { - tagList: PropTypes.arrayOf(PropTypes.object).isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx new file mode 100644 index 000000000..aa90db301 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx @@ -0,0 +1,166 @@ +import React, { FocusEvent, useCallback, useMemo, useState } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; + +interface CalendarLinkModalContentProps { + onModalClose: () => void; +} + +function CalendarLinkModalContent({ + onModalClose, +}: CalendarLinkModalContentProps) { + const [state, setState] = useState({ + unmonitored: false, + premieresOnly: false, + asAllDay: false, + tags: [], + }); + + const { unmonitored, premieresOnly, asAllDay, tags } = state; + + const handleInputChange = useCallback(({ name, value }: InputChanged) => { + setState((prevState) => ({ ...prevState, [name]: value })); + }, []); + + const handleLinkFocus = useCallback( + (event: FocusEvent) => { + event.target.select(); + }, + [] + ); + + const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => { + let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`; + + if (unmonitored) { + icalUrl += 'unmonitored=true&'; + } + + if (premieresOnly) { + icalUrl += 'premieresOnly=true&'; + } + + if (asAllDay) { + icalUrl += 'asAllDay=true&'; + } + + if (tags.length) { + icalUrl += `tags=${tags.toString()}&`; + } + + icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`; + + return { + iCalHttpUrl: `${window.location.protocol}//${icalUrl}`, + iCalWebCalUrl: `webcal://${icalUrl}`, + }; + }, [unmonitored, premieresOnly, asAllDay, tags]); + + return ( + + {translate('CalendarFeed')} + + +
+ + {translate('IncludeUnmonitored')} + + + + + + {translate('SeasonPremieresOnly')} + + + + + + {translate('ICalShowAsAllDayEvents')} + + + + + + {translate('Tags')} + + + + + + {translate('ICalFeed')} + + , + + + + , + ]} + onChange={handleInputChange} + onFocus={handleLinkFocus} + /> + +
+
+ + + + +
+ ); +} + +export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js deleted file mode 100644 index e10c5c3f9..000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import CalendarLinkModalContent from './CalendarLinkModalContent'; - -function createMapStateToProps() { - return createSelector( - createTagsSelector(), - (tagList) => { - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarLinkModalContent); diff --git a/frontend/src/Commands/Command.ts b/frontend/src/Commands/Command.ts index 45a5beed7..cd875d56b 100644 --- a/frontend/src/Commands/Command.ts +++ b/frontend/src/Commands/Command.ts @@ -1,5 +1,16 @@ import ModelBase from 'App/ModelBase'; +export type CommandStatus = + | 'queued' + | 'started' + | 'completed' + | 'failed' + | 'aborted' + | 'cancelled' + | 'orphaned'; + +export type CommandResult = 'unknown' | 'successful' | 'unsuccessful'; + export interface CommandBody { sendUpdatesToClient: boolean; updateScheduledTask: boolean; @@ -13,6 +24,10 @@ export interface CommandBody { trigger: string; suppressMessages: boolean; seriesId?: number; + seriesIds?: number[]; + seasonNumber?: number; + episodeIds?: number[]; + [key: string]: string | number | boolean | number[] | undefined; } interface Command extends ModelBase { @@ -21,8 +36,8 @@ interface Command extends ModelBase { message: string; body: CommandBody; priority: string; - status: string; - result: string; + status: CommandStatus; + result: CommandResult; queued: string; started: string; ended: string; diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index c2edf05bd..13ac9d62c 100644 --- a/frontend/src/Commands/commandNames.js +++ b/frontend/src/Commands/commandNames.js @@ -6,7 +6,7 @@ export const CLEAR_LOGS = 'ClearLog'; export const CUTOFF_UNMET_EPISODE_SEARCH = 'CutoffUnmetEpisodeSearch'; export const DELETE_LOG_FILES = 'DeleteLogFiles'; export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles'; -export const DOWNLOADED_EPSIODES_SCAN = 'DownloadedEpisodesScan'; +export const DOWNLOADED_EPISODES_SCAN = 'DownloadedEpisodesScan'; export const EPISODE_SEARCH = 'EpisodeSearch'; export const INTERACTIVE_IMPORT = 'ManualImport'; export const MISSING_EPISODE_SEARCH = 'MissingEpisodeSearch'; diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js deleted file mode 100644 index 418cbf5e6..000000000 --- a/frontend/src/Components/Alert.js +++ /dev/null @@ -1,34 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { kinds } from 'Helpers/Props'; -import styles from './Alert.css'; - -function Alert(props) { - const { className, kind, children, ...otherProps } = props; - - return ( -
- {children} -
- ); -} - -Alert.propTypes = { - className: PropTypes.string, - kind: PropTypes.oneOf(kinds.all), - children: PropTypes.node.isRequired -}; - -Alert.defaultProps = { - className: styles.alert, - kind: kinds.INFO -}; - -export default Alert; diff --git a/frontend/src/Components/Alert.tsx b/frontend/src/Components/Alert.tsx new file mode 100644 index 000000000..92c89e741 --- /dev/null +++ b/frontend/src/Components/Alert.tsx @@ -0,0 +1,18 @@ +import classNames from 'classnames'; +import React from 'react'; +import { Kind } from 'Helpers/Props/kinds'; +import styles from './Alert.css'; + +interface AlertProps { + className?: string; + kind?: Extract; + children: React.ReactNode; +} + +function Alert(props: AlertProps) { + const { className = styles.alert, kind = 'info', children } = props; + + return
{children}
; +} + +export default Alert; diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js deleted file mode 100644 index c5a4d164c..000000000 --- a/frontend/src/Components/Card.js +++ /dev/null @@ -1,60 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Link from 'Components/Link/Link'; -import styles from './Card.css'; - -class Card extends Component { - - // - // Render - - render() { - const { - className, - overlayClassName, - overlayContent, - children, - onPress - } = this.props; - - if (overlayContent) { - return ( -
- - -
- {children} -
-
- ); - } - - return ( - - {children} - - ); - } -} - -Card.propTypes = { - className: PropTypes.string.isRequired, - overlayClassName: PropTypes.string.isRequired, - overlayContent: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, - onPress: PropTypes.func.isRequired -}; - -Card.defaultProps = { - className: styles.card, - overlayClassName: styles.overlay, - overlayContent: false -}; - -export default Card; diff --git a/frontend/src/Components/Card.tsx b/frontend/src/Components/Card.tsx new file mode 100644 index 000000000..24588c841 --- /dev/null +++ b/frontend/src/Components/Card.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import Link, { LinkProps } from 'Components/Link/Link'; +import styles from './Card.css'; + +interface CardProps extends Pick { + // TODO: Consider using different properties for classname depending if it's overlaying content or not + className?: string; + overlayClassName?: string; + overlayContent?: boolean; + children: React.ReactNode; +} + +function Card(props: CardProps) { + const { + className = styles.card, + overlayClassName = styles.overlay, + overlayContent = false, + children, + onPress, + } = props; + + if (overlayContent) { + return ( +
+ + +
{children}
+
+ ); + } + + return ( + + {children} + + ); +} + +export default Card; diff --git a/frontend/src/Components/CircularProgressBar.js b/frontend/src/Components/CircularProgressBar.js deleted file mode 100644 index 3af5665a9..000000000 --- a/frontend/src/Components/CircularProgressBar.js +++ /dev/null @@ -1,138 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import styles from './CircularProgressBar.css'; - -class CircularProgressBar extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - progress: 0 - }; - } - - componentDidMount() { - this._progressStep(); - } - - componentDidUpdate(prevProps) { - const progress = this.props.progress; - - if (prevProps.progress !== progress) { - this._cancelProgressStep(); - this._progressStep(); - } - } - - componentWillUnmount() { - this._cancelProgressStep(); - } - - // - // Control - - _progressStep() { - this.requestAnimationFrame = window.requestAnimationFrame(() => { - this.setState({ - progress: this.state.progress + 1 - }, () => { - if (this.state.progress < this.props.progress) { - this._progressStep(); - } - }); - }); - } - - _cancelProgressStep() { - if (this.requestAnimationFrame) { - window.cancelAnimationFrame(this.requestAnimationFrame); - } - } - - // - // Render - - render() { - const { - className, - containerClassName, - size, - strokeWidth, - strokeColor, - showProgressText - } = this.props; - - const progress = this.state.progress; - - const center = size / 2; - const radius = center - strokeWidth; - const circumference = Math.PI * (radius * 2); - const sizeInPixels = `${size}px`; - const strokeDashoffset = ((100 - progress) / 100) * circumference; - const progressText = `${Math.round(progress)}%`; - - return ( -
- - - - - { - showProgressText && -
- {progressText} -
- } -
- ); - } -} - -CircularProgressBar.propTypes = { - className: PropTypes.string, - containerClassName: PropTypes.string, - size: PropTypes.number, - progress: PropTypes.number.isRequired, - strokeWidth: PropTypes.number, - strokeColor: PropTypes.string, - showProgressText: PropTypes.bool -}; - -CircularProgressBar.defaultProps = { - className: styles.circularProgressBar, - containerClassName: styles.circularProgressBarContainer, - size: 60, - strokeWidth: 5, - strokeColor: '#35c5f4', - showProgressText: false -}; - -export default CircularProgressBar; diff --git a/frontend/src/Components/CircularProgressBar.tsx b/frontend/src/Components/CircularProgressBar.tsx new file mode 100644 index 000000000..b14f5fc6a --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import styles from './CircularProgressBar.css'; + +interface CircularProgressBarProps { + className?: string; + containerClassName?: string; + size?: number; + progress: number; + strokeWidth?: number; + strokeColor?: string; + showProgressText?: boolean; +} + +function CircularProgressBar({ + className = styles.circularProgressBar, + containerClassName = styles.circularProgressBarContainer, + size = 60, + strokeWidth = 5, + strokeColor = '#35c5f4', + showProgressText = false, + progress, +}: CircularProgressBarProps) { + const [currentProgress, setCurrentProgress] = useState(0); + const raf = React.useRef(0); + const center = size / 2; + const radius = center - strokeWidth; + const circumference = Math.PI * (radius * 2); + const sizeInPixels = `${size}px`; + const strokeDashoffset = ((100 - currentProgress) / 100) * circumference; + const progressText = `${Math.round(currentProgress)}%`; + + const handleAnimation = useCallback( + (p: number) => { + setCurrentProgress((prevProgress) => { + if (prevProgress < p) { + return prevProgress + Math.min(1, p - prevProgress); + } + + return prevProgress; + }); + }, + [setCurrentProgress] + ); + + useEffect(() => { + if (progress > currentProgress) { + cancelAnimationFrame(raf.current); + + raf.current = requestAnimationFrame(() => handleAnimation(progress)); + } + }, [progress, currentProgress, handleAnimation]); + + useEffect( + () => { + return () => cancelAnimationFrame(raf.current); + }, + // We only want to run this effect once + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( +
+ + + + + {showProgressText && ( +
{progressText}
+ )} +
+ ); +} + +export default CircularProgressBar; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js deleted file mode 100644 index be2c87c55..000000000 --- a/frontend/src/Components/DescriptionList/DescriptionList.js +++ /dev/null @@ -1,33 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import styles from './DescriptionList.css'; - -class DescriptionList extends Component { - - // - // Render - - render() { - const { - className, - children - } = this.props; - - return ( -
- {children} -
- ); - } -} - -DescriptionList.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node -}; - -DescriptionList.defaultProps = { - className: styles.descriptionList -}; - -export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.tsx b/frontend/src/Components/DescriptionList/DescriptionList.tsx new file mode 100644 index 000000000..6deee77e5 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import styles from './DescriptionList.css'; + +interface DescriptionListProps { + className?: string; + children?: React.ReactNode; +} + +function DescriptionList(props: DescriptionListProps) { + const { className = styles.descriptionList, children } = props; + + return
{children}
; +} + +export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js deleted file mode 100644 index 931557045..000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItem.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import DescriptionListItemDescription from './DescriptionListItemDescription'; -import DescriptionListItemTitle from './DescriptionListItemTitle'; - -class DescriptionListItem extends Component { - - // - // Render - - render() { - const { - className, - titleClassName, - descriptionClassName, - title, - data - } = this.props; - - return ( -
- - {title} - - - - {data} - -
- ); - } -} - -DescriptionListItem.propTypes = { - className: PropTypes.string, - titleClassName: PropTypes.string, - descriptionClassName: PropTypes.string, - title: PropTypes.string, - data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) -}; - -export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.tsx b/frontend/src/Components/DescriptionList/DescriptionListItem.tsx new file mode 100644 index 000000000..13a7efdd0 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import DescriptionListItemDescription, { + DescriptionListItemDescriptionProps, +} from './DescriptionListItemDescription'; +import DescriptionListItemTitle, { + DescriptionListItemTitleProps, +} from './DescriptionListItemTitle'; + +interface DescriptionListItemProps { + className?: string; + titleClassName?: DescriptionListItemTitleProps['className']; + descriptionClassName?: DescriptionListItemDescriptionProps['className']; + title?: DescriptionListItemTitleProps['children']; + data?: DescriptionListItemDescriptionProps['children']; +} + +function DescriptionListItem(props: DescriptionListItemProps) { + const { className, titleClassName, descriptionClassName, title, data } = + props; + + return ( +
+ + {title} + + + + {data} + +
+ ); +} + +export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js deleted file mode 100644 index 4ef3c015e..000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js +++ /dev/null @@ -1,27 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './DescriptionListItemDescription.css'; - -function DescriptionListItemDescription(props) { - const { - className, - children - } = props; - - return ( -
- {children} -
- ); -} - -DescriptionListItemDescription.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) -}; - -DescriptionListItemDescription.defaultProps = { - className: styles.description -}; - -export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx new file mode 100644 index 000000000..e08c117dc --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx @@ -0,0 +1,17 @@ +import React, { ReactNode } from 'react'; +import styles from './DescriptionListItemDescription.css'; + +export interface DescriptionListItemDescriptionProps { + className?: string; + children?: ReactNode; +} + +function DescriptionListItemDescription( + props: DescriptionListItemDescriptionProps +) { + const { className = styles.description, children } = props; + + return
{children}
; +} + +export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js deleted file mode 100644 index e1632c1cf..000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js +++ /dev/null @@ -1,27 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './DescriptionListItemTitle.css'; - -function DescriptionListItemTitle(props) { - const { - className, - children - } = props; - - return ( -
- {children} -
- ); -} - -DescriptionListItemTitle.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.string -}; - -DescriptionListItemTitle.defaultProps = { - className: styles.title -}; - -export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx new file mode 100644 index 000000000..59ea6955c --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx @@ -0,0 +1,15 @@ +import React, { ReactNode } from 'react'; +import styles from './DescriptionListItemTitle.css'; + +export interface DescriptionListItemTitleProps { + className?: string; + children?: ReactNode; +} + +function DescriptionListItemTitle(props: DescriptionListItemTitleProps) { + const { className = styles.title, children } = props; + + return
{children}
; +} + +export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js deleted file mode 100644 index a111df70e..000000000 --- a/frontend/src/Components/DragPreviewLayer.js +++ /dev/null @@ -1,22 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './DragPreviewLayer.css'; - -function DragPreviewLayer({ children, ...otherProps }) { - return ( -
- {children} -
- ); -} - -DragPreviewLayer.propTypes = { - children: PropTypes.node, - className: PropTypes.string -}; - -DragPreviewLayer.defaultProps = { - className: styles.dragLayer -}; - -export default DragPreviewLayer; diff --git a/frontend/src/Components/DragPreviewLayer.tsx b/frontend/src/Components/DragPreviewLayer.tsx new file mode 100644 index 000000000..2e578504b --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.tsx @@ -0,0 +1,21 @@ +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 ( +
+ {children} +
+ ); +} + +export default DragPreviewLayer; diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js deleted file mode 100644 index 88412ad19..000000000 --- a/frontend/src/Components/Error/ErrorBoundary.js +++ /dev/null @@ -1,62 +0,0 @@ -import * as sentry from '@sentry/browser'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -class ErrorBoundary extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - error: null, - info: null - }; - } - - componentDidCatch(error, info) { - this.setState({ - error, - info - }); - - sentry.captureException(error); - } - - // - // Render - - render() { - const { - children, - errorComponent: ErrorComponent, - ...otherProps - } = this.props; - - const { - error, - info - } = this.state; - - if (error) { - return ( - - ); - } - - return children; - } -} - -ErrorBoundary.propTypes = { - children: PropTypes.node.isRequired, - errorComponent: PropTypes.elementType.isRequired -}; - -export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundary.tsx b/frontend/src/Components/Error/ErrorBoundary.tsx new file mode 100644 index 000000000..6b27f7a09 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundary.tsx @@ -0,0 +1,46 @@ +import * as sentry from '@sentry/browser'; +import React, { Component, ErrorInfo } from 'react'; + +interface ErrorBoundaryProps { + children: React.ReactNode; + errorComponent: React.ElementType; +} + +interface ErrorBoundaryState { + error: Error | null; + info: ErrorInfo | null; +} + +// Class component until componentDidCatch is supported in functional components +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + + this.state = { + error: null, + info: null, + }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + this.setState({ + error, + info, + }); + + sentry.captureException(error); + } + + render() { + const { children, errorComponent: ErrorComponent } = this.props; + const { error, info } = this.state; + + if (error) { + return ; + } + + return children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.tsx b/frontend/src/Components/Error/ErrorBoundaryError.tsx index 14bd8a87f..870b28058 100644 --- a/frontend/src/Components/Error/ErrorBoundaryError.tsx +++ b/frontend/src/Components/Error/ErrorBoundaryError.tsx @@ -64,7 +64,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
{info.componentStack}
)} - {
Version: {window.Sonarr.version}
} +
Version: {window.Sonarr.version}
); diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js deleted file mode 100644 index 8243fd00c..000000000 --- a/frontend/src/Components/FieldSet.js +++ /dev/null @@ -1,41 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { sizes } from 'Helpers/Props'; -import styles from './FieldSet.css'; - -class FieldSet extends Component { - - // - // Render - - render() { - const { - size, - legend, - children - } = this.props; - - return ( -
- - {legend} - - {children} -
- ); - } - -} - -FieldSet.propTypes = { - size: PropTypes.oneOf(sizes.all).isRequired, - legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), - children: PropTypes.node -}; - -FieldSet.defaultProps = { - size: sizes.MEDIUM -}; - -export default FieldSet; diff --git a/frontend/src/Components/FieldSet.tsx b/frontend/src/Components/FieldSet.tsx new file mode 100644 index 000000000..c2ff03a7f --- /dev/null +++ b/frontend/src/Components/FieldSet.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames'; +import React, { ComponentProps } from 'react'; +import { sizes } from 'Helpers/Props'; +import { Size } from 'Helpers/Props/sizes'; +import styles from './FieldSet.css'; + +interface FieldSetProps { + size?: Size; + legend?: ComponentProps<'legend'>['children']; + children?: React.ReactNode; +} + +function FieldSet({ size = sizes.MEDIUM, legend, children }: FieldSetProps) { + return ( +
+ + {legend} + + {children} +
+ ); +} + +export default FieldSet; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js deleted file mode 100644 index 6b58dbb8c..000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModal.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import FileBrowserModalContentConnector from './FileBrowserModalContentConnector'; -import styles from './FileBrowserModal.css'; - -class FileBrowserModal extends Component { - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -FileBrowserModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.tsx b/frontend/src/Components/FileBrowser/FileBrowserModal.tsx new file mode 100644 index 000000000..0925890de --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import FileBrowserModalContent, { + FileBrowserModalContentProps, +} from './FileBrowserModalContent'; +import styles from './FileBrowserModal.css'; + +interface FileBrowserModalProps extends FileBrowserModalContentProps { + isOpen: boolean; + onModalClose: () => void; +} + +function FileBrowserModal(props: FileBrowserModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js deleted file mode 100644 index f517b4d1b..000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js +++ /dev/null @@ -1,246 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import PathInput from 'Components/Form/PathInput'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -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 Scroller from 'Components/Scroller/Scroller'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import { kinds, scrollDirections } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import FileBrowserRow from './FileBrowserRow'; -import styles from './FileBrowserModalContent.css'; - -const columns = [ - { - name: 'type', - label: () => translate('Type'), - isVisible: true - }, - { - name: 'name', - label: () => translate('Name'), - isVisible: true - } -]; - -class FileBrowserModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scrollerRef = React.createRef(); - - this.state = { - isFileBrowserModalOpen: false, - currentPath: props.value - }; - } - - componentDidUpdate(prevProps, prevState) { - const { - currentPath - } = this.props; - - if ( - currentPath !== this.state.currentPath && - currentPath !== prevState.currentPath - ) { - this.setState({ currentPath }); - this._scrollerRef.current.scrollTop = 0; - } - } - - // - // Listeners - - onPathInputChange = ({ value }) => { - this.setState({ currentPath: value }); - }; - - onRowPress = (path) => { - this.props.onFetchPaths(path); - }; - - onOkPress = () => { - this.props.onChange({ - name: this.props.name, - value: this.state.currentPath - }); - - this.props.onClearPaths(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - parent, - directories, - files, - isWindowsService, - onModalClose, - ...otherProps - } = this.props; - - const emptyParent = parent === ''; - - return ( - - - {translate('FileBrowser')} - - - - { - isWindowsService && - - - - } - - - - - { - !!error && -
{translate('ErrorLoadingContents')}
- } - - { - isPopulated && !error && - - - { - emptyParent && - - } - - { - !emptyParent && parent && - - } - - { - directories.map((directory) => { - return ( - - ); - }) - } - - { - files.map((file) => { - return ( - - ); - }) - } - -
- } -
-
- - - { - isFetching && - - } - - - - - -
- ); - } -} - -FileBrowserModalContent.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - parent: PropTypes.string, - currentPath: PropTypes.string.isRequired, - directories: PropTypes.arrayOf(PropTypes.object).isRequired, - files: PropTypes.arrayOf(PropTypes.object).isRequired, - isWindowsService: PropTypes.bool.isRequired, - onFetchPaths: PropTypes.func.isRequired, - onClearPaths: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default FileBrowserModalContent; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx new file mode 100644 index 000000000..41338cb39 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +import { PathInputInternal } from 'Components/Form/PathInput'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +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 Scroller from 'Components/Scroller/Scroller'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import { clearPaths, fetchPaths } from 'Store/Actions/pathActions'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import createPathsSelector from './createPathsSelector'; +import FileBrowserRow from './FileBrowserRow'; +import styles from './FileBrowserModalContent.css'; + +const columns: Column[] = [ + { + name: 'type', + label: () => translate('Type'), + isVisible: true, + }, + { + name: 'name', + label: () => translate('Name'), + isVisible: true, + }, +]; + +const handleClearPaths = () => {}; + +export interface FileBrowserModalContentProps { + name: string; + value: string; + includeFiles?: boolean; + onChange: (args: InputChanged) => unknown; + onModalClose: () => void; +} + +function FileBrowserModalContent(props: FileBrowserModalContentProps) { + const { name, value, includeFiles = true, onChange, onModalClose } = props; + + const dispatch = useDispatch(); + + const { isWindows, mode } = useSelector(createSystemStatusSelector()); + const { isFetching, isPopulated, error, parent, directories, files, paths } = + useSelector(createPathsSelector()); + + const [currentPath, setCurrentPath] = useState(value); + const scrollerRef = useRef(null); + const previousValue = usePrevious(value); + + const emptyParent = parent === ''; + const isWindowsService = isWindows && mode === 'service'; + + const handlePathInputChange = useCallback( + ({ value }: InputChanged) => { + setCurrentPath(value); + }, + [] + ); + + const handleRowPress = useCallback( + (path: string) => { + setCurrentPath(path); + + dispatch( + fetchPaths({ + path, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }) + ); + }, + [includeFiles, dispatch, setCurrentPath] + ); + + const handleOkPress = useCallback(() => { + onChange({ + name, + value: currentPath, + }); + + dispatch(clearPaths()); + onModalClose(); + }, [name, currentPath, dispatch, onChange, onModalClose]); + + const handleFetchPaths = useCallback( + (path: string) => { + dispatch( + fetchPaths({ + path, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }) + ); + }, + [includeFiles, dispatch] + ); + + useEffect(() => { + if (value !== previousValue && value !== currentPath) { + setCurrentPath(value); + } + }, [value, previousValue, currentPath, setCurrentPath]); + + useEffect( + () => { + dispatch( + fetchPaths({ + path: currentPath, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }) + ); + + return () => { + dispatch(clearPaths()); + }; + }, + // This should only run once when the component mounts, + // so we don't need to include the other dependencies. + // eslint-disable-next-line react-hooks/exhaustive-deps + [dispatch] + ); + + return ( + + {translate('FileBrowser')} + + + {isWindowsService ? ( + + + + ) : null} + + + + + {error ?
{translate('ErrorLoadingContents')}
: null} + + {isPopulated && !error ? ( + + + {emptyParent ? ( + + ) : null} + + {!emptyParent && parent ? ( + + ) : null} + + {directories.map((directory) => { + return ( + + ); + })} + + {files.map((file) => { + return ( + + ); + })} + +
+ ) : null} +
+
+ + + {isFetching ? ( + + ) : null} + + + + + +
+ ); +} + +export default FileBrowserModalContent; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js deleted file mode 100644 index 1a3a41ef0..000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js +++ /dev/null @@ -1,119 +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 { clearPaths, fetchPaths } from 'Store/Actions/pathActions'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import FileBrowserModalContent from './FileBrowserModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.paths, - createSystemStatusSelector(), - (paths, systemStatus) => { - const { - isFetching, - isPopulated, - error, - parent, - currentPath, - directories, - files - } = paths; - - const filteredPaths = _.filter([...directories, ...files], ({ path }) => { - return path.toLowerCase().startsWith(currentPath.toLowerCase()); - }); - - return { - isFetching, - isPopulated, - error, - parent, - currentPath, - directories, - files, - paths: filteredPaths, - isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchPaths: fetchPaths, - dispatchClearPaths: clearPaths -}; - -class FileBrowserModalContentConnector extends Component { - - // Lifecycle - - componentDidMount() { - const { - value, - includeFiles, - dispatchFetchPaths - } = this.props; - - dispatchFetchPaths({ - path: value, - allowFoldersWithoutTrailingSlashes: true, - includeFiles - }); - } - - // - // Listeners - - onFetchPaths = (path) => { - const { - includeFiles, - dispatchFetchPaths - } = this.props; - - dispatchFetchPaths({ - path, - allowFoldersWithoutTrailingSlashes: true, - includeFiles - }); - }; - - onClearPaths = () => { - // this.props.dispatchClearPaths(); - }; - - onModalClose = () => { - this.props.dispatchClearPaths(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -FileBrowserModalContentConnector.propTypes = { - value: PropTypes.string, - includeFiles: PropTypes.bool.isRequired, - dispatchFetchPaths: PropTypes.func.isRequired, - dispatchClearPaths: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -FileBrowserModalContentConnector.defaultProps = { - includeFiles: false -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector); diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js deleted file mode 100644 index 06bb3029d..000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserRow.js +++ /dev/null @@ -1,62 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRowButton from 'Components/Table/TableRowButton'; -import { icons } from 'Helpers/Props'; -import styles from './FileBrowserRow.css'; - -function getIconName(type) { - switch (type) { - case 'computer': - return icons.COMPUTER; - case 'drive': - return icons.DRIVE; - case 'file': - return icons.FILE; - case 'parent': - return icons.PARENT; - default: - return icons.FOLDER; - } -} - -class FileBrowserRow extends Component { - - // - // Listeners - - onPress = () => { - this.props.onPress(this.props.path); - }; - - // - // Render - - render() { - const { - type, - name - } = this.props; - - return ( - - - - - - {name} - - ); - } - -} - -FileBrowserRow.propTypes = { - type: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - onPress: PropTypes.func.isRequired -}; - -export default FileBrowserRow; diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.tsx b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx new file mode 100644 index 000000000..fe47f1664 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { PathType } from 'App/State/PathsAppState'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowButton from 'Components/Table/TableRowButton'; +import { icons } from 'Helpers/Props'; +import styles from './FileBrowserRow.css'; + +function getIconName(type: PathType) { + switch (type) { + case 'computer': + return icons.COMPUTER; + case 'drive': + return icons.DRIVE; + case 'file': + return icons.FILE; + case 'parent': + return icons.PARENT; + default: + return icons.FOLDER; + } +} + +interface FileBrowserRowProps { + type: PathType; + name: string; + path: string; + onPress: (path: string) => void; +} + +function FileBrowserRow(props: FileBrowserRowProps) { + const { type, name, path, onPress } = props; + + const handlePress = useCallback(() => { + onPress(path); + }, [path, onPress]); + + return ( + + + + + + {name} + + ); +} + +export default FileBrowserRow; diff --git a/frontend/src/Components/FileBrowser/createPathsSelector.ts b/frontend/src/Components/FileBrowser/createPathsSelector.ts new file mode 100644 index 000000000..5da830bd5 --- /dev/null +++ b/frontend/src/Components/FileBrowser/createPathsSelector.ts @@ -0,0 +1,36 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createPathsSelector() { + return createSelector( + (state: AppState) => state.paths, + (paths) => { + const { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files, + } = paths; + + const filteredPaths = [...directories, ...files].filter(({ path }) => { + return path.toLowerCase().startsWith(currentPath.toLowerCase()); + }); + + return { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files, + paths: filteredPaths, + }; + } + ); +} + +export default createPathsSelector; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js index d718aab0c..0c4a31657 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -1,3 +1,4 @@ +import { maxBy } from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -8,6 +9,7 @@ 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'; @@ -49,7 +51,7 @@ class FilterBuilderModalContent extends Component { if (id) { dispatchSetFilter({ selectedFilterKey: id }); } else { - const last = customFilters[customFilters.length -1]; + const last = maxBy(customFilters, 'id'); dispatchSetFilter({ selectedFilterKey: last.id }); } @@ -107,7 +109,7 @@ class FilterBuilderModalContent extends Component { this.setState({ labelErrors: [ { - message: 'Label is required' + message: translate('LabelIsRequired') } ] }); @@ -145,13 +147,13 @@ class FilterBuilderModalContent extends Component { return ( - Custom Filter + {translate('CustomFilter')}
- Label + {translate('Label')}
@@ -165,7 +167,9 @@ class FilterBuilderModalContent extends Component {
-
Filters
+
+ {translate('Filters')} +
{ @@ -192,7 +196,7 @@ class FilterBuilderModalContent extends Component { - Save + {translate('Save')} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index 01c24b460..0b00c0f03 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import SelectInput from 'Components/Form/SelectInput'; import IconButton from 'Components/Link/IconButton'; import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import sortByProp from 'Utilities/Array/sortByProp'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; @@ -11,7 +12,9 @@ import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValu import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; -import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; +import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; +import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue'; +import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue'; import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue'; import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue'; import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue'; @@ -76,7 +79,13 @@ function getRowValueConnector(selectedFilterBuilderProp) { return QualityFilterBuilderRowValueConnector; case filterBuilderValueTypes.QUALITY_PROFILE: - return QualityProfileFilterBuilderRowValueConnector; + return QualityProfileFilterBuilderRowValue; + + case filterBuilderValueTypes.QUEUE_STATUS: + return QueueStatusFilterBuilderRowValue; + + case filterBuilderValueTypes.SEASONS_MONITORED_STATUS: + return SeasonsMonitoredStatusFilterBuilderRowValue; case filterBuilderValueTypes.SERIES: return SeriesFilterBuilderRowValue; @@ -224,7 +233,7 @@ class FilterBuilderRow extends Component { key: name, value: typeof label === 'function' ? label() : label }; - }).sort((a, b) => a.value.localeCompare(b.value)); + }).sort(sortByProp('value')); const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js index 68fa5c557..217626c90 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import TagInput from 'Components/Form/TagInput'; +import TagInput from 'Components/Form/Tag/TagInput'; import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props'; import tagShape from 'Helpers/Props/Shapes/tagShape'; import convertToBytes from 'Utilities/Number/convertToBytes'; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js index a7aed80b6..d1419327a 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { filterBuilderTypes } from 'Helpers/Props'; import * as filterTypes from 'Helpers/Props/filterTypes'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import FilterBuilderRowValue from './FilterBuilderRowValue'; function createTagListSelector() { @@ -38,7 +38,7 @@ function createTagListSelector() { } return acc; - }, []).sort(sortByName); + }, []).sort(sortByProp('name')); } return _.uniqBy(items, 'id'); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js index 7b6d6313a..063a97346 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import TagInputTag from 'Components/Form/TagInputTag'; +import TagInputTag from 'Components/Form/Tag/TagInputTag'; import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './FilterBuilderRowValueTag.css'; diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx new file mode 100644 index 000000000..50036cb90 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps'; +import sortByProp from 'Utilities/Array/sortByProp'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createQualityProfilesSelector() { + return createSelector( + (state: AppState) => state.settings.qualityProfiles.items, + (qualityProfiles) => { + return qualityProfiles; + } + ); +} + +function QualityProfileFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + const qualityProfiles = useSelector(createQualityProfilesSelector()); + + const tagList = qualityProfiles + .map(({ id, name }) => ({ id, name })) + .sort(sortByProp('name')); + + return ; +} + +export default QualityProfileFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js deleted file mode 100644 index 4a8b82283..000000000 --- a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js +++ /dev/null @@ -1,28 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterBuilderRowValue from './FilterBuilderRowValue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.qualityProfiles, - (qualityProfiles) => { - const tagList = qualityProfiles.items.map((qualityProfile) => { - const { - id, - name - } = qualityProfile; - - return { - id, - name - }; - }); - - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx new file mode 100644 index 000000000..1127493a5 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +const statusTagList = [ + { + id: 'queued', + get name() { + return translate('Queued'); + }, + }, + { + id: 'paused', + get name() { + return translate('Paused'); + }, + }, + { + id: 'downloading', + get name() { + return translate('Downloading'); + }, + }, + { + id: 'completed', + get name() { + return translate('Completed'); + }, + }, + { + id: 'failed', + get name() { + return translate('Failed'); + }, + }, + { + id: 'warning', + get name() { + return translate('Warning'); + }, + }, + { + id: 'delay', + get name() { + return translate('Delay'); + }, + }, + { + id: 'downloadClientUnavailable', + get name() { + return translate('DownloadClientUnavailable'); + }, + }, + { + id: 'fallback', + get name() { + return translate('Fallback'); + }, + }, +]; + +function QueueStatusFilterBuilderRowValue(props: FilterBuilderRowValueProps) { + return ; +} + +export default QueueStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js new file mode 100644 index 000000000..b84260e3c --- /dev/null +++ b/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js @@ -0,0 +1,35 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const seasonsMonitoredStatusList = [ + { + id: 'all', + get name() { + return translate('SeasonsMonitoredAll'); + } + }, + { + id: 'partial', + get name() { + return translate('SeasonsMonitoredPartial'); + } + }, + { + id: 'none', + get name() { + return translate('SeasonsMonitoredNone'); + } + } +]; + +function SeasonsMonitoredStatusFilterBuilderRowValue(props) { + return ( + + ); +} + +export default SeasonsMonitoredStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx index 2eae79c80..88b34509a 100644 --- a/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx +++ b/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; import Series from 'Series/Series'; import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import FilterBuilderRowValue from './FilterBuilderRowValue'; import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; @@ -11,7 +11,7 @@ function SeriesFilterBuilderRowValue(props: FilterBuilderRowValueProps) { const tagList = allSeries .map((series) => ({ id: series.id, name: series.title })) - .sort(sortByName); + .sort(sortByProp('name')); return ; } diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js index 3464300f1..e017f72e7 100644 --- a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js @@ -2,7 +2,7 @@ import React from 'react'; import translate from 'Utilities/String/translate'; import FilterBuilderRowValue from './FilterBuilderRowValue'; -const seriesStatusList = [ +const statusTagList = [ { id: 'continuing', get name() { @@ -32,7 +32,7 @@ const seriesStatusList = [ function SeriesStatusFilterBuilderRowValue(props) { return ( ); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js index 7407f729a..9f378d5a2 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -37,8 +37,8 @@ class CustomFilter extends Component { dispatchSetFilter } = this.props; - // Assume that delete and then unmounting means the delete was successful. - // Moving this check to a ancestor would be more accurate, but would have + // Assume that delete and then unmounting means the deletion was successful. + // Moving this check to an ancestor would be more accurate, but would have // more boilerplate. if (this.state.isDeleting && id === selectedFilterKey) { dispatchSetFilter({ selectedFilterKey: 'all' }); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js index 28eb91599..99cb6ec5c 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -5,6 +5,7 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import CustomFilter from './CustomFilter'; import styles from './CustomFiltersModalContent.css'; @@ -31,7 +32,7 @@ function CustomFiltersModalContent(props) { { customFilters - .sort((a, b) => a.label.localeCompare(b.label)) + .sort((a, b) => sortByProp(a, b, 'label')) .map((customFilter) => { return ( { - this.props.onChange({ - name: this.props.name, - value: newValue - }); - }; - - onInputBlur = () => { - this.setState({ suggestions: [] }); - }; - - onSuggestionsFetchRequested = ({ value }) => { - const { values } = this.props; - const lowerCaseValue = jdu.replace(value).toLowerCase(); - - const filteredValues = values.filter((v) => { - return jdu.replace(v).toLowerCase().contains(lowerCaseValue); - }); - - this.setState({ suggestions: filteredValues }); - }; - - onSuggestionsClearRequested = () => { - this.setState({ suggestions: [] }); - }; - - // - // Render - - render() { - const { - name, - value, - ...otherProps - } = this.props; - - const { suggestions } = this.state; - - return ( - - ); - } -} - -AutoCompleteInput.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string, - values: PropTypes.arrayOf(PropTypes.string).isRequired, - onChange: PropTypes.func.isRequired -}; - -AutoCompleteInput.defaultProps = { - value: '' -}; - -export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/AutoCompleteInput.tsx b/frontend/src/Components/Form/AutoCompleteInput.tsx new file mode 100644 index 000000000..7ba114125 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.tsx @@ -0,0 +1,81 @@ +import jdu from 'jdu'; +import React, { SyntheticEvent, useCallback, useState } from 'react'; +import { + ChangeEvent, + SuggestionsFetchRequestedParams, +} from 'react-autosuggest'; +import { InputChanged } from 'typings/inputs'; +import AutoSuggestInput from './AutoSuggestInput'; + +interface AutoCompleteInputProps { + name: string; + value?: string; + values: string[]; + onChange: (change: InputChanged) => unknown; +} + +function AutoCompleteInput({ + name, + value = '', + values, + onChange, + ...otherProps +}: AutoCompleteInputProps) { + const [suggestions, setSuggestions] = useState([]); + + const getSuggestionValue = useCallback((item: string) => { + return item; + }, []); + + const renderSuggestion = useCallback((item: string) => { + return item; + }, []); + + const handleInputChange = useCallback( + (_event: SyntheticEvent, { newValue }: ChangeEvent) => { + onChange({ + name, + value: newValue, + }); + }, + [name, onChange] + ); + + const handleInputBlur = useCallback(() => { + setSuggestions([]); + }, [setSuggestions]); + + const handleSuggestionsFetchRequested = useCallback( + ({ value: newValue }: SuggestionsFetchRequestedParams) => { + const lowerCaseValue = jdu.replace(newValue).toLowerCase(); + + const filteredValues = values.filter((v) => { + return jdu.replace(v).toLowerCase().includes(lowerCaseValue); + }); + + setSuggestions(filteredValues); + }, + [values, setSuggestions] + ); + + const handleSuggestionsClearRequested = useCallback(() => { + setSuggestions([]); + }, [setSuggestions]); + + return ( + + ); +} + +export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/AutoSuggestInput.js b/frontend/src/Components/Form/AutoSuggestInput.js deleted file mode 100644 index 34ec7530b..000000000 --- a/frontend/src/Components/Form/AutoSuggestInput.js +++ /dev/null @@ -1,257 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Autosuggest from 'react-autosuggest'; -import { Manager, Popper, Reference } from 'react-popper'; -import Portal from 'Components/Portal'; -import styles from './AutoSuggestInput.css'; - -class AutoSuggestInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scheduleUpdate = null; - } - - componentDidUpdate(prevProps) { - if ( - this._scheduleUpdate && - prevProps.suggestions !== this.props.suggestions - ) { - this._scheduleUpdate(); - } - } - - // - // Control - - renderInputComponent = (inputProps) => { - const { renderInputComponent } = this.props; - - return ( - - {({ ref }) => { - if (renderInputComponent) { - return renderInputComponent(inputProps, ref); - } - - return ( -
- -
- ); - }} -
- ); - }; - - renderSuggestionsContainer = ({ containerProps, children }) => { - return ( - - - {({ ref: popperRef, style, scheduleUpdate }) => { - this._scheduleUpdate = scheduleUpdate; - - return ( -
-
- {children} -
-
- ); - }} -
-
- ); - }; - - // - // Listeners - - onComputeMaxHeight = (data) => { - const { - top, - bottom, - width - } = data.offsets.reference; - - const windowHeight = window.innerHeight; - - if ((/^botton/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom; - } else { - data.styles.maxHeight = top; - } - - data.styles.width = width; - - return data; - }; - - onInputChange = (event, { newValue }) => { - this.props.onChange({ - name: this.props.name, - value: newValue - }); - }; - - onInputKeyDown = (event) => { - const { - name, - value, - suggestions, - onChange - } = this.props; - - if ( - event.key === 'Tab' && - suggestions.length && - suggestions[0] !== this.props.value - ) { - event.preventDefault(); - - if (value) { - onChange({ - name, - value: suggestions[0] - }); - } - } - }; - - // - // Render - - render() { - const { - forwardedRef, - className, - inputContainerClassName, - name, - value, - placeholder, - suggestions, - hasError, - hasWarning, - getSuggestionValue, - renderSuggestion, - onInputChange, - onInputKeyDown, - onInputFocus, - onInputBlur, - onSuggestionsFetchRequested, - onSuggestionsClearRequested, - onSuggestionSelected, - ...otherProps - } = this.props; - - const inputProps = { - className: classNames( - className, - hasError && styles.hasError, - hasWarning && styles.hasWarning - ), - name, - value, - placeholder, - autoComplete: 'off', - spellCheck: false, - onChange: onInputChange || this.onInputChange, - onKeyDown: onInputKeyDown || this.onInputKeyDown, - onFocus: onInputFocus, - onBlur: onInputBlur - }; - - const theme = { - container: inputContainerClassName, - containerOpen: styles.suggestionsContainerOpen, - suggestionsContainer: styles.suggestionsContainer, - suggestionsList: styles.suggestionsList, - suggestion: styles.suggestion, - suggestionHighlighted: styles.suggestionHighlighted - }; - - return ( - - - - ); - } -} - -AutoSuggestInput.propTypes = { - forwardedRef: PropTypes.func, - className: PropTypes.string.isRequired, - inputContainerClassName: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - placeholder: PropTypes.string, - suggestions: PropTypes.array.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - enforceMaxHeight: PropTypes.bool.isRequired, - minHeight: PropTypes.number.isRequired, - maxHeight: PropTypes.number.isRequired, - getSuggestionValue: PropTypes.func.isRequired, - renderInputComponent: PropTypes.elementType, - renderSuggestion: PropTypes.func.isRequired, - onInputChange: PropTypes.func, - onInputKeyDown: PropTypes.func, - onInputFocus: PropTypes.func, - onInputBlur: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionSelected: PropTypes.func, - onChange: PropTypes.func.isRequired -}; - -AutoSuggestInput.defaultProps = { - className: styles.input, - inputContainerClassName: styles.inputContainer, - enforceMaxHeight: true, - minHeight: 50, - maxHeight: 200 -}; - -export default AutoSuggestInput; diff --git a/frontend/src/Components/Form/AutoSuggestInput.tsx b/frontend/src/Components/Form/AutoSuggestInput.tsx new file mode 100644 index 000000000..b3a7c31b0 --- /dev/null +++ b/frontend/src/Components/Form/AutoSuggestInput.tsx @@ -0,0 +1,259 @@ +import classNames from 'classnames'; +import React, { + FocusEvent, + FormEvent, + KeyboardEvent, + KeyboardEventHandler, + MutableRefObject, + ReactNode, + Ref, + SyntheticEvent, + useCallback, + useEffect, + useRef, +} from 'react'; +import Autosuggest, { + AutosuggestPropsBase, + BlurEvent, + ChangeEvent, + RenderInputComponentProps, + RenderSuggestionsContainerParams, +} from 'react-autosuggest'; +import { Manager, Popper, Reference } from 'react-popper'; +import Portal from 'Components/Portal'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { InputChanged } from 'typings/inputs'; +import styles from './AutoSuggestInput.css'; + +interface AutoSuggestInputProps + extends Omit, 'renderInputComponent' | 'inputProps'> { + forwardedRef?: MutableRefObject | null>; + className?: string; + inputContainerClassName?: string; + name: string; + value?: string; + placeholder?: string; + suggestions: T[]; + hasError?: boolean; + hasWarning?: boolean; + enforceMaxHeight?: boolean; + minHeight?: number; + maxHeight?: number; + renderInputComponent?: ( + inputProps: RenderInputComponentProps, + ref: Ref + ) => ReactNode; + onInputChange: ( + event: FormEvent, + params: ChangeEvent + ) => unknown; + onInputKeyDown?: KeyboardEventHandler; + onInputFocus?: (event: SyntheticEvent) => unknown; + onInputBlur: ( + event: FocusEvent, + params?: BlurEvent + ) => unknown; + onChange?: (change: InputChanged) => unknown; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function AutoSuggestInput(props: AutoSuggestInputProps) { + const { + // TODO: forwaredRef should be replaces with React.forwardRef + forwardedRef, + className = styles.input, + inputContainerClassName = styles.inputContainer, + name, + value = '', + placeholder, + suggestions, + enforceMaxHeight = true, + hasError, + hasWarning, + minHeight = 50, + maxHeight = 200, + getSuggestionValue, + renderSuggestion, + renderInputComponent, + onInputChange, + onInputKeyDown, + onInputFocus, + onInputBlur, + onSuggestionsFetchRequested, + onSuggestionsClearRequested, + onSuggestionSelected, + onChange, + ...otherProps + } = props; + + const updater = useRef<(() => void) | null>(null); + const previousSuggestions = usePrevious(suggestions); + + const handleComputeMaxHeight = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data: any) => { + const { top, bottom, width } = data.offsets.reference; + + if (enforceMaxHeight) { + data.styles.maxHeight = maxHeight; + } else { + const windowHeight = window.innerHeight; + + if (/^botton/.test(data.placement)) { + data.styles.maxHeight = windowHeight - bottom; + } else { + data.styles.maxHeight = top; + } + } + + data.styles.width = width; + + return data; + }, + [enforceMaxHeight, maxHeight] + ); + + const createRenderInputComponent = useCallback( + (inputProps: RenderInputComponentProps) => { + return ( + + {({ ref }) => { + if (renderInputComponent) { + return renderInputComponent(inputProps, ref); + } + + return ( +
+ +
+ ); + }} +
+ ); + }, + [renderInputComponent] + ); + + const renderSuggestionsContainer = useCallback( + ({ containerProps, children }: RenderSuggestionsContainerParams) => { + return ( + + + {({ ref: popperRef, style, scheduleUpdate }) => { + updater.current = scheduleUpdate; + + return ( +
+
+ {children} +
+
+ ); + }} +
+
+ ); + }, + [minHeight, handleComputeMaxHeight] + ); + + const handleInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if ( + event.key === 'Tab' && + suggestions.length && + suggestions[0] !== value + ) { + event.preventDefault(); + + if (value) { + onSuggestionSelected?.(event, { + suggestion: suggestions[0], + suggestionValue: value, + suggestionIndex: 0, + sectionIndex: null, + method: 'enter', + }); + } + } + }, + [value, suggestions, onSuggestionSelected] + ); + + const inputProps = { + className: classNames( + className, + hasError && styles.hasError, + hasWarning && styles.hasWarning + ), + name, + value, + placeholder, + autoComplete: 'off', + spellCheck: false, + onChange: onInputChange, + onKeyDown: onInputKeyDown || handleInputKeyDown, + onFocus: onInputFocus, + onBlur: onInputBlur, + }; + + const theme = { + container: inputContainerClassName, + containerOpen: styles.suggestionsContainerOpen, + suggestionsContainer: styles.suggestionsContainer, + suggestionsList: styles.suggestionsList, + suggestion: styles.suggestion, + suggestionHighlighted: styles.suggestionHighlighted, + }; + + useEffect(() => { + if (updater.current && suggestions !== previousSuggestions) { + updater.current(); + } + }, [suggestions, previousSuggestions]); + + return ( + + + + ); +} + +export default AutoSuggestInput; diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js deleted file mode 100644 index b422198b5..000000000 --- a/frontend/src/Components/Form/CaptchaInput.js +++ /dev/null @@ -1,84 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ReCAPTCHA from 'react-google-recaptcha'; -import Icon from 'Components/Icon'; -import { icons } from 'Helpers/Props'; -import FormInputButton from './FormInputButton'; -import TextInput from './TextInput'; -import styles from './CaptchaInput.css'; - -function CaptchaInput(props) { - const { - className, - name, - value, - hasError, - hasWarning, - refreshing, - siteKey, - secretToken, - onChange, - onRefreshPress, - onCaptchaChange - } = props; - - return ( -
-
- - - - - -
- - { - !!siteKey && !!secretToken && -
- -
- } -
- ); -} - -CaptchaInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - refreshing: PropTypes.bool.isRequired, - siteKey: PropTypes.string, - secretToken: PropTypes.string, - onChange: PropTypes.func.isRequired, - onRefreshPress: PropTypes.func.isRequired, - onCaptchaChange: PropTypes.func.isRequired -}; - -CaptchaInput.defaultProps = { - className: styles.input, - value: '' -}; - -export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInput.tsx b/frontend/src/Components/Form/CaptchaInput.tsx new file mode 100644 index 000000000..d5a3f11f7 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.tsx @@ -0,0 +1,118 @@ +import classNames from 'classnames'; +import React, { useCallback, useEffect } from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Icon from 'Components/Icon'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons } from 'Helpers/Props'; +import { + getCaptchaCookie, + refreshCaptcha, + resetCaptcha, +} from 'Store/Actions/captchaActions'; +import { InputChanged } from 'typings/inputs'; +import FormInputButton from './FormInputButton'; +import TextInput from './TextInput'; +import styles from './CaptchaInput.css'; + +interface CaptchaInputProps { + className?: string; + name: string; + value?: string; + provider: string; + providerData: object; + hasError?: boolean; + hasWarning?: boolean; + refreshing: boolean; + siteKey?: string; + secretToken?: string; + onChange: (change: InputChanged) => unknown; +} + +function CaptchaInput({ + className = styles.input, + name, + value = '', + provider, + providerData, + hasError, + hasWarning, + refreshing, + siteKey, + secretToken, + onChange, +}: CaptchaInputProps) { + const { token } = useSelector((state: AppState) => state.captcha); + const dispatch = useDispatch(); + const previousToken = usePrevious(token); + + const handleCaptchaChange = useCallback( + (token: string | null) => { + // If the captcha has expired `captchaResponse` will be null. + // In the event it's null don't try to get the captchaCookie. + // TODO: Should we clear the cookie? or reset the captcha? + + if (!token) { + return; + } + + dispatch( + getCaptchaCookie({ + provider, + providerData, + captchaResponse: token, + }) + ); + }, + [provider, providerData, dispatch] + ); + + const handleRefreshPress = useCallback(() => { + dispatch(refreshCaptcha({ provider, providerData })); + }, [provider, providerData, dispatch]); + + useEffect(() => { + if (token && token !== previousToken) { + onChange({ name, value: token }); + } + }, [name, token, previousToken, onChange]); + + useEffect(() => { + dispatch(resetCaptcha()); + }, [dispatch]); + + return ( +
+
+ + + + + +
+ + {siteKey && secretToken ? ( +
+ +
+ ) : null} +
+ ); +} + +export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js deleted file mode 100644 index ad83bf02f..000000000 --- a/frontend/src/Components/Form/CaptchaInputConnector.js +++ /dev/null @@ -1,98 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions'; -import CaptchaInput from './CaptchaInput'; - -function createMapStateToProps() { - return createSelector( - (state) => state.captcha, - (captcha) => { - return captcha; - } - ); -} - -const mapDispatchToProps = { - refreshCaptcha, - getCaptchaCookie, - resetCaptcha -}; - -class CaptchaInputConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps) { - const { - name, - token, - onChange - } = this.props; - - if (token && token !== prevProps.token) { - onChange({ name, value: token }); - } - } - - componentWillUnmount = () => { - this.props.resetCaptcha(); - }; - - // - // Listeners - - onRefreshPress = () => { - const { - provider, - providerData - } = this.props; - - this.props.refreshCaptcha({ provider, providerData }); - }; - - onCaptchaChange = (captchaResponse) => { - // If the captcha has expired `captchaResponse` will be null. - // In the event it's null don't try to get the captchaCookie. - // TODO: Should we clear the cookie? or reset the captcha? - - if (!captchaResponse) { - return; - } - - const { - provider, - providerData - } = this.props; - - this.props.getCaptchaCookie({ provider, providerData, captchaResponse }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CaptchaInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - token: PropTypes.string, - onChange: PropTypes.func.isRequired, - refreshCaptcha: PropTypes.func.isRequired, - getCaptchaCookie: PropTypes.func.isRequired, - resetCaptcha: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector); diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js deleted file mode 100644 index 26d915880..000000000 --- a/frontend/src/Components/Form/CheckInput.js +++ /dev/null @@ -1,191 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import { icons, kinds } from 'Helpers/Props'; -import FormInputHelpText from './FormInputHelpText'; -import styles from './CheckInput.css'; - -class CheckInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._checkbox = null; - } - - componentDidMount() { - this.setIndeterminate(); - } - - componentDidUpdate() { - this.setIndeterminate(); - } - - // - // Control - - setIndeterminate() { - if (!this._checkbox) { - return; - } - - const { - value, - uncheckedValue, - checkedValue - } = this.props; - - this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue; - } - - toggleChecked = (checked, shiftKey) => { - const { - name, - value, - checkedValue, - uncheckedValue - } = this.props; - - const newValue = checked ? checkedValue : uncheckedValue; - - if (value !== newValue) { - this.props.onChange({ - name, - value: newValue, - shiftKey - }); - } - }; - - // - // Listeners - - setRef = (ref) => { - this._checkbox = ref; - }; - - onClick = (event) => { - if (this.props.isDisabled) { - return; - } - - const shiftKey = event.nativeEvent.shiftKey; - const checked = !this._checkbox.checked; - - event.preventDefault(); - this.toggleChecked(checked, shiftKey); - }; - - onChange = (event) => { - const checked = event.target.checked; - const shiftKey = event.nativeEvent.shiftKey; - - this.toggleChecked(checked, shiftKey); - }; - - // - // Render - - render() { - const { - className, - containerClassName, - name, - value, - checkedValue, - uncheckedValue, - helpText, - helpTextWarning, - isDisabled, - kind - } = this.props; - - const isChecked = value === checkedValue; - const isUnchecked = value === uncheckedValue; - const isIndeterminate = !isChecked && !isUnchecked; - const isCheckClass = `${kind}IsChecked`; - - return ( -
- -
- ); - } -} - -CheckInput.propTypes = { - className: PropTypes.string.isRequired, - containerClassName: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - checkedValue: PropTypes.bool, - uncheckedValue: PropTypes.bool, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - helpText: PropTypes.string, - helpTextWarning: PropTypes.string, - isDisabled: PropTypes.bool, - kind: PropTypes.oneOf(kinds.all).isRequired, - onChange: PropTypes.func.isRequired -}; - -CheckInput.defaultProps = { - className: styles.input, - containerClassName: styles.container, - checkedValue: true, - uncheckedValue: false, - kind: kinds.PRIMARY -}; - -export default CheckInput; diff --git a/frontend/src/Components/Form/CheckInput.tsx b/frontend/src/Components/Form/CheckInput.tsx new file mode 100644 index 000000000..b7080cfdd --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.tsx @@ -0,0 +1,141 @@ +import classNames from 'classnames'; +import React, { SyntheticEvent, useCallback, useEffect, useRef } from 'react'; +import Icon from 'Components/Icon'; +import { icons } from 'Helpers/Props'; +import { Kind } from 'Helpers/Props/kinds'; +import { CheckInputChanged } from 'typings/inputs'; +import FormInputHelpText from './FormInputHelpText'; +import styles from './CheckInput.css'; + +interface ChangeEvent extends SyntheticEvent { + target: EventTarget & T; +} + +interface CheckInputProps { + className?: string; + containerClassName?: string; + name: string; + checkedValue?: boolean; + uncheckedValue?: boolean; + value?: string | boolean; + helpText?: string; + helpTextWarning?: string; + isDisabled?: boolean; + kind?: Extract; + onChange: (changes: CheckInputChanged) => void; +} + +function CheckInput(props: CheckInputProps) { + const { + className = styles.input, + containerClassName = styles.container, + name, + value, + checkedValue = true, + uncheckedValue = false, + helpText, + helpTextWarning, + isDisabled, + kind = 'primary', + onChange, + } = props; + + const inputRef = useRef(null); + + const isChecked = value === checkedValue; + const isUnchecked = value === uncheckedValue; + const isIndeterminate = !isChecked && !isUnchecked; + const isCheckClass: keyof typeof styles = `${kind}IsChecked`; + + const toggleChecked = useCallback( + (checked: boolean, shiftKey: boolean) => { + const newValue = checked ? checkedValue : uncheckedValue; + + if (value !== newValue) { + onChange({ + name, + value: newValue, + shiftKey, + }); + } + }, + [name, value, checkedValue, uncheckedValue, onChange] + ); + + const handleClick = useCallback( + (event: SyntheticEvent) => { + if (isDisabled) { + return; + } + + const shiftKey = event.nativeEvent.shiftKey; + const checked = !(inputRef.current?.checked ?? false); + + event.preventDefault(); + toggleChecked(checked, shiftKey); + }, + [isDisabled, toggleChecked] + ); + + const handleChange = useCallback( + (event: ChangeEvent) => { + const checked = event.target.checked; + const shiftKey = event.nativeEvent.shiftKey; + + toggleChecked(checked, shiftKey); + }, + [toggleChecked] + ); + + useEffect(() => { + if (!inputRef.current) { + return; + } + + inputRef.current.indeterminate = + value !== uncheckedValue && value !== checkedValue; + }, [value, uncheckedValue, checkedValue]); + + return ( +
+ +
+ ); +} + +export default CheckInput; diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js deleted file mode 100644 index 55c239cb8..000000000 --- a/frontend/src/Components/Form/DeviceInput.js +++ /dev/null @@ -1,106 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import { icons } from 'Helpers/Props'; -import tagShape from 'Helpers/Props/Shapes/tagShape'; -import FormInputButton from './FormInputButton'; -import TagInput from './TagInput'; -import styles from './DeviceInput.css'; - -class DeviceInput extends Component { - - onTagAdd = (device) => { - const { - name, - value, - onChange - } = this.props; - - // New tags won't have an ID, only a name. - const deviceId = device.id || device.name; - - onChange({ - name, - value: [...value, deviceId] - }); - }; - - onTagDelete = ({ index }) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = value.slice(); - newValue.splice(index, 1); - - onChange({ - name, - value: newValue - }); - }; - - // - // Render - - render() { - const { - className, - name, - items, - selectedDevices, - hasError, - hasWarning, - isFetching, - onRefreshPress - } = this.props; - - return ( -
- - - - - -
- ); - } -} - -DeviceInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, - items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onRefreshPress: PropTypes.func.isRequired -}; - -DeviceInput.defaultProps = { - className: styles.deviceInputWrapper, - inputClassName: styles.input -}; - -export default DeviceInput; diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js deleted file mode 100644 index 2af9a79f6..000000000 --- a/frontend/src/Components/Form/DeviceInputConnector.js +++ /dev/null @@ -1,104 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; -import DeviceInput from './DeviceInput'; - -function createMapStateToProps() { - return createSelector( - (state, { value }) => value, - (state) => state.providerOptions.devices || defaultState, - (value, devices) => { - - return { - ...devices, - selectedDevices: value.map((valueDevice) => { - // Disable equality ESLint rule so we don't need to worry about - // a type mismatch between the value items and the device ID. - // eslint-disable-next-line eqeqeq - const device = devices.items.find((d) => d.id == valueDevice); - - if (device) { - return { - id: device.id, - name: `${device.name} (${device.id})` - }; - } - - return { - id: valueDevice, - name: `Unknown (${valueDevice})` - }; - }) - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchOptions: fetchOptions, - dispatchClearOptions: clearOptions -}; - -class DeviceInputConnector extends Component { - - // - // Lifecycle - - componentDidMount = () => { - this._populate(); - }; - - componentWillUnmount = () => { - this.props.dispatchClearOptions({ section: 'devices' }); - }; - - // - // Control - - _populate() { - const { - provider, - providerData, - dispatchFetchOptions - } = this.props; - - dispatchFetchOptions({ - section: 'devices', - action: 'getDevices', - provider, - providerData - }); - } - - // - // Listeners - - onRefreshPress = () => { - this._populate(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -DeviceInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - dispatchFetchOptions: PropTypes.func.isRequired, - dispatchClearOptions: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector); diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js deleted file mode 100644 index f0ebf534b..000000000 --- a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js +++ /dev/null @@ -1,101 +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 { fetchDownloadClients } from 'Store/Actions/settingsActions'; -import sortByName from 'Utilities/Array/sortByName'; -import translate from 'Utilities/String/translate'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.downloadClients, - (state, { includeAny }) => includeAny, - (state, { protocol }) => protocol, - (downloadClients, includeAny, protocolFilter) => { - const { - isFetching, - isPopulated, - error, - items - } = downloadClients; - - const filteredItems = items.filter((item) => item.protocol === protocolFilter); - - const values = _.map(filteredItems.sort(sortByName), (downloadClient) => { - return { - key: downloadClient.id, - value: downloadClient.name - }; - }); - - if (includeAny) { - values.unshift({ - key: 0, - value: `(${translate('Any')})` - }); - } - - return { - isFetching, - isPopulated, - error, - values - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchDownloadClients: fetchDownloadClients -}; - -class DownloadClientSelectInputConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - if (!this.props.isPopulated) { - this.props.dispatchFetchDownloadClients(); - } - } - - // - // Listeners - - onChange = ({ name, value }) => { - this.props.onChange({ name, value: parseInt(value) }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -DownloadClientSelectInputConnector.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - includeAny: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - dispatchFetchDownloadClients: PropTypes.func.isRequired -}; - -DownloadClientSelectInputConnector.defaultProps = { - includeAny: false, - protocol: 'torrent' -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js deleted file mode 100644 index cc4215025..000000000 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ /dev/null @@ -1,608 +0,0 @@ -import classNames from 'classnames'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Manager, Popper, Reference } from 'react-popper'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Measure from 'Components/Measure'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import Portal from 'Components/Portal'; -import Scroller from 'Components/Scroller/Scroller'; -import { icons, scrollDirections, sizes } from 'Helpers/Props'; -import { isMobile as isMobileUtil } from 'Utilities/browser'; -import * as keyCodes from 'Utilities/Constants/keyCodes'; -import getUniqueElememtId from 'Utilities/getUniqueElementId'; -import HintedSelectInputOption from './HintedSelectInputOption'; -import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; -import TextInput from './TextInput'; -import styles from './EnhancedSelectInput.css'; - -function isArrowKey(keyCode) { - return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; -} - -function getSelectedOption(selectedIndex, values) { - return values[selectedIndex]; -} - -function findIndex(startingIndex, direction, values) { - let indexToTest = startingIndex + direction; - - while (indexToTest !== startingIndex) { - if (indexToTest < 0) { - indexToTest = values.length - 1; - } else if (indexToTest >= values.length) { - indexToTest = 0; - } - - if (getSelectedOption(indexToTest, values).isDisabled) { - indexToTest = indexToTest + direction; - } else { - return indexToTest; - } - } -} - -function previousIndex(selectedIndex, values) { - return findIndex(selectedIndex, -1, values); -} - -function nextIndex(selectedIndex, values) { - return findIndex(selectedIndex, 1, values); -} - -function getSelectedIndex(props) { - const { - value, - values - } = props; - - if (Array.isArray(value)) { - return values.findIndex((v) => { - return value.size && v.key === value[0]; - }); - } - - return values.findIndex((v) => { - return v.key === value; - }); -} - -function isSelectedItem(index, props) { - const { - value, - values - } = props; - - if (Array.isArray(value)) { - return value.includes(values[index].key); - } - - return values[index].key === value; -} - -function getKey(selectedIndex, values) { - return values[selectedIndex].key; -} - -class EnhancedSelectInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scheduleUpdate = null; - this._buttonId = getUniqueElememtId(); - this._optionsId = getUniqueElememtId(); - - this.state = { - isOpen: false, - selectedIndex: getSelectedIndex(props), - width: 0, - isMobile: isMobileUtil() - }; - } - - componentDidUpdate(prevProps) { - if (this._scheduleUpdate) { - this._scheduleUpdate(); - } - - if (!Array.isArray(this.props.value)) { - if (prevProps.value !== this.props.value || prevProps.values !== this.props.values) { - this.setState({ - selectedIndex: getSelectedIndex(this.props) - }); - } - } - } - - // - // Control - - _addListener() { - window.addEventListener('click', this.onWindowClick); - } - - _removeListener() { - window.removeEventListener('click', this.onWindowClick); - } - - // - // Listeners - - onComputeMaxHeight = (data) => { - const { - top, - bottom - } = data.offsets.reference; - - const windowHeight = window.innerHeight; - - if ((/^botton/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom; - } else { - data.styles.maxHeight = top; - } - - return data; - }; - - onWindowClick = (event) => { - const button = document.getElementById(this._buttonId); - const options = document.getElementById(this._optionsId); - - if (!button || !event.target.isConnected || this.state.isMobile) { - return; - } - - if ( - !button.contains(event.target) && - options && - !options.contains(event.target) && - this.state.isOpen - ) { - this.setState({ isOpen: false }); - this._removeListener(); - } - }; - - onFocus = () => { - if (this.state.isOpen) { - this._removeListener(); - this.setState({ isOpen: false }); - } - }; - - onBlur = () => { - if (!this.props.isEditable) { - // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) - const origIndex = getSelectedIndex(this.props); - - if (origIndex !== this.state.selectedIndex) { - this.setState({ selectedIndex: origIndex }); - } - } - }; - - onKeyDown = (event) => { - const { - values - } = this.props; - - const { - isOpen, - selectedIndex - } = this.state; - - const keyCode = event.keyCode; - const newState = {}; - - if (!isOpen) { - if (isArrowKey(keyCode)) { - event.preventDefault(); - newState.isOpen = true; - } - - if ( - selectedIndex == null || selectedIndex === -1 || - getSelectedOption(selectedIndex, values).isDisabled - ) { - if (keyCode === keyCodes.UP_ARROW) { - newState.selectedIndex = previousIndex(0, values); - } else if (keyCode === keyCodes.DOWN_ARROW) { - newState.selectedIndex = nextIndex(values.length - 1, values); - } - } - - this.setState(newState); - return; - } - - if (keyCode === keyCodes.UP_ARROW) { - event.preventDefault(); - newState.selectedIndex = previousIndex(selectedIndex, values); - } - - if (keyCode === keyCodes.DOWN_ARROW) { - event.preventDefault(); - newState.selectedIndex = nextIndex(selectedIndex, values); - } - - if (keyCode === keyCodes.ENTER) { - event.preventDefault(); - newState.isOpen = false; - this.onSelect(getKey(selectedIndex, values)); - } - - if (keyCode === keyCodes.TAB) { - newState.isOpen = false; - this.onSelect(getKey(selectedIndex, values)); - } - - if (keyCode === keyCodes.ESCAPE) { - event.preventDefault(); - event.stopPropagation(); - newState.isOpen = false; - newState.selectedIndex = getSelectedIndex(this.props); - } - - if (!_.isEmpty(newState)) { - this.setState(newState); - } - }; - - onPress = () => { - if (this.state.isOpen) { - this._removeListener(); - } else { - this._addListener(); - } - - if (!this.state.isOpen && this.props.onOpen) { - this.props.onOpen(); - } - - this.setState({ isOpen: !this.state.isOpen }); - }; - - onSelect = (value) => { - if (Array.isArray(this.props.value)) { - let newValue = null; - const index = this.props.value.indexOf(value); - if (index === -1) { - newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v)); - } else { - newValue = [...this.props.value]; - newValue.splice(index, 1); - } - this.props.onChange({ - name: this.props.name, - value: newValue - }); - } else { - this.setState({ isOpen: false }); - - this.props.onChange({ - name: this.props.name, - value - }); - } - }; - - onMeasure = ({ width }) => { - this.setState({ width }); - }; - - onOptionsModalClose = () => { - this.setState({ isOpen: false }); - }; - - // - // Render - - render() { - const { - className, - disabledClassName, - name, - value, - values, - isDisabled, - isEditable, - isFetching, - hasError, - hasWarning, - valueOptions, - selectedValueOptions, - selectedValueComponent: SelectedValueComponent, - optionComponent: OptionComponent, - onChange - } = this.props; - - const { - selectedIndex, - width, - isOpen, - isMobile - } = this.state; - - const isMultiSelect = Array.isArray(value); - const selectedOption = getSelectedOption(selectedIndex, values); - let selectedValue = value; - - if (!values.length) { - selectedValue = isMultiSelect ? [] : ''; - } - - return ( -
- - - {({ ref }) => ( -
- - { - isEditable ? -
- - - { - isFetching ? - : - null - } - - { - isFetching ? - null : - - } - -
: - - - {selectedOption ? selectedOption.value : null} - - -
- - { - isFetching ? - : - null - } - - { - isFetching ? - null : - - } -
- - } -
-
- )} -
- - - {({ ref, style, scheduleUpdate }) => { - this._scheduleUpdate = scheduleUpdate; - - return ( -
- { - isOpen && !isMobile ? - - { - values.map((v, index) => { - const hasParent = v.parentKey !== undefined; - const depth = hasParent ? 1 : 0; - const parentSelected = hasParent && value.includes(v.parentKey); - return ( - - {v.value} - - ); - }) - } - : - null - } -
- ); - } - } -
-
-
- - { - isMobile ? - - - -
- - - -
- - { - values.map((v, index) => { - const hasParent = v.parentKey !== undefined; - const depth = hasParent ? 1 : 0; - const parentSelected = hasParent && value.includes(v.parentKey); - return ( - - {v.value} - - ); - }) - } -
-
-
: - null - } -
- ); - } -} - -EnhancedSelectInput.propTypes = { - className: PropTypes.string, - disabledClassName: PropTypes.string, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isEditable: PropTypes.bool.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - valueOptions: PropTypes.object.isRequired, - selectedValueOptions: PropTypes.object.isRequired, - selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, - optionComponent: PropTypes.elementType, - onOpen: PropTypes.func, - onChange: PropTypes.func.isRequired -}; - -EnhancedSelectInput.defaultProps = { - className: styles.enhancedSelect, - disabledClassName: styles.isDisabled, - isDisabled: false, - isFetching: false, - isEditable: false, - valueOptions: {}, - selectedValueOptions: {}, - selectedValueComponent: HintedSelectInputSelectedValue, - optionComponent: HintedSelectInputOption -}; - -export default EnhancedSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js deleted file mode 100644 index f2af4a585..000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputConnector.js +++ /dev/null @@ -1,159 +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 { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -const importantFieldNames = [ - 'baseUrl', - 'apiPath', - 'apiKey' -]; - -function getProviderDataKey(providerData) { - if (!providerData || !providerData.fields) { - return null; - } - - const fields = providerData.fields - .filter((f) => importantFieldNames.includes(f.name)) - .map((f) => f.value); - - return fields; -} - -function getSelectOptions(items) { - if (!items) { - return []; - } - - return items.map((option) => { - return { - key: option.value, - value: option.name, - hint: option.hint, - parentKey: option.parentValue - }; - }); -} - -function createMapStateToProps() { - return createSelector( - (state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState, - (options) => { - if (options) { - return { - isFetching: options.isFetching, - values: getSelectOptions(options.items) - }; - } - } - ); -} - -const mapDispatchToProps = { - dispatchFetchOptions: fetchOptions, - dispatchClearOptions: clearOptions -}; - -class EnhancedSelectInputConnector extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - refetchRequired: false - }; - } - - componentDidMount = () => { - this._populate(); - }; - - componentDidUpdate = (prevProps) => { - const prevKey = getProviderDataKey(prevProps.providerData); - const nextKey = getProviderDataKey(this.props.providerData); - - if (!_.isEqual(prevKey, nextKey)) { - this.setState({ refetchRequired: true }); - } - }; - - componentWillUnmount = () => { - this._cleanup(); - }; - - // - // Listeners - - onOpen = () => { - if (this.state.refetchRequired) { - this._populate(); - } - }; - - // - // Control - - _populate() { - const { - provider, - providerData, - selectOptionsProviderAction, - dispatchFetchOptions - } = this.props; - - if (selectOptionsProviderAction) { - this.setState({ refetchRequired: false }); - dispatchFetchOptions({ - section: selectOptionsProviderAction, - action: selectOptionsProviderAction, - provider, - providerData - }); - } - } - - _cleanup() { - const { - selectOptionsProviderAction, - dispatchClearOptions - } = this.props; - - if (selectOptionsProviderAction) { - dispatchClearOptions({ section: selectOptionsProviderAction }); - } - } - - // - // Render - - render() { - return ( - - ); - } -} - -EnhancedSelectInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - selectOptionsProviderAction: PropTypes.string, - onChange: PropTypes.func.isRequired, - isFetching: PropTypes.bool.isRequired, - dispatchFetchOptions: PropTypes.func.isRequired, - dispatchClearOptions: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js deleted file mode 100644 index b2783dbaa..000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.js +++ /dev/null @@ -1,113 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons } from 'Helpers/Props'; -import CheckInput from './CheckInput'; -import styles from './EnhancedSelectInputOption.css'; - -class EnhancedSelectInputOption extends Component { - - // - // Listeners - - onPress = (e) => { - e.preventDefault(); - - const { - id, - onSelect - } = this.props; - - onSelect(id); - }; - - onCheckPress = () => { - // CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation. - }; - - // - // Render - - render() { - const { - className, - id, - depth, - isSelected, - isDisabled, - isHidden, - isMultiSelect, - isMobile, - children - } = this.props; - - return ( - - - { - depth !== 0 && -
- } - - { - isMultiSelect && - - } - - {children} - - { - isMobile && -
- -
- } - - ); - } -} - -EnhancedSelectInputOption.propTypes = { - className: PropTypes.string.isRequired, - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - depth: PropTypes.number.isRequired, - isSelected: PropTypes.bool.isRequired, - isDisabled: PropTypes.bool.isRequired, - isHidden: PropTypes.bool.isRequired, - isMultiSelect: PropTypes.bool.isRequired, - isMobile: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, - onSelect: PropTypes.func.isRequired -}; - -EnhancedSelectInputOption.defaultProps = { - className: styles.option, - depth: 0, - isDisabled: false, - isHidden: false, - isMultiSelect: false -}; - -export default EnhancedSelectInputOption; diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js deleted file mode 100644 index 21ddebb02..000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js +++ /dev/null @@ -1,35 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './EnhancedSelectInputSelectedValue.css'; - -function EnhancedSelectInputSelectedValue(props) { - const { - className, - children, - isDisabled - } = props; - - return ( -
- {children} -
- ); -} - -EnhancedSelectInputSelectedValue.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node, - isDisabled: PropTypes.bool.isRequired -}; - -EnhancedSelectInputSelectedValue.defaultProps = { - className: styles.selectedValue, - isDisabled: false -}; - -export default EnhancedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js deleted file mode 100644 index 79ad3fe8a..000000000 --- a/frontend/src/Components/Form/Form.js +++ /dev/null @@ -1,66 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Alert from 'Components/Alert'; -import { kinds } from 'Helpers/Props'; -import styles from './Form.css'; - -function Form(props) { - const { - children, - validationErrors, - validationWarnings, - // eslint-disable-next-line no-unused-vars - ...otherProps - } = props; - - return ( -
- { - validationErrors.length || validationWarnings.length ? -
- { - validationErrors.map((error, index) => { - return ( - - {error.errorMessage} - - ); - }) - } - - { - validationWarnings.map((warning, index) => { - return ( - - {warning.errorMessage} - - ); - }) - } -
: - null - } - - {children} -
- ); -} - -Form.propTypes = { - children: PropTypes.node.isRequired, - validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired, - validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -Form.defaultProps = { - validationErrors: [], - validationWarnings: [] -}; - -export default Form; diff --git a/frontend/src/Components/Form/Form.tsx b/frontend/src/Components/Form/Form.tsx new file mode 100644 index 000000000..d522019e7 --- /dev/null +++ b/frontend/src/Components/Form/Form.tsx @@ -0,0 +1,45 @@ +import React, { ReactNode } from 'react'; +import Alert from 'Components/Alert'; +import { kinds } from 'Helpers/Props'; +import { ValidationError, ValidationWarning } from 'typings/pending'; +import styles from './Form.css'; + +export interface FormProps { + children: ReactNode; + validationErrors?: ValidationError[]; + validationWarnings?: ValidationWarning[]; +} + +function Form({ + children, + validationErrors = [], + validationWarnings = [], +}: FormProps) { + return ( +
+ {validationErrors.length || validationWarnings.length ? ( +
+ {validationErrors.map((error, index) => { + return ( + + {error.errorMessage} + + ); + })} + + {validationWarnings.map((warning, index) => { + return ( + + {warning.errorMessage} + + ); + })} +
+ ) : null} + + {children} +
+ ); +} + +export default Form; diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js deleted file mode 100644 index f538daa2f..000000000 --- a/frontend/src/Components/Form/FormGroup.js +++ /dev/null @@ -1,56 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { map } from 'Helpers/elementChildren'; -import { sizes } from 'Helpers/Props'; -import styles from './FormGroup.css'; - -function FormGroup(props) { - const { - className, - children, - size, - advancedSettings, - isAdvanced, - ...otherProps - } = props; - - if (!advancedSettings && isAdvanced) { - return null; - } - - const childProps = isAdvanced ? { isAdvanced } : {}; - - return ( -
- { - map(children, (child) => { - return React.cloneElement(child, childProps); - }) - } -
- ); -} - -FormGroup.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, - size: PropTypes.oneOf(sizes.all).isRequired, - advancedSettings: PropTypes.bool.isRequired, - isAdvanced: PropTypes.bool.isRequired -}; - -FormGroup.defaultProps = { - className: styles.group, - size: sizes.SMALL, - advancedSettings: false, - isAdvanced: false -}; - -export default FormGroup; diff --git a/frontend/src/Components/Form/FormGroup.tsx b/frontend/src/Components/Form/FormGroup.tsx new file mode 100644 index 000000000..1dd879897 --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames'; +import React, { Children, ComponentPropsWithoutRef, ReactNode } from 'react'; +import { Size } from 'Helpers/Props/sizes'; +import styles from './FormGroup.css'; + +interface FormGroupProps extends ComponentPropsWithoutRef<'div'> { + className?: string; + children: ReactNode; + size?: Extract; + advancedSettings?: boolean; + isAdvanced?: boolean; +} + +function FormGroup(props: FormGroupProps) { + const { + className = styles.group, + children, + size = 'small', + advancedSettings = false, + isAdvanced = false, + ...otherProps + } = props; + + if (!advancedSettings && isAdvanced) { + return null; + } + + const childProps = isAdvanced ? { isAdvanced } : {}; + + return ( +
+ {Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + return React.cloneElement(child, childProps); + })} +
+ ); +} + +export default FormGroup; diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js deleted file mode 100644 index a7145363a..000000000 --- a/frontend/src/Components/Form/FormInputButton.js +++ /dev/null @@ -1,54 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import { kinds } from 'Helpers/Props'; -import styles from './FormInputButton.css'; - -function FormInputButton(props) { - const { - className, - canSpin, - isLastButton, - ...otherProps - } = props; - - if (canSpin) { - return ( - - ); - } - - return ( -