diff --git a/.devcontainer/Sonarr.code-workspace b/.devcontainer/Sonarr.code-workspace deleted file mode 100644 index a46158e44..000000000 --- a/.devcontainer/Sonarr.code-workspace +++ /dev/null @@ -1,13 +0,0 @@ -// 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 deleted file mode 100644 index 629a2aa21..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,19 +0,0 @@ -// 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 bd62f4830..65e928bb0 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@v4 + uses: actions/setup-dotnet@v3 - 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" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" + run: dotnet test ./_tests/Sonarr.*.Test.dll --filter "${{ inputs.filter }}" --logger "trx;LogFileName=${{ env.RESULTS_NAME }}.trx" - name: Upload Test Results if: ${{ !cancelled() }} @@ -85,3 +85,12 @@ 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 deleted file mode 100644 index f33a02cd1..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -# 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 fdd66d11a..3b42128d4 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,23 +1,17 @@ 'connection': - - changed-files: - - any-glob-to-any-file: src/NzbDrone.Core/Notifications/**/* + - src/NzbDrone.Core/Notifications/**/* 'db-migration': - - changed-files: - - any-glob-to-any-file: src/NzbDrone.Core/Datastore/Migration/* + - src/NzbDrone.Core/Datastore/Migration/* 'download-client': - - changed-files: - - any-glob-to-any-file: src/NzbDrone.Core/Download/Clients/**/* + - src/NzbDrone.Core/Download/Clients/**/* 'indexer': - - changed-files: - - any-glob-to-any-file: src/NzbDrone.Core/Indexers/**/* + - src/NzbDrone.Core/Indexers/**/* 'parsing': - - changed-files: - - any-glob-to-any-file: src/NzbDrone.Core/Parser/**/* + - src/NzbDrone.Core/Parser/**/* 'ui-only': - - changed-files: - - any-glob-to-all-files: frontend/**/* + - all: ['frontend/**/*'] diff --git a/.github/workflows/api_docs.yml b/.github/workflows/api_docs.yml index dfd8ce0e2..1fc69c0fa 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@v4 + - uses: actions/checkout@v3 - name: Setup dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v3 id: setup-dotnet - name: Create openapi.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52f9d5678..f901eddc9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,14 +5,9 @@ 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 }} @@ -20,9 +15,9 @@ concurrency: env: FRAMEWORK: net6.0 - RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + BRANCH: ${{ github.head_ref || github.ref_name }} SONARR_MAJOR_VERSION: 4 - VERSION: 4.0.14 + VERSION: 4.0.0 jobs: backend: @@ -32,105 +27,103 @@ jobs: major_version: ${{ steps.variables.outputs.major_version }} version: ${{ steps.variables.outputs.version }} steps: - - name: Check out - uses: actions/checkout@v4 + - name: Check out + uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v3 - - 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 "BRANCH=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_ENV" + 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 "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT" - echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT" - echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT" + - name: Enable Extra Platforms In SDK + shell: bash + run: ./build.sh --enable-extra-platforms-in-sdk - - name: Enable Extra Platforms In SDK - shell: bash - run: ./build.sh --enable-extra-platforms-in-sdk + - name: Build Backend + shell: bash + run: ./build.sh --backend --enable-extra-platforms --packages - - name: Build Backend - shell: bash - run: ./build.sh --backend --enable-extra-platforms --packages + # Test Artifacts - # Test Artifacts + - name: Publish win-x64 Test Artifact + uses: ./.github/actions/publish-test-artifact + with: + framework: ${{ env.FRAMEWORK }} + runtime: win-x64 - - name: Publish win-x64 Test Artifact - uses: ./.github/actions/publish-test-artifact - with: - framework: ${{ env.FRAMEWORK }} - runtime: win-x64 + - name: Publish linux-x64 Test Artifact + uses: ./.github/actions/publish-test-artifact + with: + framework: ${{ env.FRAMEWORK }} + runtime: linux-x64 - - name: Publish linux-x64 Test Artifact - uses: ./.github/actions/publish-test-artifact - with: - framework: ${{ env.FRAMEWORK }} - runtime: linux-x64 + - name: Publish osx-x64 Test Artifact + uses: ./.github/actions/publish-test-artifact + with: + framework: ${{ env.FRAMEWORK }} + runtime: osx-x64 - - name: Publish osx-arm64 Test Artifact - uses: ./.github/actions/publish-test-artifact - with: - framework: ${{ env.FRAMEWORK }} - runtime: osx-arm64 - - # Build Artifacts (grouped by OS) - - - name: Publish FreeBSD Artifact - uses: actions/upload-artifact@v4 - with: - name: build_freebsd - path: _artifacts/freebsd-*/**/* - - name: Publish Linux Artifact - uses: actions/upload-artifact@v4 - with: - name: build_linux - path: _artifacts/linux-*/**/* - - name: Publish macOS Artifact - uses: actions/upload-artifact@v4 - with: - name: build_macos - path: _artifacts/osx-*/**/* - - name: Publish Windows Artifact - uses: actions/upload-artifact@v4 - with: - name: build_windows - path: _artifacts/win-*/**/* + # 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@v4 + - name: Check out + uses: actions/checkout@v3 - - name: Volta - uses: volta-cli/action@v4 + - name: Volta + uses: volta-cli/action@v4 - - name: Yarn Install - run: yarn install + - name: Yarn Intsall + run: yarn install - - name: Lint - run: yarn lint + - name: Lint + run: yarn lint - - name: Stylelint - run: yarn stylelint -f github + - name: Stylelint + run: yarn stylelint - - 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 @@ -143,44 +136,43 @@ jobs: artifact: tests-linux-x64 filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest - os: macos-latest - artifact: tests-osx-arm64 + artifact: tests-osx-x64 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@v4 + - name: Check out + uses: actions/checkout@v3 - - 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@v4 + - name: Check out + uses: actions/checkout@v3 - - 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: @@ -190,10 +182,10 @@ jobs: binary_artifact: build_linux binary_path: linux-x64/${{ needs.backend.outputs.framework }}/Sonarr - os: macos-latest - artifact: tests-osx-arm64 + artifact: tests-osx-x64 filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest binary_artifact: build_macos - binary_path: osx-arm64/${{ needs.backend.outputs.framework }}/Sonarr + binary_path: osx-x64/${{ needs.backend.outputs.framework }}/Sonarr - os: windows-latest artifact: tests-win-x64 filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest @@ -201,23 +193,23 @@ jobs: binary_path: win-x64/${{ needs.backend.outputs.framework }}/Sonarr runs-on: ${{ matrix.os }} steps: - - name: Check out - uses: actions/checkout@v4 + - name: Check out + uses: actions/checkout@v3 - - 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, frontend, unit_test, unit_test_postgres, integration_test] + needs: [backend, unit_test, unit_test_postgres, integration_test] secrets: inherit uses: ./.github/workflows/deploy.yml with: @@ -225,33 +217,3 @@ 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 deleted file mode 100644 index e9afb71a3..000000000 --- a/.github/workflows/conflict_labeler.yml +++ /dev/null @@ -1,26 +0,0 @@ -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 4fa5b54ee..af683e9d8 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@v4 + uses: actions/checkout@v3 - name: Package uses: ./.github/actions/package @@ -60,7 +60,7 @@ jobs: contents: write steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v3 - name: Download release artifacts uses: actions/download-artifact@v4 @@ -69,38 +69,12 @@ 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: false - body: ${{ steps.generate-release-notes.outputs.result }} + generateReleaseNotes: true name: ${{ inputs.version }} prerelease: ${{ inputs.branch != 'main' }} skipIfReleaseExists: true diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index df54c0fff..857cfb4a7 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -8,6 +8,5 @@ jobs: contents: read pull-requests: write runs-on: ubuntu-latest - if: github.repository == 'Sonarr/Sonarr' steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v4 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index d775234db..0435b1c71 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -8,15 +8,14 @@ on: jobs: lock: runs-on: ubuntu-latest - if: github.repository == 'Sonarr/Sonarr' steps: - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@v2 with: github-token: ${{ github.token }} - issue-inactive-days: '90' - exclude-issue-created-before: '' - exclude-any-issue-labels: 'one-day-maybe' - add-issue-labels: '' - issue-comment: '' + issue-lock-inactive-days: '90' + issue-exclude-created-before: '' + issue-exclude-labels: 'one-day-maybe' + issue-lock-labels: '' + issue-lock-comment: '' issue-lock-reason: 'resolved' process-only: '' diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml new file mode 100644 index 000000000..5e6012559 --- /dev/null +++ b/.github/workflows/publish-test-results.yml @@ -0,0 +1,41 @@ +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 deleted file mode 100644 index adf5a8c4a..000000000 --- a/.github/workflows/support-requests.yml +++ /dev/null @@ -1,29 +0,0 @@ -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 d17209556..73bd6ad62 100644 --- a/.gitignore +++ b/.gitignore @@ -127,7 +127,6 @@ coverage*.xml coverage*.json setup/Output/ *.~is -.mono #VS outout folders bin @@ -162,6 +161,3 @@ src/.idea/ # API doc generation .config/ - -# Ignore Jetbrains IntelliJ Workspace Directories -.idea/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 7a36fefe1..000000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "recommendations": [ - "esbenp.prettier-vscode", - "ms-dotnettools.csdevkit", - "ms-vscode-remote.remote-containers" - ] -} diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 6ea80f418..000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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 deleted file mode 100644 index 44aeb4060..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "typescript.tsdk": "node_modules\\typescript\\lib" -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index cfd41d42f..000000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "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 new file mode 100644 index 000000000..b879517cd --- /dev/null +++ b/Logo/Jetbrains/dottrace.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Logo/Jetbrains/jetbrains.svg b/Logo/Jetbrains/jetbrains.svg new file mode 100644 index 000000000..75d4d2177 --- /dev/null +++ b/Logo/Jetbrains/jetbrains.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Logo/Jetbrains/resharper.svg b/Logo/Jetbrains/resharper.svg new file mode 100644 index 000000000..24c987a78 --- /dev/null +++ b/Logo/Jetbrains/resharper.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Logo/Jetbrains/teamcity.svg b/Logo/Jetbrains/teamcity.svg new file mode 100644 index 000000000..ca14b3dc1 --- /dev/null +++ b/Logo/Jetbrains/teamcity.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index d43366f93..ef3c2ecea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Sonarr Sonarr +# Sonarr Sonarr -[![Translated](https://translate.servarr.com/widget/servarr/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/) +[![Translated](https://translate.servarr.com/widgets/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,17 +69,13 @@ This project would not be possible without the support of our users and software #### JetBrains -Thank you to [JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools +Thank you to [JetBrains JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools -[TeamCity](http://www.jetbrains.com/teamcity/) - -[ReSharper](http://www.jetbrains.com/resharper/) - -[dotTrace](http://www.jetbrains.com/dottrace/) - -[Rider](http://www.jetbrains.com/rider/) +* [TeamCity TeamCity](http://www.jetbrains.com/teamcity/) +* [ReSharper ReSharper](http://www.jetbrains.com/resharper/) +* [dotTrace dotTrace](http://www.jetbrains.com/dottrace/) ### Licenses -- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) -- Copyright 2010-2024 +- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) +- Copyright 2010-2023 diff --git a/distribution/debian/install.sh b/distribution/debian/install.sh index 803d7cf51..87a0b0914 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 the selected 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 that 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 doesn't exist +# -f to Force so we fail if it doesnt exist rm -f "${app^}".*.tar.gz echo "" echo "Downloading..." diff --git a/docs.sh b/docs.sh index 386f5df68..a0f21c41a 100755 --- a/docs.sh +++ b/docs.sh @@ -25,23 +25,17 @@ 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.6.2 Swashbuckle.AspNetCore.Cli +dotnet tool install --version 6.5.0 Swashbuckle.AspNetCore.Cli -dotnet tool run swagger tofile --output ./src/Sonarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v3 & +dotnet tool run swagger tofile --output ./src/Sonarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/Sonarr.dll" v3 & -sleep 45 +sleep 30 kill %1 diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index e14b9125d..cc26a2633 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -210,6 +210,7 @@ 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 @@ -358,20 +359,11 @@ module.exports = { ], rules: Object.assign(typescriptEslintRecommended.rules, { - '@typescript-eslint/no-unused-vars': [ - 'error', - { - args: 'after-used', - argsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - destructuredArrayIgnorePattern: '^_', - varsIgnorePattern: '^_', - ignoreRestSiblings: true - - } - ], - '@typescript-eslint/explicit-function-return-type': 'off', 'no-shadow': 'off', + // These should be enabled after cleaning things up + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + 'react/prop-types': 'off', 'prettier/prettier': 'error', 'simple-import-sort/imports': [ 'error', @@ -384,41 +376,7 @@ 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 8da95337f..edb88e0e7 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -9,7 +9,7 @@ "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll": "explicit" + "source.fixAll": true }, "typescript.preferences.quoteStyle": "single", diff --git a/frontend/babel.config.js b/frontend/babel.config.js index ade9f24a2..5c0d5ecdc 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -2,8 +2,6 @@ const loose = true; module.exports = { plugins: [ - '@babel/plugin-transform-logical-assignment-operators', - // Stage 1 '@babel/plugin-proposal-export-default-from', ['@babel/plugin-transform-optional-chaining', { loose }], diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index 0d0364950..e0ec27c27 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -26,7 +26,6 @@ module.exports = (env) => { const config = { mode: isProduction ? 'production' : 'development', devtool: isProduction ? 'source-map' : 'eval-source-map', - target: 'web', stats: { children: false @@ -52,7 +51,8 @@ module.exports = (env) => { 'node_modules' ], alias: { - jquery: 'jquery/dist/jquery.min' + jquery: 'jquery/dist/jquery.min', + 'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate' }, fallback: { buffer: false, @@ -67,7 +67,7 @@ module.exports = (env) => { output: { path: distFolder, publicPath: '/', - filename: isProduction ? '[name]-[contenthash].js' : '[name].js', + filename: '[name]-[contenthash].js', sourceMapFilename: '[file].map' }, @@ -92,7 +92,7 @@ module.exports = (env) => { new MiniCssExtractPlugin({ filename: 'Content/styles.css', - chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css' + chunkFilename: 'Content/[id]-[chunkhash].css' }), new HtmlWebpackPlugin({ @@ -134,12 +134,6 @@ 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') } ] } @@ -187,7 +181,7 @@ module.exports = (env) => { loose: true, debug: false, useBuiltIns: 'entry', - corejs: '3.39' + corejs: 3 } ] ] @@ -208,7 +202,7 @@ module.exports = (env) => { options: { importLoaders: 1, modules: { - localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]' + localIdentName: '[name]/[local]/[hash:base64:5]' } } }, diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 89db00f8c..f657adf28 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -16,7 +16,6 @@ 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 new file mode 100644 index 000000000..797aa5175 --- /dev/null +++ b/frontend/src/Activity/Blocklist/Blocklist.js @@ -0,0 +1,261 @@ +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 deleted file mode 100644 index 4163bc9ca..000000000 --- a/frontend/src/Activity/Blocklist/Blocklist.tsx +++ /dev/null @@ -1,329 +0,0 @@ -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 new file mode 100644 index 000000000..454fa13a9 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistConnector.js @@ -0,0 +1,152 @@ +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 new file mode 100644 index 000000000..5f8b98d3d --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js @@ -0,0 +1,90 @@ +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 deleted file mode 100644 index ec026ae92..000000000 --- a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index ea80458f1..000000000 --- a/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 new file mode 100644 index 000000000..b6bd2863c --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistRow.js @@ -0,0 +1,212 @@ +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 deleted file mode 100644 index c7410320d..000000000 --- a/frontend/src/Activity/Blocklist/BlocklistRow.tsx +++ /dev/null @@ -1,163 +0,0 @@ -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 new file mode 100644 index 000000000..f0b93cd25 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistRowConnector.js @@ -0,0 +1,26 @@ +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 new file mode 100644 index 000000000..862d8707e --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -0,0 +1,354 @@ +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 deleted file mode 100644 index f460ec433..000000000 --- a/frontend/src/Activity/History/Details/HistoryDetails.tsx +++ /dev/null @@ -1,321 +0,0 @@ -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 new file mode 100644 index 000000000..0848c7905 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js @@ -0,0 +1,19 @@ +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.tsx b/frontend/src/Activity/History/Details/HistoryDetailsModal.js similarity index 52% rename from frontend/src/Activity/History/Details/HistoryDetailsModal.tsx rename to frontend/src/Activity/History/Details/HistoryDetailsModal.js index 8134a9736..ddeea5b78 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -1,3 +1,4 @@ +import PropTypes from 'prop-types'; import React from 'react'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; @@ -7,12 +8,11 @@ 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: HistoryEventType) { +function getHeaderTitle(eventType) { switch (eventType) { case 'grabbed': return translate('Grabbed'); @@ -31,33 +31,29 @@ function getHeaderTitle(eventType: HistoryEventType) { } } -interface HistoryDetailsModalProps { - isOpen: boolean; - eventType: HistoryEventType; - sourceTitle: string; - data: HistoryData; - downloadId?: string; - isMarkingAsFailed: boolean; - onMarkAsFailedPress: () => void; - onModalClose: () => void; -} - -function HistoryDetailsModal(props: HistoryDetailsModalProps) { +function HistoryDetailsModal(props) { const { isOpen, eventType, sourceTitle, data, downloadId, - isMarkingAsFailed = false, + isMarkingAsFailed, + shortDateFormat, + timeFormat, 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 new file mode 100644 index 000000000..e5cc31ecd --- /dev/null +++ b/frontend/src/Activity/History/History.js @@ -0,0 +1,180 @@ +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 deleted file mode 100644 index 9f00a1ab3..000000000 --- a/frontend/src/Activity/History/History.tsx +++ /dev/null @@ -1,231 +0,0 @@ -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 new file mode 100644 index 000000000..b407960bd --- /dev/null +++ b/frontend/src/Activity/History/HistoryConnector.js @@ -0,0 +1,165 @@ +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.tsx b/frontend/src/Activity/History/HistoryEventTypeCell.js similarity index 60% rename from frontend/src/Activity/History/HistoryEventTypeCell.tsx rename to frontend/src/Activity/History/HistoryEventTypeCell.js index adedf08c0..2f5ef6ee1 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.tsx +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -1,17 +1,12 @@ +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: HistoryEventType, data: HistoryData) { +function getIconName(eventType, data) { switch (eventType) { case 'grabbed': return icons.DOWNLOADING; @@ -22,9 +17,7 @@ function getIconName(eventType: HistoryEventType, data: HistoryData) { case 'downloadFailed': return icons.DOWNLOADING; case 'episodeFileDeleted': - return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk' - ? icons.FILE_MISSING - : icons.DELETE; + return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE; case 'episodeFileRenamed': return icons.ORGANIZE; case 'downloadIgnored': @@ -34,7 +27,7 @@ function getIconName(eventType: HistoryEventType, data: HistoryData) { } } -function getIconKind(eventType: HistoryEventType) { +function getIconKind(eventType) { switch (eventType) { case 'downloadFailed': return kinds.DANGER; @@ -43,13 +36,10 @@ function getIconKind(eventType: HistoryEventType) { } } -function getTooltip(eventType: HistoryEventType, data: HistoryData) { +function getTooltip(eventType, data) { switch (eventType) { case 'grabbed': - return translate('EpisodeGrabbedTooltip', { - indexer: (data as GrabbedHistoryData).indexer, - downloadClient: (data as GrabbedHistoryData).downloadClient, - }); + return translate('EpisodeGrabbedTooltip', { indexer: data.indexer, downloadClient: data.downloadClient }); case 'seriesFolderImported': return translate('SeriesFolderImportedTooltip'); case 'downloadFolderImported': @@ -57,9 +47,7 @@ function getTooltip(eventType: HistoryEventType, data: HistoryData) { case 'downloadFailed': return translate('DownloadFailedEpisodeTooltip'); case 'episodeFileDeleted': - return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk' - ? translate('EpisodeFileMissingTooltip') - : translate('EpisodeFileDeletedTooltip'); + return data.reason === 'MissingFromDisk' ? translate('EpisodeFileMissingTooltip') : translate('EpisodeFileDeletedTooltip'); case 'episodeFileRenamed': return translate('EpisodeFileRenamedTooltip'); case 'downloadIgnored': @@ -69,21 +57,31 @@ function getTooltip(eventType: HistoryEventType, data: HistoryData) { } } -interface HistoryEventTypeCellProps { - eventType: HistoryEventType; - data: HistoryData; -} - -function HistoryEventTypeCell({ eventType, data }: HistoryEventTypeCellProps) { +function HistoryEventTypeCell({ eventType, data }) { 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 new file mode 100644 index 000000000..2b19e6970 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.js @@ -0,0 +1,312 @@ +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 deleted file mode 100644 index d1ba279dc..000000000 --- a/frontend/src/Activity/History/HistoryRow.tsx +++ /dev/null @@ -1,270 +0,0 @@ -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 new file mode 100644 index 000000000..b5d6223f6 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRowConnector.js @@ -0,0 +1,76 @@ +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 c94e383b1..110c7e01c 100644 --- a/frontend/src/Activity/Queue/ProtocolLabel.css +++ b/frontend/src/Activity/Queue/ProtocolLabel.css @@ -11,7 +11,3 @@ 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 ba0cb260d..f3b389e3d 100644 --- a/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts +++ b/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts @@ -2,7 +2,6 @@ // 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 new file mode 100644 index 000000000..e8a08943c --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.js @@ -0,0 +1,20 @@ +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 deleted file mode 100644 index c1824452a..000000000 --- a/frontend/src/Activity/Queue/ProtocolLabel.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 new file mode 100644 index 000000000..633357b7e --- /dev/null +++ b/frontend/src/Activity/Queue/Queue.js @@ -0,0 +1,364 @@ +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 deleted file mode 100644 index bd063e69a..000000000 --- a/frontend/src/Activity/Queue/Queue.tsx +++ /dev/null @@ -1,415 +0,0 @@ -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 new file mode 100644 index 000000000..178cb8e5f --- /dev/null +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -0,0 +1,203 @@ +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.tsx b/frontend/src/Activity/Queue/QueueDetails.js similarity index 60% rename from frontend/src/Activity/Queue/QueueDetails.tsx rename to frontend/src/Activity/Queue/QueueDetails.js index be70ceead..abc97b75c 100644 --- a/frontend/src/Activity/Queue/QueueDetails.tsx +++ b/frontend/src/Activity/Queue/QueueDetails.js @@ -1,49 +1,36 @@ +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'; -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) { +function QueueDetails(props) { const { title, size, sizeleft, status, - trackedDownloadState = 'downloading', - trackedDownloadStatus = 'ok', + trackedDownloadState, + trackedDownloadStatus, 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) { @@ -58,9 +45,11 @@ function QueueDetails(props: QueueDetailsProps) { return ( {title}} + body={ +
{title}
+ } position={tooltipPositions.LEFT} /> ); @@ -79,4 +68,22 @@ function QueueDetails(props: QueueDetailsProps) { ); } +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 new file mode 100644 index 000000000..573b3d9c2 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.js @@ -0,0 +1,78 @@ +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 deleted file mode 100644 index 17a6ac1fe..000000000 --- a/frontend/src/Activity/Queue/QueueOptions.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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 new file mode 100644 index 000000000..b2c99511c --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptionsConnector.js @@ -0,0 +1,19 @@ +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 459cdad8e..4a9ff08b9 100644 --- a/frontend/src/Activity/Queue/QueueRow.css +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -26,5 +26,4 @@ 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 new file mode 100644 index 000000000..95ff2527e --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -0,0 +1,478 @@ +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 deleted file mode 100644 index 25f5cb410..000000000 --- a/frontend/src/Activity/Queue/QueueRow.tsx +++ /dev/null @@ -1,411 +0,0 @@ -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 new file mode 100644 index 000000000..e1e469a70 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRowConnector.js @@ -0,0 +1,70 @@ +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.tsx b/frontend/src/Activity/Queue/QueueStatus.js similarity index 57% rename from frontend/src/Activity/Queue/QueueStatus.tsx rename to frontend/src/Activity/Queue/QueueStatus.js index 31a28f35c..c6e8cf5dd 100644 --- a/frontend/src/Activity/Queue/QueueStatus.tsx +++ b/frontend/src/Activity/Queue/QueueStatus.js @@ -1,59 +1,51 @@ +import PropTypes from 'prop-types'; import React from 'react'; -import Icon, { IconProps } from 'Components/Icon'; +import Icon from 'Components/Icon'; import Popover from 'Components/Tooltip/Popover'; -import { icons, kinds } from 'Helpers/Props'; -import { TooltipPosition } from 'Helpers/Props/tooltipPositions'; -import { - QueueTrackedDownloadState, - QueueTrackedDownloadStatus, - StatusMessage, -} from 'typings/Queue'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './QueueStatus.css'; -function getDetailedPopoverBody(statusMessages: StatusMessage[]) { +function getDetailedPopoverBody(statusMessages) { return (
- {statusMessages.map(({ title, messages }) => { - return ( -
- {title} -
    - {messages.map((message) => { - return
  • {message}
  • ; - })} -
-
- ); - })} + { + statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + { + messages.map((message) => { + return ( +
  • + {message} +
  • + ); + }) + } +
+
+ ); + }) + }
); } -interface QueueStatusProps { - sourceTitle: string; - status: string; - trackedDownloadStatus?: QueueTrackedDownloadStatus; - trackedDownloadState?: QueueTrackedDownloadState; - statusMessages?: StatusMessage[]; - errorMessage?: string; - position: TooltipPosition; - canFlip?: boolean; -} - -function QueueStatus(props: QueueStatusProps) { +function QueueStatus(props) { const { sourceTitle, status, - trackedDownloadStatus = 'ok', - trackedDownloadState = 'downloading', - statusMessages = [], + trackedDownloadStatus, + trackedDownloadState, + statusMessages, errorMessage, position, - canFlip = false, + canFlip } = props; const hasWarning = trackedDownloadStatus === 'warning'; @@ -61,7 +53,7 @@ function QueueStatus(props: QueueStatusProps) { // status === 'downloading' let iconName = icons.DOWNLOADING; - let iconKind: IconProps['kind'] = kinds.DEFAULT; + let iconKind = kinds.DEFAULT; let title = translate('Downloading'); if (status === 'paused') { @@ -78,11 +70,6 @@ function QueueStatus(props: QueueStatusProps) { iconName = icons.DOWNLOADED; title = translate('Downloaded'); - if (trackedDownloadState === 'importBlocked') { - title += ` - ${translate('UnableToImportAutomatically')}`; - iconKind = kinds.WARNING; - } - if (trackedDownloadState === 'importPending') { title += ` - ${translate('WaitingToImport')}`; iconKind = kinds.PURPLE; @@ -123,8 +110,7 @@ function QueueStatus(props: QueueStatusProps) { if (status === 'warning') { iconName = icons.DOWNLOADING; iconKind = kinds.WARNING; - const warningMessage = - errorMessage || translate('CheckDownloadClientForDetails'); + const warningMessage = errorMessage || translate('CheckDownloadClientForDetails'); title = translate('DownloadWarning', { warningMessage }); } @@ -142,17 +128,35 @@ function QueueStatus(props: QueueStatusProps) { return ( } - title={title} - body={ - hasWarning || hasError - ? getDetailedPopoverBody(statusMessages) - : sourceTitle + anchor={ + } + title={title} + 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 new file mode 100644 index 000000000..4e8b9658c --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.js @@ -0,0 +1,47 @@ +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 deleted file mode 100644 index 634e33164..000000000 --- a/frontend/src/Activity/Queue/QueueStatusCell.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js new file mode 100644 index 000000000..0cf7af855 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js @@ -0,0 +1,171 @@ +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 deleted file mode 100644 index 461fa57ad..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx +++ /dev/null @@ -1,231 +0,0 @@ -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/RemoveQueueItemModal.css b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css similarity index 100% rename from frontend/src/Activity/Queue/RemoveQueueItemModal.css rename to frontend/src/Activity/Queue/RemoveQueueItemsModal.css diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css.d.ts similarity index 100% rename from frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts rename to frontend/src/Activity/Queue/RemoveQueueItemsModal.css.d.ts diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js new file mode 100644 index 000000000..18ea39aea --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js @@ -0,0 +1,174 @@ +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 deleted file mode 100644 index 894434e07..000000000 --- a/frontend/src/Activity/Queue/Status/QueueStatus.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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 new file mode 100644 index 000000000..9412d7952 --- /dev/null +++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js @@ -0,0 +1,76 @@ +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 deleted file mode 100644 index 4fd37b28c..000000000 --- a/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts +++ /dev/null @@ -1,32 +0,0 @@ -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.tsx b/frontend/src/Activity/Queue/TimeleftCell.js similarity index 70% rename from frontend/src/Activity/Queue/TimeleftCell.tsx rename to frontend/src/Activity/Queue/TimeleftCell.js index 917a6ad0d..b280b5a06 100644 --- a/frontend/src/Activity/Queue/TimeleftCell.tsx +++ b/frontend/src/Activity/Queue/TimeleftCell.js @@ -1,3 +1,4 @@ +import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; @@ -10,18 +11,7 @@ import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; import styles from './TimeleftCell.css'; -interface TimeleftCellProps { - estimatedCompletionTime?: string; - timeleft?: string; - status: string; - size: number; - sizeleft: number; - showRelativeDates: boolean; - shortDateFormat: string; - timeFormat: string; -} - -function TimeleftCell(props: TimeleftCellProps) { +function TimeleftCell(props) { const { estimatedCompletionTime, timeleft, @@ -30,18 +20,12 @@ function TimeleftCell(props: TimeleftCellProps) { sizeleft, showRelativeDates, shortDateFormat, - timeFormat, + timeFormat } = props; if (status === 'delay') { - const date = getRelativeDate({ - date: estimatedCompletionTime, - shortDateFormat, - showRelativeDates, - }); - const time = formatTime(estimatedCompletionTime, timeFormat, { - includeMinuteZero: true, - }); + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); return ( @@ -56,14 +40,8 @@ function TimeleftCell(props: TimeleftCellProps) { } if (status === 'downloadClientUnavailable') { - const date = getRelativeDate({ - date: estimatedCompletionTime, - shortDateFormat, - showRelativeDates, - }); - const time = formatTime(estimatedCompletionTime, timeFormat, { - includeMinuteZero: true, - }); + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); return ( @@ -78,7 +56,11 @@ function TimeleftCell(props: TimeleftCellProps) { } if (!timeleft || status === 'completed' || status === 'failed') { - return -; + return ( + + - + + ); } const totalSize = formatBytes(size); @@ -94,4 +76,15 @@ function TimeleftCell(props: TimeleftCellProps) { ); } +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 18cbffddb..331d4849e 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js @@ -1,6 +1,5 @@ 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'; @@ -130,8 +129,7 @@ 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 dcf3f6de3..469385630 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css @@ -69,16 +69,6 @@ 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 b6fcfe361..1380d41f3 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts @@ -3,10 +3,7 @@ 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 8ce556456..815447ca8 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js @@ -6,7 +6,6 @@ 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'; @@ -56,8 +55,6 @@ class AddNewSeriesSearchResult extends Component { titleSlug, year, network, - originalLanguage, - genres, status, overview, statistics, @@ -148,49 +145,14 @@ class AddNewSeriesSearchResult extends Component { - { - originalLanguage?.name ? - : - null - } - { network ? : - null - } - - { - genres.length > 0 ? - : null } @@ -256,8 +218,6 @@ 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, @@ -269,8 +229,4 @@ 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 d0c6e98ae..415155274 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css @@ -1,10 +1,18 @@ .inputContainer { margin-right: 20px; min-width: 150px; + + div { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } } .label { - margin-bottom: 10px; + margin-bottom: 3px; font-weight: bold; } diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js new file mode 100644 index 000000000..781b2ca10 --- /dev/null +++ b/frontend/src/App/App.js @@ -0,0 +1,31 @@ +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 deleted file mode 100644 index b71199bb3..000000000 --- a/frontend/src/App/App.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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 new file mode 100644 index 000000000..c2c95f96d --- /dev/null +++ b/frontend/src/App/AppRoutes.js @@ -0,0 +1,280 @@ +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 deleted file mode 100644 index fbe4a15bb..000000000 --- a/frontend/src/App/AppRoutes.tsx +++ /dev/null @@ -1,167 +0,0 @@ -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 new file mode 100644 index 000000000..abc7f8832 --- /dev/null +++ b/frontend/src/App/AppUpdatedModal.js @@ -0,0 +1,30 @@ +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 deleted file mode 100644 index 696d36fb2..000000000 --- a/frontend/src/App/AppUpdatedModal.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 new file mode 100644 index 000000000..a21afbc5a --- /dev/null +++ b/frontend/src/App/AppUpdatedModalConnector.js @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..8cce1bc16 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.js @@ -0,0 +1,139 @@ +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 deleted file mode 100644 index 6553d6270..000000000 --- a/frontend/src/App/AppUpdatedModalContent.tsx +++ /dev/null @@ -1,145 +0,0 @@ -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 new file mode 100644 index 000000000..4100ee674 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContentConnector.js @@ -0,0 +1,78 @@ +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 new file mode 100644 index 000000000..ef177749f --- /dev/null +++ b/frontend/src/App/ApplyTheme.js @@ -0,0 +1,50 @@ +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 deleted file mode 100644 index ce598f2dc..000000000 --- a/frontend/src/App/ApplyTheme.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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.ts b/frontend/src/App/ColorImpairedContext.js similarity index 100% rename from frontend/src/App/ColorImpairedContext.ts rename to frontend/src/App/ColorImpairedContext.js diff --git a/frontend/src/App/ConnectionLostModal.tsx b/frontend/src/App/ConnectionLostModal.js similarity index 54% rename from frontend/src/App/ConnectionLostModal.tsx rename to frontend/src/App/ConnectionLostModal.js index f08f2c0e2..5c08f491f 100644 --- a/frontend/src/App/ConnectionLostModal.tsx +++ b/frontend/src/App/ConnectionLostModal.js @@ -1,4 +1,5 @@ -import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import React from 'react'; import Button from 'Components/Link/Button'; import Modal from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; @@ -9,31 +10,36 @@ import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './ConnectionLostModal.css'; -interface ConnectionLostModalProps { - isOpen: boolean; -} - -function ConnectionLostModal(props: ConnectionLostModalProps) { - const { isOpen } = props; - - const handleModalClose = useCallback(() => { - location.reload(); - }, []); +function ConnectionLostModal(props) { + const { + isOpen, + onModalClose + } = props; return ( - - - {translate('ConnectionLost')} + + + + {translate('ConnectionLost')} + -
{translate('ConnectionLostToBackend')}
+
+ {translate('ConnectionLostToBackend')} +
{translate('ConnectionLostReconnect')}
- @@ -42,4 +48,9 @@ function ConnectionLostModal(props: ConnectionLostModalProps) { ); } +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 new file mode 100644 index 000000000..8ab8e3cd0 --- /dev/null +++ b/frontend/src/App/ConnectionLostModalConnector.js @@ -0,0 +1,12 @@ +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 4e9dbe7a0..cabc39b1c 100644 --- a/frontend/src/App/State/AppSectionState.ts +++ b/frontend/src/App/State/AppSectionState.ts @@ -1,16 +1,10 @@ -import Column from 'Components/Table/Column'; -import { SortDirection } from 'Helpers/Props/sortDirections'; -import { ValidationFailure } from 'typings/pending'; -import { FilterBuilderProp, PropertyFilter } from './AppState'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { FilterBuilderProp } from './AppState'; export interface Error { - status?: number; - responseJSON: - | { - message: string | undefined; - } - | ValidationFailure[] - | undefined; + responseJSON: { + message: string; + }; } export interface AppSectionDeleteState { @@ -24,18 +18,10 @@ 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[]; } @@ -48,31 +34,13 @@ 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 84bd5d0b4..72aa0d7f0 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,23 +1,16 @@ -import BlocklistAppState from './BlocklistAppState'; +import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; 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; @@ -41,25 +34,19 @@ export interface PropertyFilter { export interface Filter { key: string; label: string; - filters: PropertyFilter[]; + filers: PropertyFilter[]; } export interface CustomFilter { id: number; type: string; label: string; - filters: PropertyFilter[]; + filers: PropertyFilter[]; } export interface AppSectionState { - isConnected: boolean; - isReconnecting: boolean; - isSidebarVisible: boolean; - version: string; - prevVersion?: string; dimensions: { isSmallScreen: boolean; - isLargeScreen: boolean; width: number; height: number; }; @@ -67,29 +54,20 @@ 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 deleted file mode 100644 index 004a30732..000000000 --- a/frontend/src/App/State/BlocklistAppState.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 75c8b5e50..de6a523b3 100644 --- a/frontend/src/App/State/CalendarAppState.ts +++ b/frontend/src/App/State/CalendarAppState.ts @@ -1,29 +1,10 @@ -import moment from 'moment'; import AppSectionState, { AppSectionFilterState, } from 'App/State/AppSectionState'; -import { CalendarView } from 'Calendar/calendarViews'; -import { CalendarItem } from 'typings/Calendar'; - -interface CalendarOptions { - showEpisodeInformation: boolean; - showFinaleIcon: boolean; - showSpecialIcon: boolean; - showCutoffUnmetIcon: boolean; - collapseMultipleEpisodes: boolean; - fullColorEvents: boolean; -} +import Episode from 'Episode/Episode'; interface CalendarAppState - extends AppSectionState, - AppSectionFilterState { - searchMissingCommandId: number | null; - start: moment.Moment; - end: moment.Moment; - dates: string[]; - time: string; - view: CalendarView; - options: CalendarOptions; -} + extends AppSectionState, + AppSectionFilterState {} export default CalendarAppState; diff --git a/frontend/src/App/State/CaptchaAppState.ts b/frontend/src/App/State/CaptchaAppState.ts deleted file mode 100644 index 7252937eb..000000000 --- a/frontend/src/App/State/CaptchaAppState.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 632b82179..e368ff86e 100644 --- a/frontend/src/App/State/HistoryAppState.ts +++ b/frontend/src/App/State/HistoryAppState.ts @@ -1,14 +1,10 @@ import AppSectionState, { AppSectionFilterState, - PagedAppSectionState, - TableAppSectionState, } from 'App/State/AppSectionState'; import History from 'typings/History'; interface HistoryAppState extends AppSectionState, - AppSectionFilterState, - PagedAppSectionState, - TableAppSectionState {} + AppSectionFilterState {} export default HistoryAppState; diff --git a/frontend/src/App/State/InteractiveImportAppState.ts b/frontend/src/App/State/InteractiveImportAppState.ts index 84fd9f4c1..cf86f620d 100644 --- a/frontend/src/App/State/InteractiveImportAppState.ts +++ b/frontend/src/App/State/InteractiveImportAppState.ts @@ -1,20 +1,11 @@ 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 deleted file mode 100644 index 495f109d8..000000000 --- a/frontend/src/App/State/MetadataAppState.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 619767929..000000000 --- a/frontend/src/App/State/OAuthAppState.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 068a48dc0..000000000 --- a/frontend/src/App/State/PathsAppState.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 7fb5df02b..000000000 --- a/frontend/src/App/State/ProviderOptionsAppState.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 954d649a2..05d74acac 100644 --- a/frontend/src/App/State/QueueAppState.ts +++ b/frontend/src/App/State/QueueAppState.ts @@ -3,29 +3,15 @@ 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, - PagedAppSectionState, - TableAppSectionState { + AppSectionFilterState { isGrabbing: boolean; grabError: Error; isRemoving: boolean; @@ -33,12 +19,9 @@ 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 deleted file mode 100644 index 350f6eac8..000000000 --- a/frontend/src/App/State/ReleasesAppState.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 5da5987dd..f9c216bdc 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/sortDirections'; +import SortDirection from 'Helpers/Props/SortDirection'; import Series from 'Series/Series'; import { Filter, FilterBuilderProp } from './AppState'; @@ -20,7 +20,6 @@ export interface SeriesIndexAppState { showTitle: boolean; showMonitored: boolean; showQualityProfile: boolean; - showTags: boolean; showSearchAction: boolean; }; @@ -35,7 +34,6 @@ export interface SeriesIndexAppState { showSeasonCount: boolean; showPath: boolean; showSizeOnDisk: boolean; - showTags: boolean; showSearchAction: boolean; }; @@ -59,8 +57,6 @@ 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 b8e6f4954..e249f2d20 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,44 +1,22 @@ import AppSectionState, { AppSectionDeleteState, - AppSectionItemSchemaState, AppSectionItemState, AppSectionSaveState, - PagedAppSectionState, + AppSectionSchemaState, } 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 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'; +import { UiSettings } from 'typings/UiSettings'; 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, @@ -47,9 +25,7 @@ export interface ImportListAppState export interface IndexerAppState extends AppSectionState, AppSectionDeleteState, - AppSectionSaveState { - isTestingAll: boolean; -} + AppSectionSaveState {} export interface NotificationAppState extends AppSectionState, @@ -57,52 +33,18 @@ export interface NotificationAppState export interface QualityProfilesAppState extends AppSectionState, - AppSectionItemSchemaState {} + AppSectionSchemaState {} -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 1161f0e1e..d43c1d0ee 100644 --- a/frontend/src/App/State/SystemAppState.ts +++ b/frontend/src/App/State/SystemAppState.ts @@ -1,22 +1,10 @@ -import DiskSpace from 'typings/DiskSpace'; -import Health from 'typings/Health'; import SystemStatus from 'typings/SystemStatus'; -import Task from 'typings/Task'; -import Update from 'typings/Update'; -import AppSectionState, { AppSectionItemState } from './AppSectionState'; +import { 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 deleted file mode 100644 index b543d3879..000000000 --- a/frontend/src/App/State/WantedAppState.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 new file mode 100644 index 000000000..89472301d --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.js @@ -0,0 +1,38 @@ +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 deleted file mode 100644 index fdef40466..000000000 --- a/frontend/src/Calendar/Agenda/Agenda.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 new file mode 100644 index 000000000..b6f238873 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaConnector.js @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..608528478 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.js @@ -0,0 +1,254 @@ +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 deleted file mode 100644 index 2fd2d7c54..000000000 --- a/frontend/src/Calendar/Agenda/AgendaEvent.tsx +++ /dev/null @@ -1,227 +0,0 @@ -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 new file mode 100644 index 000000000..d476acf80 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..0a2fd671d --- /dev/null +++ b/frontend/src/Calendar/Calendar.js @@ -0,0 +1,67 @@ +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 deleted file mode 100644 index caa337cf0..000000000 --- a/frontend/src/Calendar/Calendar.tsx +++ /dev/null @@ -1,170 +0,0 @@ -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 new file mode 100644 index 000000000..47c769126 --- /dev/null +++ b/frontend/src/Calendar/CalendarConnector.js @@ -0,0 +1,196 @@ +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 new file mode 100644 index 000000000..2e4d56b6b --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.js @@ -0,0 +1,197 @@ +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 deleted file mode 100644 index f408b6a60..000000000 --- a/frontend/src/Calendar/CalendarPage.tsx +++ /dev/null @@ -1,226 +0,0 @@ -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 new file mode 100644 index 000000000..b47142b64 --- /dev/null +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -0,0 +1,117 @@ +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 a619109ca..7538b0467 100644 --- a/frontend/src/Calendar/Day/CalendarDay.tsx +++ b/frontend/src/Calendar/Day/CalendarDay.tsx @@ -1,104 +1,25 @@ 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 CalendarEvent from 'Calendar/Events/CalendarEvent'; -import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup'; -import { - CalendarEvent as CalendarEventModel, - CalendarEventGroup as CalendarEventGroupModel, - CalendarItem, -} from 'typings/Calendar'; +import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; +import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector'; +import Series from 'Series/Series'; +import CalendarEventGroup, { CalendarEvent } from 'typings/CalendarEventGroup'; 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; - onEventModalOpenToggle(isOpen: boolean): unknown; + events: (CalendarEvent | CalendarEventGroup)[]; + view: string; + onEventModalOpenToggle(...args: unknown[]): unknown; } -function CalendarDay({ - date, - isTodaysDate, - onEventModalOpenToggle, -}: CalendarDayProps) { - const { time, view } = useSelector((state: AppState) => state.calendar); - const events = useSelector(createCalendarEventsConnector(date)); +function CalendarDay(props: CalendarDayProps) { + const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } = + props; const ref = React.useRef(null); @@ -132,7 +53,7 @@ function CalendarDay({ {events.map((event) => { if (event.isGroup) { return ( - diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js new file mode 100644 index 000000000..8fd6cc5a1 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDayConnector.js @@ -0,0 +1,91 @@ +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 new file mode 100644 index 000000000..f2bb4c8d4 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.js @@ -0,0 +1,164 @@ +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 deleted file mode 100644 index 149dc1455..000000000 --- a/frontend/src/Calendar/Day/CalendarDays.tsx +++ /dev/null @@ -1,135 +0,0 @@ -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 new file mode 100644 index 000000000..0acce70b9 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDaysConnector.js @@ -0,0 +1,25 @@ +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 new file mode 100644 index 000000000..39e40fce8 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.js @@ -0,0 +1,56 @@ +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 deleted file mode 100644 index c8b493b7c..000000000 --- a/frontend/src/Calendar/Day/DayOfWeek.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 new file mode 100644 index 000000000..9f94b1079 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.js @@ -0,0 +1,97 @@ +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 deleted file mode 100644 index 64bc886cc..000000000 --- a/frontend/src/Calendar/Day/DaysOfWeek.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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 new file mode 100644 index 000000000..7f5cdef19 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeekConnector.js @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..1f9d59b2b --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.js @@ -0,0 +1,267 @@ +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 deleted file mode 100644 index 079256a0e..000000000 --- a/frontend/src/Calendar/Events/CalendarEvent.tsx +++ /dev/null @@ -1,240 +0,0 @@ -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 new file mode 100644 index 000000000..e1ac2096d --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventConnector.js @@ -0,0 +1,29 @@ +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 990d994ec..c52e0192d 100644 --- a/frontend/src/Calendar/Events/CalendarEventGroup.css +++ b/frontend/src/Calendar/Events/CalendarEventGroup.css @@ -43,7 +43,6 @@ .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 new file mode 100644 index 000000000..2130c11f9 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.js @@ -0,0 +1,256 @@ +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 deleted file mode 100644 index 1ee981cfd..000000000 --- a/frontend/src/Calendar/Events/CalendarEventGroup.tsx +++ /dev/null @@ -1,253 +0,0 @@ -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 new file mode 100644 index 000000000..dbd967784 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js @@ -0,0 +1,37 @@ +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 new file mode 100644 index 000000000..db26eb1d2 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js @@ -0,0 +1,56 @@ +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 deleted file mode 100644 index 2372bc78e..000000000 --- a/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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 new file mode 100644 index 000000000..4555fc63b --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.js @@ -0,0 +1,268 @@ +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 deleted file mode 100644 index 2faaca25e..000000000 --- a/frontend/src/Calendar/Header/CalendarHeader.tsx +++ /dev/null @@ -1,221 +0,0 @@ -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 new file mode 100644 index 000000000..616e48650 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderConnector.js @@ -0,0 +1,85 @@ +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 new file mode 100644 index 000000000..98958af03 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js @@ -0,0 +1,45 @@ +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 deleted file mode 100644 index c9366f9ef..000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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.tsx b/frontend/src/Calendar/Legend/Legend.js similarity index 76% rename from frontend/src/Calendar/Legend/Legend.tsx rename to frontend/src/Calendar/Legend/Legend.js index b9887f856..f6e970e8b 100644 --- a/frontend/src/Calendar/Legend/Legend.tsx +++ b/frontend/src/Calendar/Legend/Legend.js @@ -1,22 +1,20 @@ +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() { - const view = useSelector((state: AppState) => state.calendar.view); +function Legend(props) { const { + view, showFinaleIcon, showSpecialIcon, showCutoffUnmetIcon, fullColorEvents, - } = useSelector((state: AppState) => state.calendar.options); - const { enableColorImpairedMode } = useSelector(createUISettingsSelector()); + colorImpairedMode + } = props; const iconsToShow = []; const isAgendaView = view === 'agenda'; @@ -58,7 +56,7 @@ function Legend() { if (showCutoffUnmetIcon) { iconsToShow.push(
@@ -94,7 +92,7 @@ function Legend() { tooltip={translate('CalendarLegendEpisodeOnAirTooltip')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={enableColorImpairedMode} + colorImpairedMode={colorImpairedMode} />
@@ -112,7 +110,7 @@ function Legend() { tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={enableColorImpairedMode} + colorImpairedMode={colorImpairedMode} /> @@ -136,15 +134,30 @@ function Legend() { {iconsToShow[0]} - {iconsToShow.length > 1 ? ( -
- {iconsToShow[1]} - {iconsToShow[2]} -
- ) : null} - {iconsToShow.length > 3 ?
{iconsToShow[3]}
: null} + { + iconsToShow.length > 1 && +
+ {iconsToShow[1]} + {iconsToShow[2]} +
+ } + { + iconsToShow.length > 3 && +
+ {iconsToShow[3]} +
+ } ); } +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 new file mode 100644 index 000000000..889b7a002 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendConnector.js @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..b6bdeeff7 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.js @@ -0,0 +1,43 @@ +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 deleted file mode 100644 index 88a758c44..000000000 --- a/frontend/src/Calendar/Legend/LegendIconItem.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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.tsx b/frontend/src/Calendar/Legend/LegendItem.js similarity index 61% rename from frontend/src/Calendar/Legend/LegendItem.tsx rename to frontend/src/Calendar/Legend/LegendItem.js index 40466ab9d..f0304b9e6 100644 --- a/frontend/src/Calendar/Legend/LegendItem.tsx +++ b/frontend/src/Calendar/Legend/LegendItem.js @@ -1,26 +1,17 @@ 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'; -interface LegendItemProps { - name?: string; - status: CalendarStatus; - tooltip: string; - isAgendaView: boolean; - fullColorEvents: boolean; - colorImpairedMode: boolean; -} - -function LegendItem(props: LegendItemProps) { +function LegendItem(props) { const { name, status, tooltip, isAgendaView, fullColorEvents, - colorImpairedMode, + colorImpairedMode } = props; return ( @@ -38,4 +29,13 @@ function LegendItem(props: LegendItemProps) { ); } +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 new file mode 100644 index 000000000..b68c83f30 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.js @@ -0,0 +1,29 @@ +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 deleted file mode 100644 index ae782a684..000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModal.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 new file mode 100644 index 000000000..c34401315 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js @@ -0,0 +1,276 @@ +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 deleted file mode 100644 index 4f974dda3..000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx +++ /dev/null @@ -1,228 +0,0 @@ -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 new file mode 100644 index 000000000..1f517b698 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js @@ -0,0 +1,25 @@ +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.ts b/frontend/src/Calendar/calendarViews.js similarity index 72% rename from frontend/src/Calendar/calendarViews.ts rename to frontend/src/Calendar/calendarViews.js index 4f5549dbd..929958b66 100644 --- a/frontend/src/Calendar/calendarViews.ts +++ b/frontend/src/Calendar/calendarViews.js @@ -5,5 +5,3 @@ 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.ts b/frontend/src/Calendar/getStatusStyle.js similarity index 67% rename from frontend/src/Calendar/getStatusStyle.ts rename to frontend/src/Calendar/getStatusStyle.js index 678e6c2a1..b149a8aab 100644 --- a/frontend/src/Calendar/getStatusStyle.ts +++ b/frontend/src/Calendar/getStatusStyle.js @@ -1,13 +1,7 @@ +/* eslint max-params: 0 */ import moment from 'moment'; -import { CalendarStatus } from 'typings/Calendar'; -function getStatusStyle( - hasFile: boolean, - downloading: boolean, - startTime: moment.Moment, - endTime: moment.Moment, - isMonitored: boolean -): CalendarStatus { +function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) { const currentTime = moment(); if (hasFile) { diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js new file mode 100644 index 000000000..8cc487c16 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModal.js @@ -0,0 +1,29 @@ +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 deleted file mode 100644 index f0eecbd4a..000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModal.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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 new file mode 100644 index 000000000..eb64cb207 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js @@ -0,0 +1,222 @@ +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 deleted file mode 100644 index aa90db301..000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx +++ /dev/null @@ -1,166 +0,0 @@ -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 new file mode 100644 index 000000000..e10c5c3f9 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js @@ -0,0 +1,17 @@ +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 cd875d56b..45a5beed7 100644 --- a/frontend/src/Commands/Command.ts +++ b/frontend/src/Commands/Command.ts @@ -1,16 +1,5 @@ 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; @@ -24,10 +13,6 @@ 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 { @@ -36,8 +21,8 @@ interface Command extends ModelBase { message: string; body: CommandBody; priority: string; - status: CommandStatus; - result: CommandResult; + status: string; + result: string; queued: string; started: string; ended: string; diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index 13ac9d62c..c2edf05bd 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_EPISODES_SCAN = 'DownloadedEpisodesScan'; +export const DOWNLOADED_EPSIODES_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 new file mode 100644 index 000000000..418cbf5e6 --- /dev/null +++ b/frontend/src/Components/Alert.js @@ -0,0 +1,34 @@ +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 deleted file mode 100644 index 92c89e741..000000000 --- a/frontend/src/Components/Alert.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 new file mode 100644 index 000000000..c5a4d164c --- /dev/null +++ b/frontend/src/Components/Card.js @@ -0,0 +1,60 @@ +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 deleted file mode 100644 index 24588c841..000000000 --- a/frontend/src/Components/Card.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 new file mode 100644 index 000000000..3af5665a9 --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.js @@ -0,0 +1,138 @@ +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 deleted file mode 100644 index b14f5fc6a..000000000 --- a/frontend/src/Components/CircularProgressBar.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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 new file mode 100644 index 000000000..be2c87c55 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.js @@ -0,0 +1,33 @@ +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 deleted file mode 100644 index 6deee77e5..000000000 --- a/frontend/src/Components/DescriptionList/DescriptionList.tsx +++ /dev/null @@ -1,15 +0,0 @@ -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 new file mode 100644 index 000000000..931557045 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js @@ -0,0 +1,46 @@ +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 deleted file mode 100644 index 13a7efdd0..000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItem.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 new file mode 100644 index 000000000..4ef3c015e --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js @@ -0,0 +1,27 @@ +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 deleted file mode 100644 index e08c117dc..000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 new file mode 100644 index 000000000..e1632c1cf --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js @@ -0,0 +1,27 @@ +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 deleted file mode 100644 index 59ea6955c..000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx +++ /dev/null @@ -1,15 +0,0 @@ -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 new file mode 100644 index 000000000..a111df70e --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.js @@ -0,0 +1,22 @@ +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 deleted file mode 100644 index 2e578504b..000000000 --- a/frontend/src/Components/DragPreviewLayer.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import styles from './DragPreviewLayer.css'; - -interface DragPreviewLayerProps { - className?: string; - children?: React.ReactNode; -} - -function DragPreviewLayer({ - className = styles.dragLayer, - children, - ...otherProps -}: DragPreviewLayerProps) { - return ( -
- {children} -
- ); -} - -export default DragPreviewLayer; diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js new file mode 100644 index 000000000..88412ad19 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundary.js @@ -0,0 +1,62 @@ +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 deleted file mode 100644 index 6b27f7a09..000000000 --- a/frontend/src/Components/Error/ErrorBoundary.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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 870b28058..14bd8a87f 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 new file mode 100644 index 000000000..8243fd00c --- /dev/null +++ b/frontend/src/Components/FieldSet.js @@ -0,0 +1,41 @@ +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 deleted file mode 100644 index c2ff03a7f..000000000 --- a/frontend/src/Components/FieldSet.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 new file mode 100644 index 000000000..6b58dbb8c --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.js @@ -0,0 +1,39 @@ +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 deleted file mode 100644 index 0925890de..000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModal.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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 new file mode 100644 index 000000000..f517b4d1b --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js @@ -0,0 +1,246 @@ +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 deleted file mode 100644 index 41338cb39..000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx +++ /dev/null @@ -1,237 +0,0 @@ -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 new file mode 100644 index 000000000..1a3a41ef0 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js @@ -0,0 +1,119 @@ +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 new file mode 100644 index 000000000..06bb3029d --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.js @@ -0,0 +1,62 @@ +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 deleted file mode 100644 index fe47f1664..000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserRow.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 5da830bd5..000000000 --- a/frontend/src/Components/FileBrowser/createPathsSelector.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 0c4a31657..d718aab0c 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -1,4 +1,3 @@ -import { maxBy } from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -9,7 +8,6 @@ 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'; @@ -51,7 +49,7 @@ class FilterBuilderModalContent extends Component { if (id) { dispatchSetFilter({ selectedFilterKey: id }); } else { - const last = maxBy(customFilters, 'id'); + const last = customFilters[customFilters.length -1]; dispatchSetFilter({ selectedFilterKey: last.id }); } @@ -109,7 +107,7 @@ class FilterBuilderModalContent extends Component { this.setState({ labelErrors: [ { - message: translate('LabelIsRequired') + message: 'Label is required' } ] }); @@ -147,13 +145,13 @@ class FilterBuilderModalContent extends Component { return ( - {translate('CustomFilter')} + Custom Filter
- {translate('Label')} + Label
@@ -167,9 +165,7 @@ class FilterBuilderModalContent extends Component {
-
- {translate('Filters')} -
+
Filters
{ @@ -196,7 +192,7 @@ class FilterBuilderModalContent extends Component { - {translate('Save')} + Save diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index 0b00c0f03..01c24b460 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -3,7 +3,6 @@ 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'; @@ -12,9 +11,7 @@ import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValu import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; -import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; -import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue'; -import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue'; +import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue'; import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue'; import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue'; @@ -79,13 +76,7 @@ function getRowValueConnector(selectedFilterBuilderProp) { return QualityFilterBuilderRowValueConnector; case filterBuilderValueTypes.QUALITY_PROFILE: - return QualityProfileFilterBuilderRowValue; - - case filterBuilderValueTypes.QUEUE_STATUS: - return QueueStatusFilterBuilderRowValue; - - case filterBuilderValueTypes.SEASONS_MONITORED_STATUS: - return SeasonsMonitoredStatusFilterBuilderRowValue; + return QualityProfileFilterBuilderRowValueConnector; case filterBuilderValueTypes.SERIES: return SeriesFilterBuilderRowValue; @@ -233,7 +224,7 @@ class FilterBuilderRow extends Component { key: name, value: typeof label === 'function' ? label() : label }; - }).sort(sortByProp('value')); + }).sort((a, b) => a.value.localeCompare(b.value)); const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js index 217626c90..68fa5c557 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/Tag/TagInput'; +import TagInput from 'Components/Form/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 d1419327a..a7aed80b6 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 sortByProp from 'Utilities/Array/sortByProp'; +import sortByName from 'Utilities/Array/sortByName'; import FilterBuilderRowValue from './FilterBuilderRowValue'; function createTagListSelector() { @@ -38,7 +38,7 @@ function createTagListSelector() { } return acc; - }, []).sort(sortByProp('name')); + }, []).sort(sortByName); } return _.uniqBy(items, 'id'); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js index 063a97346..7b6d6313a 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/Tag/TagInputTag'; +import TagInputTag from 'Components/Form/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 deleted file mode 100644 index 50036cb90..000000000 --- a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 new file mode 100644 index 000000000..4a8b82283 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js @@ -0,0 +1,28 @@ +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 deleted file mode 100644 index 1127493a5..000000000 --- a/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index b84260e3c..000000000 --- a/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js +++ /dev/null @@ -1,35 +0,0 @@ -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 88b34509a..2eae79c80 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 sortByProp from 'Utilities/Array/sortByProp'; +import sortByName from 'Utilities/Array/sortByName'; 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(sortByProp('name')); + .sort(sortByName); return ; } diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js index e017f72e7..3464300f1 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 statusTagList = [ +const seriesStatusList = [ { id: 'continuing', get name() { @@ -32,7 +32,7 @@ const statusTagList = [ function SeriesStatusFilterBuilderRowValue(props) { return ( ); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js index 9f378d5a2..7407f729a 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 deletion was successful. - // Moving this check to an ancestor would be more accurate, but would have + // Assume that delete and then unmounting means the delete was successful. + // Moving this check to a 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 99cb6ec5c..28eb91599 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -5,7 +5,6 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; -import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import CustomFilter from './CustomFilter'; import styles from './CustomFiltersModalContent.css'; @@ -32,7 +31,7 @@ function CustomFiltersModalContent(props) { { customFilters - .sort((a, b) => sortByProp(a, b, 'label')) + .sort((a, b) => a.label.localeCompare(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 deleted file mode 100644 index 7ba114125..000000000 --- a/frontend/src/Components/Form/AutoCompleteInput.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 new file mode 100644 index 000000000..34ec7530b --- /dev/null +++ b/frontend/src/Components/Form/AutoSuggestInput.js @@ -0,0 +1,257 @@ +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 deleted file mode 100644 index b3a7c31b0..000000000 --- a/frontend/src/Components/Form/AutoSuggestInput.tsx +++ /dev/null @@ -1,259 +0,0 @@ -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 new file mode 100644 index 000000000..b422198b5 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.js @@ -0,0 +1,84 @@ +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 deleted file mode 100644 index d5a3f11f7..000000000 --- a/frontend/src/Components/Form/CaptchaInput.tsx +++ /dev/null @@ -1,118 +0,0 @@ -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 new file mode 100644 index 000000000..ad83bf02f --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInputConnector.js @@ -0,0 +1,98 @@ +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 new file mode 100644 index 000000000..26d915880 --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.js @@ -0,0 +1,191 @@ +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 deleted file mode 100644 index b7080cfdd..000000000 --- a/frontend/src/Components/Form/CheckInput.tsx +++ /dev/null @@ -1,141 +0,0 @@ -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/Tag/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css similarity index 64% rename from frontend/src/Components/Form/Tag/DeviceInput.css rename to frontend/src/Components/Form/DeviceInput.css index 189cafc6b..7abe83db5 100644 --- a/frontend/src/Components/Form/Tag/DeviceInput.css +++ b/frontend/src/Components/Form/DeviceInput.css @@ -3,6 +3,6 @@ } .input { - composes: input from '~Components/Form/Tag/TagInput.css'; + composes: input from '~./TagInput.css'; composes: hasButton from '~Components/Form/Input.css'; } diff --git a/frontend/src/Components/Form/Tag/DeviceInput.css.d.ts b/frontend/src/Components/Form/DeviceInput.css.d.ts similarity index 100% rename from frontend/src/Components/Form/Tag/DeviceInput.css.d.ts rename to frontend/src/Components/Form/DeviceInput.css.d.ts diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js new file mode 100644 index 000000000..55c239cb8 --- /dev/null +++ b/frontend/src/Components/Form/DeviceInput.js @@ -0,0 +1,106 @@ +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 new file mode 100644 index 000000000..2af9a79f6 --- /dev/null +++ b/frontend/src/Components/Form/DeviceInputConnector.js @@ -0,0 +1,104 @@ +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 new file mode 100644 index 000000000..f0ebf534b --- /dev/null +++ b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js @@ -0,0 +1,101 @@ +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/Select/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css similarity index 92% rename from frontend/src/Components/Form/Select/EnhancedSelectInput.css rename to frontend/src/Components/Form/EnhancedSelectInput.css index 735d63573..56f5564b9 100644 --- a/frontend/src/Components/Form/Select/EnhancedSelectInput.css +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -19,7 +19,7 @@ .isDisabled { opacity: 0.7; - cursor: not-allowed !important; + cursor: not-allowed; } .dropdownArrowContainer { @@ -73,12 +73,6 @@ padding: 10px 0; } -.optionsInnerModalBody { - composes: innerModalBody from '~Components/Modal/ModalBody.css'; - - padding: 0; -} - .optionsModalScroller { composes: scroller from '~Components/Scroller/Scroller.css'; diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts b/frontend/src/Components/Form/EnhancedSelectInput.css.d.ts similarity index 94% rename from frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts rename to frontend/src/Components/Form/EnhancedSelectInput.css.d.ts index 98167a9b5..edcf0079b 100644 --- a/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts +++ b/frontend/src/Components/Form/EnhancedSelectInput.css.d.ts @@ -14,7 +14,6 @@ interface CssExports { 'mobileCloseButtonContainer': string; 'options': string; 'optionsContainer': string; - 'optionsInnerModalBody': string; 'optionsModal': string; 'optionsModalBody': string; 'optionsModalScroller': string; diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js new file mode 100644 index 000000000..cc4215025 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -0,0 +1,608 @@ +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 new file mode 100644 index 000000000..f2af4a585 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputConnector.js @@ -0,0 +1,159 @@ +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/Select/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css similarity index 87% rename from frontend/src/Components/Form/Select/EnhancedSelectInputOption.css rename to frontend/src/Components/Form/EnhancedSelectInputOption.css index bfdaa9036..d7f0e861b 100644 --- a/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css @@ -16,13 +16,13 @@ } .optionCheck { - composes: container from '~Components/Form/CheckInput.css'; + composes: container from '~./CheckInput.css'; flex: 0 0 0; } .optionCheckInput { - composes: input from '~Components/Form/CheckInput.css'; + composes: input from '~./CheckInput.css'; margin-top: 0; } diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts b/frontend/src/Components/Form/EnhancedSelectInputOption.css.d.ts similarity index 100% rename from frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts rename to frontend/src/Components/Form/EnhancedSelectInputOption.css.d.ts diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js new file mode 100644 index 000000000..b2783dbaa --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js @@ -0,0 +1,113 @@ +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/Select/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css similarity index 100% rename from frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css rename to frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css.d.ts similarity index 100% rename from frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts rename to frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css.d.ts diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js new file mode 100644 index 000000000..21ddebb02 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js @@ -0,0 +1,35 @@ +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 new file mode 100644 index 000000000..79ad3fe8a --- /dev/null +++ b/frontend/src/Components/Form/Form.js @@ -0,0 +1,66 @@ +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 deleted file mode 100644 index d522019e7..000000000 --- a/frontend/src/Components/Form/Form.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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 new file mode 100644 index 000000000..f538daa2f --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.js @@ -0,0 +1,56 @@ +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 deleted file mode 100644 index 1dd879897..000000000 --- a/frontend/src/Components/Form/FormGroup.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 new file mode 100644 index 000000000..a7145363a --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.js @@ -0,0 +1,54 @@ +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 ( +