diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d16b780d70..6691a132d3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,11 +2,11 @@ // README at: https://github.com/devcontainers/templates/tree/main/src/dotnet { "name": "Radarr", - "image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0", "features": { "ghcr.io/devcontainers/features/node:1": { "nodeGypDependencies": true, - "version": "16", + "version": "20", "nvmVersion": "latest" } }, diff --git a/.gitignore b/.gitignore index e633126a6c..95a3b62a66 100644 --- a/.gitignore +++ b/.gitignore @@ -165,15 +165,12 @@ Thumbs.db /tools/Addins/* packages.config.md5sum - -# Common IntelliJ Platform excludes - -# Ignore Rider projects completely for now -.idea/ - # ignore node_modules symlink node_modules node_modules.nosync # API doc generation .config/ + +# Ignore Jetbrains IntelliJ Workspace Directories +.idea/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 92e82d325a..832711c354 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "preLaunchTask": "build dotnet", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/_output/net6.0/Radarr", + "program": "${workspaceFolder}/_output/net8.0/Radarr", "args": [], "cwd": "${workspaceFolder}", // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7c86c3f06..64626a0194 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,186 @@ + # How to Contribute We're always looking for people to help make Radarr even better, there are a number of ways to contribute. -This file has been moved to the wiki for the latest details please see the [contributing wiki page](https://wiki.servarr.com/radarr/contributing). +# Documentation -## Documentation +Setup guides, [FAQ](/radarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/radarr) the better. -Setup guides, [FAQ](https://wiki.servarr.com/radarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/radarr) the better. +# Development -## Development +Radarr is written in C# (backend) and JS (frontend). The backend is built on the .NET6 (and _soon_ .NET8) framework, while the frontend utilizes Reactjs. -See the [Wiki Page](https://wiki.servarr.com/radarr/contributing) +## Tools required + +- Visual Studio 2022 or higher is recommended (). The community version is free and works (). + +> VS 2022 V17.0 or higher is recommended as it includes the .NET6 SDK +{.is-info} + +- HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc) +- [Git](https://git-scm.com/downloads) +- The [Node.js](https://nodejs.org/) runtime is required. The following versions are supported: + - **20** (any minor or patch version within this) +{.grid-list} + +> The Application will **NOT** run on older versions such as `18.x`, `16.x` or any version below 20.0! Due to a dependency issue, it will also not run on `21.x` and is untested on other verisons. +{.is-warning} + +- [Yarn](https://yarnpkg.com/getting-started/install) is required to build the frontend + - Yarn is included with **Node 20**+ by default. Enable it with `corepack enable` + - For other Node versions, install it with `npm i -g corepack` + +## Getting started + +1. Fork Radarr +1. Clone the repository into your development machine. [*info*](https://docs.github.com/en/get-started/quickstart/fork-a-repo) + +> Be sure to run lint `yarn lint --fix` on your code for any front end changes before committing. +For css changes `yarn stylelint-windows --fix` {.is-info} + +### Building the frontend + +- Navigate to the cloned directory +- Install the required Node Packages + + ```bash + yarn install + ``` + +- Start webpack to monitor your development environment for any changes that need post processing using: + + ```bash + yarn start + ``` + +### Building the Backend + +The backend solution is most easily built and ran in Visual Studio or Rider, however if the only priority is working on the frontend UI it can be built easily from command line as well when the correct SDK is installed. + +#### Visual Studio + +> Ensure startup project is set to `Radarr.Console` and framework to `net6.0` +{.is-info} + +1. First `Build` the solution in Visual Studio, this will ensure all projects are correctly built and dependencies restored +1. Next `Debug/Run` the project in Visual Studio to start Radarr +1. Open + +#### Command line + +1. Clean solution + +```shell +dotnet clean src/Radarr.sln -c Debug +``` + +1. Restore and Build debug configuration for the correct platform (Posix or Windows) + +```shell +dotnet msbuild -restore src/Radarr.sln -p:Configuration=Debug -p:Platform=Posix -t:PublishAllRids +``` + +1. Run the produced executable from `/_output` + +## Contributing Code + +- If you're adding a new, already requested feature, please comment on [GitHub Issues](https://github.com/Radarr/Radarr/issues) so work is not duplicated (If you want to add something not already on there, please talk to us first) +- Rebase from Radarr's develop branch, do not merge +- Make meaningful commits, or squash them +- Feel free to make a pull request before work is complete, this will let us see where its at and make comments/suggest improvements +- Reach out to us on the discord if you have any questions +- Add tests (unit/integration) +- Commit with \*nix line endings for consistency (We checkout Windows and commit \*nix) +- One feature/bug fix per pull request to keep things clean and easy to understand +- Use 4 spaces instead of tabs, this is the default for VS 2022 and WebStorm + +## Pull Requesting + +- Only make pull requests to `develop`, never `master`, if you make a PR to `master` we will comment on it and close it +- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability +- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it +- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed) + - `new-feature` (Good) + - `fix-bug` (Good) + - `patch` (Bad) + - `develop` (Bad) +- Commits should be wrote as `New:` or `Fixed:` for changes that would not be considered a `maintenance release` + +## Unit Testing + +Radarr utilizes nunit for its unit, integration, and automation test suite. + +### Running Tests + +Tests can be run easily from within VS using the included nunit3testadapter nuget package or from the command line using the included bash script `test.sh`. + +From VS simply navigate to Test Explorer and run or debug the tests you'd like to examine. + +Tests can be run all at once or one at a time in VS. + +From command line the `test.sh` script accepts 3 parameters + +```bash +test.sh +``` + +### Writing Tests + +While not always fun, we encourage writing unit tests for any backend code changes. This will ensure the change is functioning as you intended and that future changes dont break the expected behavior. + +> We currently require 80% coverage on new code when submitting a PR +{.is-info} + +If you have any questions about any of this, please let us know. + +# Translation + +Radarr uses a self hosted open access [Weblate](https://translate.servarr.com) instance to manage its json translation files. These files are stored in the repo at `src/NzbDrone.Core/Localization` + +## Contributing to an Existing Translation + +Weblate handles synchronization and translation of strings for all languages other than English. Editing of translated strings and translating existing strings for supported languages should be performed there for the Radarr project. + +The English translation, `en.json`, serves as the source for all other translations and is managed on GitHub repo. + +## Adding a Language + +Adding translations to Radarr requires two steps + +- Adding the Language to weblate +- Adding the Language to Radarr codebase + +## Adding Translation Strings in Code + +The English translation, `src/NzbDrone.Core/Localization/en.json`, serves as the source for all other translations and is managed on GitHub repo. When adding a new string to either the UI or backend a key must also be added to `en.json` along with the default value in English. This key may then be consumed as follows: + +> PRs for translation of log messages will not be accepted +{.is-warning} + +### Backend Strings + +Backend strings may be added utilizing the Localization Service `GetLocalizedString` method + +```dotnet +private readonly ILocalizationService _localizationService; + +public IndexerCheck(ILocalizationService localizationService) +{ + _localizationService = localizationService; +} + +var translated = _localizationService.GetLocalizedString("IndexerHealthCheckNoIndexers") +``` + +### Frontend Strings + +New strings can be added to the frontend by importing the translate function and using a key specified from `en.json` + +```js +import translate from 'Utilities/String/translate'; + +
+ {translate('UnableToAddANewIndexerPleaseTryAgain')} +
+``` diff --git a/Logo/dottrace.svg b/Logo/dottrace.svg deleted file mode 100644 index b879517cd0..0000000000 --- a/Logo/dottrace.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/jetbrains.svg b/Logo/jetbrains.svg deleted file mode 100644 index 75d4d21771..0000000000 --- a/Logo/jetbrains.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/resharper.svg b/Logo/resharper.svg deleted file mode 100644 index 24c987a780..0000000000 --- a/Logo/resharper.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/rider.svg b/Logo/rider.svg deleted file mode 100644 index 82da35b0b2..0000000000 --- a/Logo/rider.svg +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - rider - - - - - - - - - - - - - - diff --git a/Logo/webstorm.svg b/Logo/webstorm.svg deleted file mode 100644 index 39ab7eb973..0000000000 --- a/Logo/webstorm.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md index 056625b3fd..1131ce4e50 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Note that only one type of a given movie is supported. If you want both a 4k ver * Adding new movies with lots of information, such as trailers, ratings, etc. * Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc. -* Can watch for better quality of the movies you have and do an automatic upgrade. *e.g. from DVD to Blu-Ray* +* Can watch for better quality of the movies you 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 * Full integration with SABnzbd and NZBGet @@ -68,12 +68,12 @@ Support this project by becoming a sponsor. Your logo will show up here with a l ## JetBrains -Thank you to [JetBrains JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. +Thank you to [JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. -* [ReSharper ReSharper](http://www.jetbrains.com/resharper/) -* [WebStorm WebStorm](http://www.jetbrains.com/webstorm/) -* [Rider Rider](http://www.jetbrains.com/rider/) -* [dotTrace dotTrace](http://www.jetbrains.com/dottrace/) +* [ReSharper ReSharper](http://www.jetbrains.com/resharper/) +* [WebStorm WebStorm](http://www.jetbrains.com/webstorm/) +* [Rider Rider](http://www.jetbrains.com/rider/) +* [dotTrace dotTrace](http://www.jetbrains.com/dottrace/) ## DigitalOcean @@ -87,4 +87,4 @@ This project is also supported by DigitalOcean ### License * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) -* Copyright 2010-2022 +* Copyright 2010-2025 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 60bc575251..5bb9f29743 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,18 +9,18 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '5.12.2' + majorVersion: '6.1.1' minorVersion: $[counter('minorVersion', 2000)] radarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(radarrVersion)' sentryOrg: 'servarr' sentryUrl: 'https://sentry.servarr.com' - dotnetVersion: '6.0.424' + dotnetVersion: '8.0.405' nodeVersion: '20.X' innoVersion: '6.2.2' - windowsImage: 'windows-2022' - linuxImage: 'ubuntu-20.04' - macImage: 'macOS-13' + windowsImage: 'windows-2025' + linuxImage: 'ubuntu-24.04' + macImage: 'macOS-15' trigger: branches: @@ -106,7 +106,7 @@ stages: echo "Extra platforms already enabled" else echo "Enabling extra platform support" - sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS + sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS" fi displayName: Enable Extra Platform Support - bash: ./build.sh --backend --enable-extra-platforms @@ -122,27 +122,23 @@ stages: artifact: '$(osName)Backend' displayName: Publish Backend condition: and(succeeded(), eq(variables['osName'], 'Windows')) - - publish: '$(testsFolder)/net6.0/win-x64/publish' + - publish: '$(testsFolder)/net8.0/win-x64/publish' artifact: win-x64-tests displayName: Publish win-x64 Test Package condition: and(succeeded(), eq(variables['osName'], 'Windows')) - - publish: '$(testsFolder)/net6.0/linux-x64/publish' + - publish: '$(testsFolder)/net8.0/linux-x64/publish' artifact: linux-x64-tests displayName: Publish linux-x64 Test Package condition: and(succeeded(), eq(variables['osName'], 'Windows')) - - publish: '$(testsFolder)/net6.0/linux-x86/publish' - artifact: linux-x86-tests - displayName: Publish linux-x86 Test Package - condition: and(succeeded(), eq(variables['osName'], 'Windows')) - - publish: '$(testsFolder)/net6.0/linux-musl-x64/publish' + - publish: '$(testsFolder)/net8.0/linux-musl-x64/publish' artifact: linux-musl-x64-tests displayName: Publish linux-musl-x64 Test Package condition: and(succeeded(), eq(variables['osName'], 'Windows')) - - publish: '$(testsFolder)/net6.0/freebsd-x64/publish' + - publish: '$(testsFolder)/net8.0/freebsd-x64/publish' artifact: freebsd-x64-tests displayName: Publish freebsd-x64 Test Package condition: and(succeeded(), eq(variables['osName'], 'Windows')) - - publish: '$(testsFolder)/net6.0/osx-x64/publish' + - publish: '$(testsFolder)/net8.0/osx-x64/publish' artifact: osx-x64-tests displayName: Publish osx-x64 Test Package condition: and(succeeded(), eq(variables['osName'], 'Windows')) @@ -189,7 +185,7 @@ stages: artifact: '$(osName)Frontend' displayName: Publish Frontend condition: and(succeeded(), eq(variables['osName'], 'Windows')) - + - stage: Installer dependsOn: - Build_Backend @@ -260,21 +256,21 @@ stages: archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).windows-core-x64.zip' archiveType: 'zip' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/win-x64/net6.0 + rootFolderOrFile: $(artifactsFolder)/win-x64/net8.0 - task: ArchiveFiles@2 displayName: Create win-x86 zip inputs: archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).windows-core-x86.zip' archiveType: 'zip' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/win-x86/net6.0 + rootFolderOrFile: $(artifactsFolder)/win-x86/net8.0 - task: ArchiveFiles@2 displayName: Create osx-x64 app inputs: archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).osx-app-core-x64.zip' archiveType: 'zip' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/osx-x64-app/net6.0 + rootFolderOrFile: $(artifactsFolder)/osx-x64-app/net8.0 - task: ArchiveFiles@2 displayName: Create osx-x64 tar inputs: @@ -282,14 +278,14 @@ stages: archiveType: 'tar' tarCompression: 'gz' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/osx-x64/net6.0 + rootFolderOrFile: $(artifactsFolder)/osx-x64/net8.0 - task: ArchiveFiles@2 displayName: Create osx-arm64 app inputs: archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).osx-app-core-arm64.zip' archiveType: 'zip' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/osx-arm64-app/net6.0 + rootFolderOrFile: $(artifactsFolder)/osx-arm64-app/net8.0 - task: ArchiveFiles@2 displayName: Create osx-arm64 tar inputs: @@ -297,7 +293,7 @@ stages: archiveType: 'tar' tarCompression: 'gz' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/osx-arm64/net6.0 + rootFolderOrFile: $(artifactsFolder)/osx-arm64/net8.0 - task: ArchiveFiles@2 displayName: Create linux-x64 tar inputs: @@ -305,7 +301,7 @@ stages: archiveType: 'tar' tarCompression: 'gz' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/linux-x64/net6.0 + rootFolderOrFile: $(artifactsFolder)/linux-x64/net8.0 - task: ArchiveFiles@2 displayName: Create linux-musl-x64 tar inputs: @@ -313,15 +309,7 @@ stages: archiveType: 'tar' tarCompression: 'gz' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/linux-musl-x64/net6.0 - - task: ArchiveFiles@2 - displayName: Create linux-x86 tar - inputs: - archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).linux-core-x86.tar.gz' - archiveType: 'tar' - tarCompression: 'gz' - includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/linux-x86/net6.0 + rootFolderOrFile: $(artifactsFolder)/linux-musl-x64/net8.0 - task: ArchiveFiles@2 displayName: Create linux-arm tar inputs: @@ -329,7 +317,7 @@ stages: archiveType: 'tar' tarCompression: 'gz' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/linux-arm/net6.0 + rootFolderOrFile: $(artifactsFolder)/linux-arm/net8.0 - task: ArchiveFiles@2 displayName: Create linux-musl-arm tar inputs: @@ -337,7 +325,7 @@ stages: archiveType: 'tar' tarCompression: 'gz' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/linux-musl-arm/net6.0 + rootFolderOrFile: $(artifactsFolder)/linux-musl-arm/net8.0 - task: ArchiveFiles@2 displayName: Create linux-arm64 tar inputs: @@ -345,7 +333,7 @@ stages: archiveType: 'tar' tarCompression: 'gz' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/linux-arm64/net6.0 + rootFolderOrFile: $(artifactsFolder)/linux-arm64/net8.0 - task: ArchiveFiles@2 displayName: Create linux-musl-arm64 tar inputs: @@ -353,7 +341,7 @@ stages: archiveType: 'tar' tarCompression: 'gz' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/linux-musl-arm64/net6.0 + rootFolderOrFile: $(artifactsFolder)/linux-musl-arm64/net8.0 - task: ArchiveFiles@2 displayName: Create freebsd-x64 tar inputs: @@ -361,7 +349,7 @@ stages: archiveType: 'tar' tarCompression: 'gz' includeRootFolder: false - rootFolderOrFile: $(artifactsFolder)/freebsd-x64/net6.0 + rootFolderOrFile: $(artifactsFolder)/freebsd-x64/net8.0 - publish: $(Build.ArtifactStagingDirectory) artifact: 'Packages' displayName: Publish Packages @@ -392,7 +380,7 @@ stages: SENTRY_AUTH_TOKEN: $(sentryAuthTokenServarr) SENTRY_ORG: $(sentryOrg) SENTRY_URL: $(sentryUrl) - + - stage: Unit_Test displayName: Unit Tests dependsOn: Build_Backend @@ -481,6 +469,7 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: '$(testName) Unit Tests' failTaskOnFailedTests: true + failTaskOnMissingResultsFile: ne(variables['testName'], 'freebsd-x64') - job: Unit_Docker displayName: Unit Docker @@ -492,29 +481,19 @@ stages: testName: 'Musl Net Core' artifactName: linux-musl-x64-tests containerImage: ghcr.io/servarr/testimages:alpine - linux-x86: - testName: 'linux-x86' - artifactName: linux-x86-tests - containerImage: ghcr.io/servarr/testimages:linux-x86 pool: vmImage: ${{ variables.linuxImage }} - + container: $[ variables['containerImage'] ] timeoutInMinutes: 10 - + steps: - task: UseDotNet@2 displayName: 'Install .NET' inputs: version: $(dotnetVersion) - condition: and(succeeded(), ne(variables['testName'], 'linux-x86')) - - bash: | - SDKURL=$(curl -s https://api.github.com/repos/Servarr/dotnet-linux-x86/releases | jq -rc '.[].assets[].browser_download_url' | grep sdk-${DOTNETVERSION}.*gz$) - curl -fsSL $SDKURL | tar xzf - -C /opt/dotnet - displayName: 'Install .NET' - condition: and(succeeded(), eq(variables['testName'], 'linux-x86')) - checkout: none - task: DownloadPipelineArtifact@2 displayName: Download Test Artifact @@ -540,7 +519,8 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: '$(testName) Unit Tests' failTaskOnFailedTests: true - + failTaskOnMissingResultsFile: true + - job: Unit_LinuxCore_Postgres14 displayName: Unit Native LinuxCore with Postgres14 Database dependsOn: Prepare @@ -557,7 +537,7 @@ stages: vmImage: ${{ variables.linuxImage }} timeoutInMinutes: 10 - + steps: - task: UseDotNet@2 displayName: 'Install .net core' @@ -596,6 +576,7 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: 'LinuxCore Postgres14 Unit Tests' failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true - job: Unit_LinuxCore_Postgres15 displayName: Unit Native LinuxCore with Postgres15 Database @@ -608,12 +589,12 @@ stages: Radarr__Postgres__Port: '5432' Radarr__Postgres__User: 'radarr' Radarr__Postgres__Password: 'radarr' - + pool: vmImage: ${{ variables.linuxImage }} timeoutInMinutes: 10 - + steps: - task: UseDotNet@2 displayName: 'Install .net core' @@ -652,6 +633,7 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: 'LinuxCore Postgres15 Unit Tests' failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true - stage: Integration displayName: Integration @@ -695,7 +677,7 @@ stages: pool: vmImage: $(imageName) - + steps: - task: UseDotNet@2 displayName: 'Install .net core' @@ -717,7 +699,7 @@ stages: targetPath: $(Build.ArtifactStagingDirectory) - task: ExtractFiles@1 inputs: - archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' displayName: Extract Package - bash: | @@ -734,6 +716,7 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: '$(testName) Integration Tests' failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true displayName: Publish Test Results - job: Integration_LinuxCore_Postgres14 @@ -771,7 +754,7 @@ stages: targetPath: $(Build.ArtifactStagingDirectory) - task: ExtractFiles@1 inputs: - archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' displayName: Extract Package - bash: | @@ -796,6 +779,7 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: 'Integration LinuxCore Postgres14 Database Integration Tests' failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true displayName: Publish Test Results @@ -834,7 +818,7 @@ stages: targetPath: $(Build.ArtifactStagingDirectory) - task: ExtractFiles@1 inputs: - archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' displayName: Extract Package - bash: | @@ -859,6 +843,7 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: 'Integration LinuxCore Postgres15 Database Integration Tests' failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true displayName: Publish Test Results - job: Integration_FreeBSD @@ -905,6 +890,7 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: 'FreeBSD Integration Tests' failTaskOnFailedTests: true + failTaskOnMissingResultsFile: false displayName: Publish Test Results - job: Integration_Docker @@ -918,29 +904,18 @@ stages: artifactName: linux-musl-x64-tests containerImage: ghcr.io/servarr/testimages:alpine pattern: 'Radarr.*.linux-musl-core-x64.tar.gz' - linux-x86: - testName: 'linux-x86' - artifactName: linux-x86-tests - containerImage: ghcr.io/servarr/testimages:linux-x86 - pattern: 'Radarr.*.linux-core-x86.tar.gz' pool: vmImage: ${{ variables.linuxImage }} container: $[ variables['containerImage'] ] timeoutInMinutes: 15 - + steps: - task: UseDotNet@2 displayName: 'Install .NET' inputs: version: $(dotnetVersion) - condition: and(succeeded(), ne(variables['testName'], 'linux-x86')) - - bash: | - SDKURL=$(curl -s https://api.github.com/repos/Servarr/dotnet-linux-x86/releases | jq -rc '.[].assets[].browser_download_url' | grep sdk-${DOTNETVERSION}.*gz$) - curl -fsSL $SDKURL | tar xzf - -C /opt/dotnet - displayName: 'Install .NET' - condition: and(succeeded(), eq(variables['testName'], 'linux-x86')) - checkout: none - task: DownloadPipelineArtifact@2 displayName: Download Test Artifact @@ -957,7 +932,7 @@ stages: targetPath: $(Build.ArtifactStagingDirectory) - task: ExtractFiles@1 inputs: - archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' displayName: Extract Package - bash: | @@ -974,12 +949,13 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: '$(testName) Integration Tests' failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true displayName: Publish Test Results - stage: Automation displayName: Automation dependsOn: Packages - + jobs: - job: Automation strategy: @@ -1005,7 +981,7 @@ stages: pool: vmImage: $(imageName) - + steps: - task: UseDotNet@2 displayName: 'Install .net core' @@ -1027,7 +1003,7 @@ stages: targetPath: $(Build.ArtifactStagingDirectory) - task: ExtractFiles@1 inputs: - archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' displayName: Extract Package - bash: | @@ -1055,6 +1031,7 @@ stages: testResultsFiles: '**/TestResult.xml' testRunTitle: '$(osName) Automation Tests' failTaskOnFailedTests: $(failBuild) + failTaskOnMissingResultsFile: $(failBuild) displayName: Publish Test Results - stage: Analyze @@ -1116,20 +1093,20 @@ stages: vmImage: ${{ variables.windowsImage }} steps: - checkout: self # Need history for Sonar analysis - - task: SonarCloudPrepare@2 + - task: SonarCloudPrepare@3 env: SONAR_SCANNER_OPTS: '' inputs: SonarCloud: 'SonarCloud' organization: 'radarr' - scannerMode: 'CLI' + scannerMode: 'cli' configMode: 'manual' cliProjectKey: 'Radarr_Radarr.UI' cliProjectName: 'RadarrUI' cliProjectVersion: '$(radarrVersion)' cliSources: './frontend' - - task: SonarCloudAnalyze@2 - + - task: SonarCloudAnalyze@3 + - job: Api_Docs displayName: API Docs dependsOn: Prepare @@ -1151,7 +1128,7 @@ stages: - checkout: self submodules: true persistCredentials: true - fetchDepth: 1 + fetchDepth: 1 - bash: ./docs.sh Windows displayName: Create openapi.json - bash: | @@ -1205,34 +1182,35 @@ stages: submodules: true - powershell: Set-Service SCardSvr -StartupType Manual displayName: Enable Windows Test Service - - task: SonarCloudPrepare@2 + - task: SonarCloudPrepare@3 condition: eq(variables['System.PullRequest.IsFork'], 'False') inputs: SonarCloud: 'SonarCloud' organization: 'radarr' - scannerMode: 'MSBuild' + scannerMode: 'dotnet' projectKey: 'Radarr_Radarr' projectName: 'Radarr' projectVersion: '$(radarrVersion)' extraProperties: | sonar.exclusions=**/obj/**,**/*.dll,**/NzbDrone.Core.Test/Files/**/*,./frontend/**,**/ExternalModules/**,./src/Libraries/** sonar.coverage.exclusions=**/Radarr.Api.V3/**/* - sonar.cs.opencover.reportsPaths=$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml + sonar.cs.cobertura.reportsPaths=$(Build.SourcesDirectory)/CoverageResults/**/coverage.cobertura.xml sonar.cs.nunit.reportsPaths=$(Build.SourcesDirectory)/TestResult.xml - bash: | - ./build.sh --backend -f net6.0 -r win-x64 - TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage + ./build.sh --backend -f net8.0 -r win-x64 + TEST_DIR=_tests/net8.0/win-x64/publish/ ./test.sh Windows Unit Coverage displayName: Coverage Unit Tests - - task: SonarCloudAnalyze@2 + - task: SonarCloudAnalyze@3 condition: eq(variables['System.PullRequest.IsFork'], 'False') displayName: Publish SonarCloud Results - task: reportgenerator@5 displayName: Generate Coverage Report inputs: - reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml' + reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.cobertura.xml' targetdir: '$(Build.SourcesDirectory)/CoverageResults/combined' reporttypes: 'HtmlInline_AzurePipelines;Cobertura;Badges' publishCodeCoverageResults: true + sourcedirs: src - stage: Report_Out dependsOn: @@ -1264,4 +1242,3 @@ stages: DISCORDCHANNELID: $(discordChannelId) DISCORDWEBHOOKKEY: $(discordWebhookKey) DISCORDTHREADID: $(discordThreadId) - diff --git a/build.sh b/build.sh index 2cb5facae7..73e785bebe 100755 --- a/build.sh +++ b/build.sh @@ -33,14 +33,14 @@ EnableExtraPlatformsInSDK() echo "Extra platforms already enabled" else echo "Enabling extra platform support" - sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS + sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS" fi } EnableExtraPlatforms() { if grep -qv freebsd-x64 src/Directory.Build.props; then - sed -i'' -e "s^\(.*\)^\1;freebsd-x64;linux-x86^g" src/Directory.Build.props + sed -i'' -e "s^\(.*\)^\1;freebsd-x64^g" src/Directory.Build.props fi } @@ -79,9 +79,9 @@ Build() if [[ -z "$RID" || -z "$FRAMEWORK" ]]; then - dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -t:PublishAllRids + dotnet msbuild -restore $slnFile -p:SelfContained=True -p:Configuration=Release -p:Platform=$platform -t:PublishAllRids else - dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$RID -t:PublishAllRids + dotnet msbuild -restore $slnFile -p:SelfContained=True -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$RID -t:PublishAllRids fi ProgressEnd 'Build' @@ -137,7 +137,7 @@ PackageLinux() echo "Adding Radarr.Mono to UpdatePackage" cp $folder/Radarr.Mono.* $folder/Radarr.Update - if [ "$framework" = "net6.0" ]; then + if [ "$framework" = "net8.0" ]; then cp $folder/Mono.Posix.NETStandard.* $folder/Radarr.Update cp $folder/libMonoPosixHelper.* $folder/Radarr.Update fi @@ -165,7 +165,7 @@ PackageMacOS() echo "Adding Radarr.Mono to UpdatePackage" cp $folder/Radarr.Mono.* $folder/Radarr.Update - if [ "$framework" = "net6.0" ]; then + if [ "$framework" = "net8.0" ]; then cp $folder/Mono.Posix.NETStandard.* $folder/Radarr.Update cp $folder/libMonoPosixHelper.* $folder/Radarr.Update fi @@ -377,15 +377,14 @@ then Build if [[ -z "$RID" || -z "$FRAMEWORK" ]]; then - PackageTests "net6.0" "win-x64" - PackageTests "net6.0" "win-x86" - PackageTests "net6.0" "linux-x64" - PackageTests "net6.0" "linux-musl-x64" - PackageTests "net6.0" "osx-x64" + PackageTests "net8.0" "win-x64" + PackageTests "net8.0" "win-x86" + PackageTests "net8.0" "linux-x64" + PackageTests "net8.0" "linux-musl-x64" + PackageTests "net8.0" "osx-x64" if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; then - PackageTests "net6.0" "freebsd-x64" - PackageTests "net6.0" "linux-x86" + PackageTests "net8.0" "freebsd-x64" fi else PackageTests "$FRAMEWORK" "$RID" @@ -413,20 +412,19 @@ then if [[ -z "$RID" || -z "$FRAMEWORK" ]]; then - Package "net6.0" "win-x64" - Package "net6.0" "win-x86" - Package "net6.0" "linux-x64" - Package "net6.0" "linux-musl-x64" - Package "net6.0" "linux-arm64" - Package "net6.0" "linux-musl-arm64" - Package "net6.0" "linux-arm" - Package "net6.0" "linux-musl-arm" - Package "net6.0" "osx-x64" - Package "net6.0" "osx-arm64" + Package "net8.0" "win-x64" + Package "net8.0" "win-x86" + Package "net8.0" "linux-x64" + Package "net8.0" "linux-musl-x64" + Package "net8.0" "linux-arm64" + Package "net8.0" "linux-musl-arm64" + Package "net8.0" "linux-arm" + Package "net8.0" "linux-musl-arm" + Package "net8.0" "osx-x64" + Package "net8.0" "osx-arm64" if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; then - Package "net6.0" "freebsd-x64" - Package "net6.0" "linux-x86" + Package "net8.0" "freebsd-x64" fi else Package "$FRAMEWORK" "$RID" @@ -436,7 +434,7 @@ fi if [ "$INSTALLER" = "YES" ]; then InstallInno - BuildInstaller "net6.0" "win-x64" - BuildInstaller "net6.0" "win-x86" + BuildInstaller "net8.0" "win-x64" + BuildInstaller "net8.0" "win-x86" RemoveInno fi diff --git a/create_test_cases.py b/create_test_cases.py deleted file mode 100644 index 5d1879ea1f..0000000000 --- a/create_test_cases.py +++ /dev/null @@ -1,44 +0,0 @@ -input1 = """Prometheus.Special.Edition.Fan Edit.2012..BRRip.x264.AAC-m2g -Star Wars Episode IV - A New Hope (Despecialized) 1999.mkv -Prometheus.(Special.Edition.Remastered).2012.[Bluray-1080p].mkv -Prometheus Extended 2012 -Prometheus Extended Directors Cut Fan Edit 2012 -Prometheus Director's Cut 2012 -Prometheus Directors Cut 2012 -Prometheus.(Extended.Theatrical.Version.IMAX).BluRay.1080p.2012.asdf -2001 A Space Odyssey Director's Cut (1968).mkv -2001: A Space Odyssey (Extended Directors Cut FanEdit) Bluray 1080p 1968 -A Fake Movie 2035 Directors 2012.mkv -Blade Runner Director's Cut 2049.mkv -Prometheus 50th Anniversary Edition 2012.mkv -Movie 2in1 2012.mkv -Movie IMAX 2012.mkv""" - -output1 = """Special.Edition.Fan Edit BRRip.x264.AAC-m2g -Despecialized mkv -Special.Edition.Remastered Bluray-1080p].mkv -Extended mkv -Extended Directors Cut Fan Edit mkv -Director's Cut mkv -Directors Cut mkv -Extended.Theatrical.Version.IMAX asdf -Director's Cut mkv -Extended Directors Cut FanEdit mkv -Directors mkv -Director's Cut mkv -50th Anniversary Edition mkv -2in1 mkv -IMAX mkv""" - -inputs = input1.split("\n") -outputs = output1.split("\n") -real_o = [] -for output in outputs: - real_o.append(output.split(" ")[0].replace(".", " ").strip()) - -count = 0 - -for inp in inputs: - o = real_o[count] - print "[TestCase(\"{0}\", \"{1}\")]".format(inp, o) - count += 1 diff --git a/docs.sh b/docs.sh index f26c1bcdca..c84aeb4fe6 100644 --- a/docs.sh +++ b/docs.sh @@ -1,13 +1,18 @@ +#!/bin/bash +set -e + +FRAMEWORK="net8.0" PLATFORM=$1 +ARCHITECTURE="${2:-x64}" if [ "$PLATFORM" = "Windows" ]; then - RUNTIME="win-x64" + RUNTIME="win-$ARCHITECTURE" elif [ "$PLATFORM" = "Linux" ]; then - RUNTIME="linux-x64" + RUNTIME="linux-$ARCHITECTURE" elif [ "$PLATFORM" = "Mac" ]; then - RUNTIME="osx-x64" + RUNTIME="osx-$ARCHITECTURE" else - echo "Platform must be provided as first arguement: Windows, Linux or Mac" + echo "Platform must be provided as first argument: Windows, Linux or Mac" exit 1 fi @@ -33,9 +38,9 @@ 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 8.1.4 Swashbuckle.AspNetCore.Cli -dotnet tool run swagger tofile --output ./src/Radarr.Api.V3/openapi.json "$outputFolder/net6.0/$RUNTIME/$application" v3 & +dotnet tool run swagger tofile --output ./src/Radarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v3 & sleep 45 diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index ddc7300fd1..77b933a8f7 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -210,7 +210,6 @@ module.exports = { 'no-undef-init': 'off', 'no-undefined': 'off', 'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }], - 'no-use-before-define': 'error', // Node.js and CommonJS diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index 60dd6d2b12..6c244c5af3 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -14,7 +14,6 @@ module.exports = (env) => { const srcFolder = path.join(frontendFolder, 'src'); const isProduction = !!env.production; const isProfiling = isProduction && !!env.profile; - const inlineWebWorkers = 'no-fallback'; const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder); @@ -26,6 +25,7 @@ module.exports = (env) => { const config = { mode: isProduction ? 'production' : 'development', devtool: isProduction ? 'source-map' : 'eval-source-map', + target: 'web', stats: { children: false @@ -133,6 +133,12 @@ module.exports = (env) => { { source: 'frontend/src/Content/robots.txt', destination: path.join(distFolder, 'Content/robots.txt') + }, + + // manifest.json and browserconfig.xml + { + source: 'frontend/src/Content/*.(json|xml)', + destination: path.join(distFolder, 'Content') } ] } @@ -153,16 +159,6 @@ module.exports = (env) => { module: { rules: [ - { - test: /\.worker\.js$/, - use: { - loader: 'worker-loader', - options: { - filename: '[name].js', - inline: inlineWebWorkers - } - } - }, { test: [/\.jsx?$/, /\.tsx?$/], exclude: /(node_modules|JsLibraries)/, @@ -180,7 +176,7 @@ module.exports = (env) => { loose: true, debug: false, useBuiltIns: 'entry', - corejs: 3 + corejs: '3.42' } ] ] diff --git a/frontend/src/Activity/Blocklist/Blocklist.tsx b/frontend/src/Activity/Blocklist/Blocklist.tsx index 4163bc9ca4..75afecce0e 100644 --- a/frontend/src/Activity/Blocklist/Blocklist.tsx +++ b/frontend/src/Activity/Blocklist/Blocklist.tsx @@ -145,7 +145,7 @@ function Blocklist() { }); const handleFilterSelect = useCallback( - (selectedFilterKey: string) => { + (selectedFilterKey: string | number) => { dispatch(setBlocklistFilter({ selectedFilterKey })); }, [dispatch] diff --git a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx index ec026ae92e..2a1c4f9451 100644 --- a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx +++ b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx @@ -26,7 +26,7 @@ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) { return ( - Details + {translate('Details')} diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js deleted file mode 100644 index b3ae7cb2c8..0000000000 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ /dev/null @@ -1,354 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; -import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; -import Link from 'Components/Link/Link'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; -import translate from 'Utilities/String/translate'; -import styles from './HistoryDetails.css'; - -function HistoryDetails(props) { - const { - eventType, - sourceTitle, - data, - downloadId, - shortDateFormat, - timeFormat - } = props; - - if (eventType === 'grabbed') { - const { - indexer, - releaseGroup, - movieMatchType, - customFormatScore, - nzbInfoUrl, - downloadClient, - downloadClientName, - age, - ageHours, - ageMinutes, - publishedDate - } = data; - - const downloadClientNameInfo = downloadClientName ?? downloadClient; - - return ( - - - - { - indexer ? - : - null - } - - { - releaseGroup ? - : - null - } - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - { - movieMatchType ? - : - null - } - - { - nzbInfoUrl ? - - - {translate('InfoUrl')} - - - - {nzbInfoUrl} - - : - null - } - - { - downloadClientNameInfo ? - : - null - } - - { - downloadId ? - : - null - } - - { - indexer ? - : - 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 === 'movieFileDeleted') { - const { - reason, - customFormatScore - } = data; - - let reasonMessage = ''; - - switch (reason) { - case 'Manual': - reasonMessage = translate('DeletedReasonManual'); - break; - case 'MissingFromDisk': - reasonMessage = translate('DeletedReasonMissingFromDisk'); - break; - case 'Upgrade': - reasonMessage = translate('DeletedReasonUpgrade'); - break; - default: - reasonMessage = ''; - } - - return ( - - - - - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - ); - } - - if (eventType === 'movieFileRenamed') { - const { - sourcePath, - sourceRelativePath, - path, - relativePath - } = data; - - return ( - - - - - - - - - - ); - } - - if (eventType === 'downloadIgnored') { - const { - message - } = data; - - return ( - - - - { - downloadId ? - : - null - } - - { - message ? - : - null - } - - ); - } - - return ( - - - - ); -} - -HistoryDetails.propTypes = { - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - downloadId: PropTypes.string, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetails.tsx b/frontend/src/Activity/History/Details/HistoryDetails.tsx new file mode 100644 index 0000000000..887404ecb8 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.tsx @@ -0,0 +1,348 @@ +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, + GrabbedHistoryData, + HistoryData, + HistoryEventType, + MovieFileDeletedHistory, + MovieFileRenamedHistory, +} from 'typings/History'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; +import styles from './HistoryDetails.css'; + +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, + movieMatchType, + releaseSource, + customFormatScore, + nzbInfoUrl, + downloadClient, + downloadClientName, + age, + ageHours, + ageMinutes, + publishedDate, + size, + } = 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} + + {movieMatchType ? ( + + ) : null} + + {releaseSource ? ( + + ) : null} + + {nzbInfoUrl ? ( + + + {translate('InfoUrl')} + + + + {nzbInfoUrl} + + + ) : null} + + {downloadClientNameInfo ? ( + + ) : null} + + {downloadId ? ( + + ) : null} + + {age || ageHours || ageMinutes ? ( + + ) : null} + + {publishedDate ? ( + + ) : null} + + {size ? ( + + ) : null} + + ); + } + + if (eventType === 'downloadFailed') { + const { message, indexer } = data as DownloadFailedHistory; + + return ( + + + + {downloadId ? ( + + ) : null} + + {indexer ? ( + + ) : null} + + {message ? ( + + ) : null} + + ); + } + + if (eventType === 'downloadFolderImported') { + const { customFormatScore, droppedPath, importedPath, size } = + data as DownloadFolderImportedHistory; + + return ( + + + + {droppedPath ? ( + + ) : null} + + {importedPath ? ( + + ) : null} + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + {size ? ( + + ) : null} + + ); + } + + if (eventType === 'movieFileDeleted') { + const { reason, customFormatScore, size } = data as MovieFileDeletedHistory; + + let reasonMessage = ''; + + switch (reason) { + case 'Manual': + reasonMessage = translate('DeletedReasonManual'); + break; + case 'MissingFromDisk': + reasonMessage = translate('DeletedReasonMovieMissingFromDisk'); + break; + case 'Upgrade': + reasonMessage = translate('DeletedReasonUpgrade'); + break; + default: + reasonMessage = ''; + } + + return ( + + + + + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + {size ? ( + + ) : null} + + ); + } + + if (eventType === 'movieFileRenamed') { + const { sourcePath, sourceRelativePath, path, relativePath } = + data as MovieFileRenamedHistory; + + return ( + + + + + + + + + + ); + } + + if (eventType === 'downloadIgnored') { + const { message } = data as DownloadIgnoredHistory; + + return ( + + + + {downloadId ? ( + + ) : null} + + {message ? ( + + ) : null} + + ); + } + + return ( + + + + ); +} + +export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js deleted file mode 100644 index 65d95e5574..0000000000 --- a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js +++ /dev/null @@ -1,18 +0,0 @@ -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 { - shortDateFormat: uiSettings.shortDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -export default connect(createMapStateToProps)(HistoryDetails); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx similarity index 52% rename from frontend/src/Activity/History/Details/HistoryDetailsModal.js rename to frontend/src/Activity/History/Details/HistoryDetailsModal.tsx index 19fda4907c..69e4405ea0 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.js +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; @@ -8,11 +7,12 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; +import { HistoryData, HistoryEventType } from 'typings/History'; import translate from 'Utilities/String/translate'; import HistoryDetails from './HistoryDetails'; import styles from './HistoryDetailsModal.css'; -function getHeaderTitle(eventType) { +function getHeaderTitle(eventType: HistoryEventType) { switch (eventType) { case 'grabbed': return translate('Grabbed'); @@ -31,29 +31,33 @@ function getHeaderTitle(eventType) { } } -function HistoryDetailsModal(props) { +interface HistoryDetailsModalProps { + isOpen: boolean; + eventType: HistoryEventType; + sourceTitle: string; + data: HistoryData; + downloadId?: string; + isMarkingAsFailed?: boolean; + onMarkAsFailedPress: () => void; + onModalClose: () => void; +} + +function HistoryDetailsModal(props: HistoryDetailsModalProps) { const { isOpen, eventType, sourceTitle, data, downloadId, - isMarkingAsFailed, - shortDateFormat, - timeFormat, + isMarkingAsFailed = false, onMarkAsFailedPress, - onModalClose + onModalClose, } = props; return ( - + - - {getHeaderTitle(eventType)} - + {getHeaderTitle(eventType)} - { - eventType === 'grabbed' && - - {translate('MarkAsFailed')} - - } + {eventType === 'grabbed' && ( + + {translate('MarkAsFailed')} + + )} - + ); } -HistoryDetailsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - downloadId: PropTypes.string, - isMarkingAsFailed: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onMarkAsFailedPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -HistoryDetailsModal.defaultProps = { - isMarkingAsFailed: false -}; - export default HistoryDetailsModal; diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js deleted file mode 100644 index 21a06fb570..0000000000 --- a/frontend/src/Activity/History/History.js +++ /dev/null @@ -1,158 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import TablePager from 'Components/Table/TablePager'; -import { align, icons, kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import HistoryFilterModal from './HistoryFilterModal'; -import HistoryRowConnector from './HistoryRowConnector'; - -class History extends Component { - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - isMoviesFetching, - isMoviesPopulated, - moviesError, - items, - columns, - selectedFilterKey, - filters, - customFilters, - totalRecords, - onFilterSelect, - onFirstPagePress, - ...otherProps - } = this.props; - - const isFetchingAny = isFetching || isMoviesFetching; - const isAllPopulated = isPopulated && (isMoviesPopulated || !items.length); - const hasError = error || moviesError; - - 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, - isMoviesFetching: PropTypes.bool.isRequired, - isMoviesPopulated: PropTypes.bool.isRequired, - moviesError: 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, - onFilterSelect: PropTypes.func.isRequired, - onFirstPagePress: PropTypes.func.isRequired -}; - -export default History; diff --git a/frontend/src/Activity/History/History.tsx b/frontend/src/Activity/History/History.tsx new file mode 100644 index 0000000000..07080f51fb --- /dev/null +++ b/frontend/src/Activity/History/History.tsx @@ -0,0 +1,216 @@ +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 useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import { align, icons, kinds } from 'Helpers/Props'; +import createMoviesFetchingSelector from 'Movie/createMoviesFetchingSelector'; +import { + clearHistory, + fetchHistory, + gotoHistoryPage, + setHistoryFilter, + setHistorySort, + setHistoryTableOption, +} from 'Store/Actions/historyActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import { TableOptionsChangePayload } from 'typings/Table'; +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 { isMoviesFetching, isMoviesPopulated, moviesError } = useSelector( + createMoviesFetchingSelector() + ); + const customFilters = useSelector(createCustomFiltersSelector('history')); + const dispatch = useDispatch(); + + const isFetchingAny = isFetching || isMoviesFetching; + const isAllPopulated = isPopulated && (isMoviesPopulated || !items.length); + const hasError = error || moviesError; + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoHistoryPage, + }); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string | number) => { + 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()); + }; + }, [requestCurrentPage, 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 movies to populate because they are never coming. + + isPopulated && !hasError && !items.length ? ( + {translate('NoHistoryFound')} + ) : null + } + + {isAllPopulated && !hasError && items.length ? ( +
+ + + {items.map((item) => { + return ( + + ); + })} + +
+ + +
+ ) : null} +
+
+ ); +} + +export default History; diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js deleted file mode 100644 index 6cb5d5f7c2..0000000000 --- a/frontend/src/Activity/History/HistoryConnector.js +++ /dev/null @@ -1,141 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import withCurrentPage from 'Components/withCurrentPage'; -import * as historyActions from 'Store/Actions/historyActions'; -import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import History from './History'; - -function createMapStateToProps() { - return createSelector( - (state) => state.history, - (state) => state.movies, - createCustomFiltersSelector('history'), - (history, movies, customFilters) => { - return { - isMoviesFetching: movies.isFetching, - isMoviesPopulated: movies.isPopulated, - moviesError: movies.error, - customFilters, - ...history - }; - } - ); -} - -const mapDispatchToProps = { - ...historyActions -}; - -class HistoryConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - useCurrentPage, - fetchHistory, - gotoHistoryFirstPage - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchHistory(); - } else { - gotoHistoryFirstPage(); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearHistory(); - } - - // - // 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 -}; - -export default withCurrentPage( - connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector) -); diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.tsx similarity index 56% rename from frontend/src/Activity/History/HistoryEventTypeCell.js rename to frontend/src/Activity/History/HistoryEventTypeCell.tsx index b6e003ace2..5069a8e052 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.js +++ b/frontend/src/Activity/History/HistoryEventTypeCell.tsx @@ -1,12 +1,17 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons, kinds } from 'Helpers/Props'; +import { + GrabbedHistoryData, + HistoryData, + HistoryEventType, + MovieFileDeletedHistory, +} from 'typings/History'; import translate from 'Utilities/String/translate'; import styles from './HistoryEventTypeCell.css'; -function getIconName(eventType, data) { +function getIconName(eventType: HistoryEventType, data: HistoryData) { switch (eventType) { case 'grabbed': return icons.DOWNLOADING; @@ -17,7 +22,9 @@ function getIconName(eventType, data) { case 'downloadFailed': return icons.DOWNLOADING; case 'movieFileDeleted': - return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE; + return (data as MovieFileDeletedHistory).reason === 'MissingFromDisk' + ? icons.FILE_MISSING + : icons.DELETE; case 'movieFileRenamed': return icons.ORGANIZE; case 'downloadIgnored': @@ -27,7 +34,7 @@ function getIconName(eventType, data) { } } -function getIconKind(eventType) { +function getIconKind(eventType: HistoryEventType) { switch (eventType) { case 'downloadFailed': return kinds.DANGER; @@ -36,52 +43,47 @@ function getIconKind(eventType) { } } -function getTooltip(eventType, data) { +function getTooltip(eventType: HistoryEventType, data: HistoryData) { switch (eventType) { case 'grabbed': - return translate('MovieGrabbedHistoryTooltip', { indexer: data.indexer, downloadClient: data.downloadClient }); + return translate('MovieGrabbedTooltip', { + indexer: (data as GrabbedHistoryData).indexer, + downloadClient: (data as GrabbedHistoryData).downloadClient, + }); case 'movieFolderImported': return translate('MovieFolderImportedTooltip'); case 'downloadFolderImported': return translate('MovieImportedTooltip'); case 'downloadFailed': - return translate('MovieDownloadFailedTooltip'); + return translate('DownloadFailedMovieTooltip'); case 'movieFileDeleted': - return data.reason === 'MissingFromDisk' ? translate('MovieFileMissingTooltip') : translate('MovieFileDeletedTooltip'); + return (data as MovieFileDeletedHistory).reason === 'MissingFromDisk' + ? translate('MovieFileMissingTooltip') + : translate('MovieFileDeletedTooltip'); case 'movieFileRenamed': return translate('MovieFileRenamedTooltip'); case 'downloadIgnored': - return translate('MovieDownloadIgnoredTooltip'); + return translate('DownloadIgnoredMovieTooltip'); default: return translate('UnknownEventTooltip'); } } -function HistoryEventTypeCell({ eventType, data }) { +interface HistoryEventTypeCellProps { + eventType: HistoryEventType; + data: HistoryData; +} + +function HistoryEventTypeCell({ eventType, data }: HistoryEventTypeCellProps) { const iconName = getIconName(eventType, data); const iconKind = getIconKind(eventType); const tooltip = getTooltip(eventType, data); return ( - - + + ); } -HistoryEventTypeCell.propTypes = { - eventType: PropTypes.string.isRequired, - data: PropTypes.object -}; - -HistoryEventTypeCell.defaultProps = { - data: {} -}; - export default HistoryEventTypeCell; diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js deleted file mode 100644 index db87740ac9..0000000000 --- a/frontend/src/Activity/History/HistoryRow.js +++ /dev/null @@ -1,277 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRow from 'Components/Table/TableRow'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import { icons, tooltipPositions } from 'Helpers/Props'; -import MovieFormats from 'Movie/MovieFormats'; -import MovieLanguages from 'Movie/MovieLanguages'; -import MovieQuality from 'Movie/MovieQuality'; -import MovieTitleLink from 'Movie/MovieTitleLink'; -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 { - movie, - quality, - customFormats, - customFormatScore, - languages, - qualityCutoffNotMet, - eventType, - sourceTitle, - date, - data, - downloadId, - isMarkingAsFailed, - columns, - shortDateFormat, - timeFormat, - onMarkAsFailedPress - } = this.props; - - if (!movie) { - return null; - } - - return ( - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'eventType') { - return ( - - ); - } - - if (name === 'movieMetadata.sortTitle') { - 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 = { - movieId: PropTypes.number, - movie: PropTypes.object.isRequired, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - customFormatScore: PropTypes.number.isRequired, - qualityCutoffNotMet: PropTypes.bool.isRequired, - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - date: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - downloadId: PropTypes.string, - isMarkingAsFailed: PropTypes.bool, - markAsFailedError: PropTypes.object, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onMarkAsFailedPress: PropTypes.func.isRequired -}; - -HistoryRow.defaultProps = { - customFormats: [] -}; - -export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRow.tsx b/frontend/src/Activity/History/HistoryRow.tsx new file mode 100644 index 0000000000..1f253cac9b --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.tsx @@ -0,0 +1,229 @@ +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 usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import Language from 'Language/Language'; +import MovieFormats from 'Movie/MovieFormats'; +import MovieLanguages from 'Movie/MovieLanguages'; +import MovieQuality from 'Movie/MovieQuality'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import useMovie from 'Movie/useMovie'; +import { QualityModel } from 'Quality/Quality'; +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; + movieId: 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, + movieId, + languages, + quality, + customFormats = [], + customFormatScore, + qualityCutoffNotMet, + eventType, + sourceTitle, + date, + data, + downloadId, + isMarkingAsFailed = false, + markAsFailedError, + columns, + } = props; + + const wasMarkingAsFailed = usePrevious(isMarkingAsFailed); + const dispatch = useDispatch(); + const movie = useMovie(movieId); + + 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 (!movie) { + return null; + } + + return ( + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'eventType') { + return ( + + ); + } + + if (name === 'movieMetadata.sortTitle') { + return ( + + + + ); + } + + if (name === 'languages') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'date') { + return ; + } + + if (name === 'downloadClient') { + const downloadClientName = + 'downloadClientName' in data ? data.downloadClientName : null; + const downloadClient = + 'downloadClient' in data ? data.downloadClient : null; + + return ( + + {downloadClientName ?? downloadClient ?? ''} + + ); + } + + if (name === 'indexer') { + return ( + + {'indexer' in data ? data.indexer : ''} + + ); + } + + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + + if (name === 'releaseGroup') { + return ( + + {'releaseGroup' in data ? data.releaseGroup : ''} + + ); + } + + if (name === 'sourceTitle') { + return {sourceTitle}; + } + + if (name === 'details') { + return ( + + + + ); + } + + return null; + })} + + + + ); +} + +export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js deleted file mode 100644 index f3bdb404b0..0000000000 --- a/frontend/src/Activity/History/HistoryRowConnector.js +++ /dev/null @@ -1,73 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import HistoryRow from './HistoryRow'; - -function createMapStateToProps() { - return createSelector( - createMovieSelector(), - createUISettingsSelector(), - (movie, uiSettings) => { - return { - movie, - 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/Queue.tsx b/frontend/src/Activity/Queue/Queue.tsx index 536d07c096..e1f952bd76 100644 --- a/frontend/src/Activity/Queue/Queue.tsx +++ b/frontend/src/Activity/Queue/Queue.tsx @@ -183,7 +183,7 @@ function Queue() { }); const handleFilterSelect = useCallback( - (selectedFilterKey: string) => { + (selectedFilterKey: string | number) => { dispatch(setQueueFilter({ selectedFilterKey })); }, [dispatch] @@ -304,7 +304,7 @@ function Queue() { { + ({ name, value }: InputChanged) => { dispatch( setQueueOption({ [name]: value, diff --git a/frontend/src/Activity/Queue/QueueStatus.tsx b/frontend/src/Activity/Queue/QueueStatus.tsx index 2bd7f6d796..baeae8d638 100644 --- a/frontend/src/Activity/Queue/QueueStatus.tsx +++ b/frontend/src/Activity/Queue/QueueStatus.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import Icon, { IconProps } from 'Components/Icon'; +import Icon, { IconKind } from 'Components/Icon'; import Popover from 'Components/Tooltip/Popover'; import { icons, kinds } from 'Helpers/Props'; -import TooltipPosition from 'Helpers/Props/TooltipPosition'; +import { TooltipPosition } from 'Helpers/Props/tooltipPositions'; import { QueueTrackedDownloadState, QueueTrackedDownloadStatus, @@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) { // status === 'downloading' let iconName = icons.DOWNLOADING; - let iconKind: IconProps['kind'] = kinds.DEFAULT; + let iconKind: IconKind = kinds.DEFAULT; let title = translate('Downloading'); if (status === 'paused') { @@ -90,7 +90,7 @@ function QueueStatus(props: QueueStatusProps) { if (trackedDownloadState === 'importing') { title += ` - ${translate('Importing')}`; - iconKind = kinds.PURPLE; + iconKind = kinds.PRIMARY; } if (trackedDownloadState === 'failedPending') { diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js index 29bdaa3130..85ff4dd7da 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js @@ -82,8 +82,7 @@ class AddNewMovie extends Component { const { error, items, - hasExistingMovies, - colorImpairedMode + hasExistingMovies } = this.props; const term = this.state.term; @@ -131,7 +130,9 @@ class AddNewMovie extends Component {
{translate('FailedLoadingSearchResults')}
- {getErrorMessage(error)} + + {getErrorMessage(error)} +
{translate('WhySearchesCouldBeFailing')} @@ -148,7 +149,6 @@ class AddNewMovie extends Component { return ( ); @@ -221,8 +221,7 @@ AddNewMovie.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, hasExistingMovies: PropTypes.bool.isRequired, onMovieLookupChange: PropTypes.func.isRequired, - onClearMovieLookup: PropTypes.func.isRequired, - colorImpairedMode: PropTypes.bool.isRequired + onClearMovieLookup: PropTypes.func.isRequired }; export default AddNewMovie; diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js index 448fc18675..5a05865790 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js @@ -6,7 +6,6 @@ import { clearAddMovie, lookupMovie } from 'Store/Actions/addMovieActions'; import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import parseUrl from 'Utilities/String/parseUrl'; @@ -17,15 +16,13 @@ function createMapStateToProps() { (state) => state.addMovie, (state) => state.movies.items.length, (state) => state.router.location, - createUISettingsSelector(), - (addMovie, existingMoviesCount, location, uiSettings) => { + (addMovie, existingMoviesCount, location) => { const { params } = parseUrl(location.search); return { ...addMovie, term: params.term, - hasExistingMovies: existingMoviesCount > 0, - colorImpairedMode: uiSettings.enableColorImpairedMode + hasExistingMovies: existingMoviesCount > 0 }; } ); diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js index 7e13c46419..8e5b4b4559 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js @@ -79,9 +79,9 @@ class AddNewMovieModalContent extends Component { }
-
- {overview} -
+ {overview ? ( +
{overview}
+ ) : null}
@@ -98,7 +98,9 @@ class AddNewMovieModalContent extends Component { movieFolder: folder, isWindows }} - helpText={translate('SubfolderWillBeCreatedAutomaticallyInterp', [folder])} + helpText={translate('AddNewMovieRootFolderHelpText', { + folder + })} onChange={onInputChange} {...rootFolderPath} /> @@ -110,7 +112,7 @@ class AddNewMovieModalContent extends Component { - - {genres.slice(0, 3).join(', ')} - + : null } @@ -280,20 +276,18 @@ class AddNewMovieSearchResult extends Component { } canFlip={true} kind={kinds.INVERSE} - position={tooltipPositions.BOTTOM} + position={tooltipPositions.TOP} /> { isExistingMovie && isSmallScreen && }
@@ -338,12 +332,9 @@ AddNewMovieSearchResult.propTypes = { isExistingMovie: PropTypes.bool.isRequired, isExcluded: PropTypes.bool, isSmallScreen: PropTypes.bool.isRequired, - id: PropTypes.number, monitored: PropTypes.bool.isRequired, isAvailable: PropTypes.bool.isRequired, movieFile: PropTypes.object, - queueItem: PropTypes.object, - colorImpairedMode: PropTypes.bool, runtime: PropTypes.number.isRequired, movieRuntimeFormat: PropTypes.string.isRequired, certification: PropTypes.string diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js index 6e22256bce..ad3a5a3b0f 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js @@ -8,19 +8,16 @@ function createMapStateToProps() { return createSelector( createExistingMovieSelector(), createDimensionsSelector(), - (state) => state.queue.details.items, (state) => state.movieFiles.items, (state, { internalId }) => internalId, (state) => state.settings.ui.item.movieRuntimeFormat, - (isExistingMovie, dimensions, queueItems, movieFiles, internalId, movieRuntimeFormat) => { - const queueItem = queueItems.find((item) => internalId > 0 && item.movieId === internalId); + (isExistingMovie, dimensions, movieFiles, internalId, movieRuntimeFormat) => { const movieFile = movieFiles.find((item) => internalId > 0 && item.movieId === internalId); return { existingMovieId: internalId, isExistingMovie, isSmallScreen: dimensions.isSmallScreen, - queueItem, movieFile, movieRuntimeFormat }; diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js index 8a170af860..eb92afbdb3 100644 --- a/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js @@ -108,7 +108,7 @@ class ImportMovie extends Component { { !rootFoldersFetching && !!rootFoldersError ? - {translate('UnableToLoadRootFolders')} + {translate('RootFoldersLoadError')} : null } diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css index 415155274e..d0c6e98ae5 100644 --- a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css @@ -1,18 +1,10 @@ .inputContainer { margin-right: 20px; min-width: 150px; - - div { - margin-top: 10px; - - &:first-child { - margin-top: 0; - } - } } .label { - margin-bottom: 3px; + margin-bottom: 10px; font-weight: bold; } diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js index d84485b418..86259816f7 100644 --- a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js @@ -117,7 +117,7 @@ class ImportMovieFooter extends Component {
{ return ( - {translate('UnableToLoadRootFolders')} + {translate('RootFoldersLoadError')} : null } diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js deleted file mode 100644 index 5715dd3e33..0000000000 --- a/frontend/src/App/App.js +++ /dev/null @@ -1,30 +0,0 @@ -import { ConnectedRouter } from 'connected-react-router'; -import PropTypes from 'prop-types'; -import React from 'react'; -import DocumentTitle from 'react-document-title'; -import { Provider } from 'react-redux'; -import PageConnector from 'Components/Page/PageConnector'; -import ApplyTheme from './ApplyTheme'; -import AppRoutes from './AppRoutes'; - -function App({ store, history }) { - return ( - - - - - - - - - - - ); -} - -App.propTypes = { - store: PropTypes.object.isRequired, - history: PropTypes.object.isRequired -}; - -export default App; diff --git a/frontend/src/App/App.tsx b/frontend/src/App/App.tsx new file mode 100644 index 0000000000..0c7863f2e4 --- /dev/null +++ b/frontend/src/App/App.tsx @@ -0,0 +1,35 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router'; +import React from 'react'; +import DocumentTitle from 'react-document-title'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; +import Page from 'Components/Page/Page'; +import ApplyTheme from './ApplyTheme'; +import AppRoutes from './AppRoutes'; + +interface AppProps { + store: Store; + history: ConnectedRouterProps['history']; +} + +const queryClient = new QueryClient(); + +function App({ store, history }: AppProps) { + return ( + + + + + + + + + + + + + ); +} + +export default App; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js deleted file mode 100644 index c5207e4546..0000000000 --- a/frontend/src/App/AppRoutes.js +++ /dev/null @@ -1,260 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Redirect, Route } from 'react-router-dom'; -import Blocklist from 'Activity/Blocklist/Blocklist'; -import HistoryConnector from 'Activity/History/HistoryConnector'; -import Queue from 'Activity/Queue/Queue'; -import AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector'; -import ImportMovies from 'AddMovie/ImportMovie/ImportMovies'; -import CalendarPageConnector from 'Calendar/CalendarPageConnector'; -import CollectionConnector from 'Collection/CollectionConnector'; -import NotFound from 'Components/NotFound'; -import Switch from 'Components/Router/Switch'; -import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector'; -import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector'; -import MovieIndex from 'Movie/Index/MovieIndex'; -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 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 ( - - {/* - Movies - */} - - - - { - window.Radarr.urlBase && - { - return ( - - ); - }} - /> - } - - - - - - - - - - - - {/* - Calendar - */} - - - - {/* - Activity - */} - - - - - - - - {/* - Wanted - */} - - - - - - {/* - Settings - */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* - System - */} - - - - - - - - - - - - - - {/* - Not Found - */} - - - - ); -} - -AppRoutes.propTypes = { - app: PropTypes.func.isRequired -}; - -export default AppRoutes; diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx new file mode 100644 index 0000000000..2b8e105d69 --- /dev/null +++ b/frontend/src/App/AppRoutes.tsx @@ -0,0 +1,157 @@ +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 AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector'; +import ImportMovies from 'AddMovie/ImportMovie/ImportMovies'; +import CalendarPage from 'Calendar/CalendarPage'; +import CollectionConnector from 'Collection/CollectionConnector'; +import NotFound from 'Components/NotFound'; +import Switch from 'Components/Router/Switch'; +import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector'; +import MovieDetailsPage from 'Movie/Details/MovieDetailsPage'; +import MovieIndex from 'Movie/Index/MovieIndex'; +import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage'; +import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; +import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; +import ImportListSettings from 'Settings/ImportLists/ImportListSettings'; +import IndexerSettings from 'Settings/Indexers/IndexerSettings'; +import MediaManagement from 'Settings/MediaManagement/MediaManagement'; +import MetadataSettings from 'Settings/Metadata/MetadataSettings'; +import 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 CutoffUnmet from 'Wanted/CutoffUnmet/CutoffUnmet'; +import Missing from 'Wanted/Missing/Missing'; + +function RedirectWithUrlBase() { + return ; +} + +function AppRoutes() { + return ( + + {/* + Movies + */} + + + + {window.Radarr.urlBase && ( + + )} + + + + + + + + + + + + {/* + Calendar + */} + + + + {/* + Activity + */} + + + + + + + + {/* + Wanted + */} + + + + + + {/* + Settings + */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* + System + */} + + + + + + + + + + + + + + {/* + Not Found + */} + + + + ); +} + +export default AppRoutes; diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js deleted file mode 100644 index abc7f8832f..0000000000 --- a/frontend/src/App/AppUpdatedModal.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector'; - -function AppUpdatedModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -AppUpdatedModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModal.tsx b/frontend/src/App/AppUpdatedModal.tsx new file mode 100644 index 0000000000..696d36fb24 --- /dev/null +++ b/frontend/src/App/AppUpdatedModal.tsx @@ -0,0 +1,28 @@ +import React, { useCallback } from 'react'; +import Modal from 'Components/Modal/Modal'; +import AppUpdatedModalContent from './AppUpdatedModalContent'; + +interface AppUpdatedModalProps { + isOpen: boolean; + onModalClose: (...args: unknown[]) => unknown; +} + +function AppUpdatedModal(props: AppUpdatedModalProps) { + const { isOpen, onModalClose } = props; + + const handleModalClose = useCallback(() => { + location.reload(); + }, []); + + return ( + + + + ); +} + +export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js deleted file mode 100644 index a21afbc5aa..0000000000 --- a/frontend/src/App/AppUpdatedModalConnector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import AppUpdatedModal from './AppUpdatedModal'; - -function createMapDispatchToProps(dispatch, props) { - return { - onModalClose() { - location.reload(); - } - }; -} - -export default connect(null, createMapDispatchToProps)(AppUpdatedModal); diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js deleted file mode 100644 index 8cce1bc16c..0000000000 --- a/frontend/src/App/AppUpdatedModalContent.js +++ /dev/null @@ -1,139 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { kinds } from 'Helpers/Props'; -import UpdateChanges from 'System/Updates/UpdateChanges'; -import translate from 'Utilities/String/translate'; -import styles from './AppUpdatedModalContent.css'; - -function mergeUpdates(items, version, prevVersion) { - let installedIndex = items.findIndex((u) => u.version === version); - let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion); - - if (installedIndex === -1) { - installedIndex = 0; - } - - if (installedPreviouslyIndex === -1) { - installedPreviouslyIndex = items.length; - } else if (installedPreviouslyIndex === installedIndex && items.length) { - installedPreviouslyIndex += 1; - } - - const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); - - if (!appliedUpdates.length) { - return null; - } - - const appliedChanges = { new: [], fixed: [] }; - appliedUpdates.forEach((u) => { - if (u.changes) { - appliedChanges.new.push(... u.changes.new); - appliedChanges.fixed.push(... u.changes.fixed); - } - }); - - const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges }); - - if (!appliedChanges.new.length && !appliedChanges.fixed.length) { - mergedUpdate.changes = null; - } - - return mergedUpdate; -} - -function AppUpdatedModalContent(props) { - const { - version, - prevVersion, - isPopulated, - error, - items, - onSeeChangesPress, - onModalClose - } = props; - - const update = mergeUpdates(items, version, prevVersion); - - return ( - - - {translate('AppUpdated')} - - - -
- -
- - { - isPopulated && !error && !!update && -
- { - !update.changes && -
{translate('MaintenanceRelease')}
- } - - { - !!update.changes && -
-
- {translate('WhatsNew')} -
- - - - -
- } -
- } - - { - !isPopulated && !error && - - } -
- - - - - - -
- ); -} - -AppUpdatedModalContent.propTypes = { - version: PropTypes.string.isRequired, - prevVersion: PropTypes.string, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - onSeeChangesPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContent.tsx b/frontend/src/App/AppUpdatedModalContent.tsx new file mode 100644 index 0000000000..6031f748fd --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds } from 'Helpers/Props'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import UpdateChanges from 'System/Updates/UpdateChanges'; +import Update from 'typings/Update'; +import translate from 'Utilities/String/translate'; +import AppState from './State/AppState'; +import styles from './AppUpdatedModalContent.css'; + +function mergeUpdates(items: Update[], version: string, prevVersion?: string) { + let installedIndex = items.findIndex((u) => u.version === version); + let installedPreviouslyIndex = items.findIndex( + (u) => u.version === prevVersion + ); + + if (installedIndex === -1) { + installedIndex = 0; + } + + if (installedPreviouslyIndex === -1) { + installedPreviouslyIndex = items.length; + } else if (installedPreviouslyIndex === installedIndex && items.length) { + installedPreviouslyIndex += 1; + } + + const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); + + if (!appliedUpdates.length) { + return null; + } + + const appliedChanges: Update['changes'] = { new: [], fixed: [] }; + + appliedUpdates.forEach((u: Update) => { + if (u.changes) { + appliedChanges.new.push(...u.changes.new); + appliedChanges.fixed.push(...u.changes.fixed); + } + }); + + const mergedUpdate: Update = Object.assign({}, appliedUpdates[0], { + changes: appliedChanges, + }); + + if (!appliedChanges.new.length && !appliedChanges.fixed.length) { + mergedUpdate.changes = null; + } + + return mergedUpdate; +} + +interface AppUpdatedModalContentProps { + onModalClose: () => void; +} + +function AppUpdatedModalContent(props: AppUpdatedModalContentProps) { + const dispatch = useDispatch(); + const { version, prevVersion } = useSelector((state: AppState) => state.app); + const { isPopulated, error, items } = useSelector( + (state: AppState) => state.system.updates + ); + const previousVersion = usePrevious(version); + + const { onModalClose } = props; + + const update = mergeUpdates(items, version, prevVersion); + + const handleSeeChangesPress = useCallback(() => { + window.location.href = `${window.Radarr.urlBase}/system/updates`; + }, []); + + useEffect(() => { + dispatch(fetchUpdates()); + }, [dispatch]); + + useEffect(() => { + if (version !== previousVersion) { + dispatch(fetchUpdates()); + } + }, [version, previousVersion, dispatch]); + + return ( + + {translate('AppUpdated')} + + +
+ +
+ + {isPopulated && !error && !!update ? ( +
+ {update.changes ? ( +
+ {translate('MaintenanceRelease')} +
+ ) : null} + + {update.changes ? ( +
+
{translate('WhatsNew')}
+ + + + +
+ ) : null} +
+ ) : null} + + {!isPopulated && !error ? : null} +
+ + + + + + +
+ ); +} + +export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js deleted file mode 100644 index 6f81170b27..0000000000 --- a/frontend/src/App/AppUpdatedModalContentConnector.js +++ /dev/null @@ -1,78 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchUpdates } from 'Store/Actions/systemActions'; -import AppUpdatedModalContent from './AppUpdatedModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.version, - (state) => state.app.prevVersion, - (state) => state.system.updates, - (version, prevVersion, updates) => { - const { - isPopulated, - error, - items - } = updates; - - return { - version, - prevVersion, - isPopulated, - error, - items - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchFetchUpdates() { - dispatch(fetchUpdates()); - }, - - onSeeChangesPress() { - window.location = `${window.Radarr.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/ColorImpairedContext.js b/frontend/src/App/ColorImpairedContext.ts similarity index 100% rename from frontend/src/App/ColorImpairedContext.js rename to frontend/src/App/ColorImpairedContext.ts diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.tsx similarity index 54% rename from frontend/src/App/ConnectionLostModal.js rename to frontend/src/App/ConnectionLostModal.tsx index 5c08f491f4..f08f2c0e20 100644 --- a/frontend/src/App/ConnectionLostModal.js +++ b/frontend/src/App/ConnectionLostModal.tsx @@ -1,5 +1,4 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; import Button from 'Components/Link/Button'; import Modal from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; @@ -10,36 +9,31 @@ import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './ConnectionLostModal.css'; -function ConnectionLostModal(props) { - const { - isOpen, - onModalClose - } = props; +interface ConnectionLostModalProps { + isOpen: boolean; +} + +function ConnectionLostModal(props: ConnectionLostModalProps) { + const { isOpen } = props; + + const handleModalClose = useCallback(() => { + location.reload(); + }, []); return ( - - - - {translate('ConnectionLost')} - + + + {translate('ConnectionLost')} -
- {translate('ConnectionLostToBackend')} -
+
{translate('ConnectionLostToBackend')}
{translate('ConnectionLostReconnect')}
- @@ -48,9 +42,4 @@ function ConnectionLostModal(props) { ); } -ConnectionLostModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - export default ConnectionLostModal; diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js deleted file mode 100644 index 8ab8e3cd07..0000000000 --- a/frontend/src/App/ConnectionLostModalConnector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import ConnectionLostModal from './ConnectionLostModal'; - -function createMapDispatchToProps(dispatch, props) { - return { - onModalClose() { - location.reload(); - } - }; -} - -export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal); diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx index 66be388ce0..eca22c6c78 100644 --- a/frontend/src/App/SelectContext.tsx +++ b/frontend/src/App/SelectContext.tsx @@ -9,13 +9,13 @@ export type SelectContextAction = | { type: 'unselectAll' } | { type: 'toggleSelected'; - id: number; - isSelected: boolean; + id: number | string; + isSelected: boolean | null; shiftKey: boolean; } | { type: 'removeItem'; - id: number; + id: number | string; } | { type: 'updateItems'; diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts index f89eb25f78..34b5af597e 100644 --- a/frontend/src/App/State/AppSectionState.ts +++ b/frontend/src/App/State/AppSectionState.ts @@ -1,11 +1,16 @@ import Column from 'Components/Table/Column'; -import SortDirection from 'Helpers/Props/SortDirection'; -import { FilterBuilderProp, PropertyFilter } from './AppState'; +import { SortDirection } from 'Helpers/Props/sortDirections'; +import { ValidationFailure } from 'typings/pending'; +import { Filter, FilterBuilderProp } from './AppState'; export interface Error { - responseJSON: { - message: string; - }; + status?: number; + responseJSON: + | { + message: string | undefined; + } + | ValidationFailure[] + | undefined; } export interface AppSectionDeleteState { @@ -30,7 +35,7 @@ export interface TableAppSectionState { export interface AppSectionFilterState { selectedFilterKey: string; - filters: PropertyFilter[]; + filters: Filter[]; filterBuilderProps: FilterBuilderProp[]; } @@ -38,9 +43,15 @@ export interface AppSectionSchemaState { isSchemaFetching: boolean; isSchemaPopulated: boolean; schemaError: Error; - schema: { - items: T[]; - }; + schema: T[]; + selectedSchema?: T; +} + +export interface AppSectionItemSchemaState { + isSchemaFetching: boolean; + isSchemaPopulated: boolean; + schemaError: Error; + schema: T; } export interface AppSectionItemState { @@ -51,6 +62,17 @@ export interface AppSectionItemState { item: T; } +export interface AppSectionProviderState + extends AppSectionDeleteState, + AppSectionSaveState { + isFetching: boolean; + isPopulated: boolean; + isTesting?: 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 6d60772e0f..d5e16cdb9e 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,18 +1,30 @@ +import { Error } from './AppSectionState'; import BlocklistAppState from './BlocklistAppState'; import CalendarAppState from './CalendarAppState'; +import CaptchaAppState from './CaptchaAppState'; import CommandAppState from './CommandAppState'; -import HistoryAppState from './HistoryAppState'; +import CustomFiltersAppState from './CustomFiltersAppState'; +import ExtraFilesAppState from './ExtraFilesAppState'; +import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState'; import InteractiveImportAppState from './InteractiveImportAppState'; +import MessagesAppState from './MessagesAppState'; +import MovieBlocklistAppState from './MovieBlocklistAppState'; import MovieCollectionAppState from './MovieCollectionAppState'; import MovieCreditAppState from './MovieCreditAppState'; import MovieFilesAppState from './MovieFilesAppState'; import MoviesAppState, { MovieIndexAppState } from './MoviesAppState'; +import OAuthAppState from './OAuthAppState'; +import OrganizePreviewAppState from './OrganizePreviewAppState'; 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 SettingsAppState from './SettingsAppState'; import SystemAppState from './SystemAppState'; import TagsAppState from './TagsAppState'; +import WantedAppState from './WantedAppState'; interface FilterBuilderPropOption { id: string; @@ -35,46 +47,67 @@ export interface PropertyFilter { export interface Filter { key: string; - label: string; - filers: PropertyFilter[]; + label: string | (() => string); + filters: PropertyFilter[]; } export interface CustomFilter { id: number; type: string; label: string; - filers: PropertyFilter[]; + filters: PropertyFilter[]; } export interface AppSectionState { + isUpdated: boolean; isConnected: boolean; + isDisconnected: boolean; isReconnecting: boolean; + isSidebarVisible: boolean; version: string; + prevVersion?: string; dimensions: { isSmallScreen: boolean; + isLargeScreen: boolean; width: number; height: number; }; + translations: { + error?: Error; + isPopulated: boolean; + }; + messages: MessagesAppState; } interface AppState { app: AppSectionState; blocklist: BlocklistAppState; calendar: CalendarAppState; + captcha: CaptchaAppState; commands: CommandAppState; + customFilters: CustomFiltersAppState; + extraFiles: ExtraFilesAppState; history: HistoryAppState; interactiveImport: InteractiveImportAppState; + movieBlocklist: MovieBlocklistAppState; movieCollections: MovieCollectionAppState; movieCredits: MovieCreditAppState; movieFiles: MovieFilesAppState; + movieHistory: MovieHistoryAppState; movieIndex: MovieIndexAppState; movies: MoviesAppState; + oAuth: OAuthAppState; + organizePreview: OrganizePreviewAppState; parse: ParseAppState; + paths: PathsAppState; + providerOptions: ProviderOptionsAppState; queue: QueueAppState; + releases: ReleasesAppState; rootFolders: RootFolderAppState; settings: SettingsAppState; system: SystemAppState; tags: TagsAppState; + wanted: WantedAppState; } export default AppState; diff --git a/frontend/src/App/State/CalendarAppState.ts b/frontend/src/App/State/CalendarAppState.ts index d13e7af6eb..0fcf1692da 100644 --- a/frontend/src/App/State/CalendarAppState.ts +++ b/frontend/src/App/State/CalendarAppState.ts @@ -1,10 +1,29 @@ +import moment from 'moment'; import AppSectionState, { AppSectionFilterState, } from 'App/State/AppSectionState'; -import Movie from 'Movie/Movie'; +import { CalendarView } from 'Calendar/calendarViews'; +import { CalendarItem } from 'typings/Calendar'; + +interface CalendarOptions { + showMovieInformation: boolean; + showCinemaRelease: boolean; + showDigitalRelease: boolean; + showPhysicalRelease: boolean; + showCutoffUnmetIcon: boolean; + fullColorEvents: boolean; +} interface CalendarAppState - extends AppSectionState, - AppSectionFilterState {} + extends AppSectionState, + AppSectionFilterState { + searchMissingCommandId: number | null; + start: moment.Moment; + end: moment.Moment; + dates: string[]; + time: string; + view: CalendarView; + options: CalendarOptions; +} export default CalendarAppState; diff --git a/frontend/src/App/State/CaptchaAppState.ts b/frontend/src/App/State/CaptchaAppState.ts new file mode 100644 index 0000000000..7252937eb5 --- /dev/null +++ b/frontend/src/App/State/CaptchaAppState.ts @@ -0,0 +1,11 @@ +interface CaptchaAppState { + refreshing: false; + token: string; + siteKey: unknown; + secretToken: unknown; + ray: unknown; + stoken: unknown; + responseUrl: unknown; +} + +export default CaptchaAppState; diff --git a/frontend/src/App/State/ExtraFilesAppState.ts b/frontend/src/App/State/ExtraFilesAppState.ts new file mode 100644 index 0000000000..ef1aff9cd9 --- /dev/null +++ b/frontend/src/App/State/ExtraFilesAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import { ExtraFile } from 'MovieFile/ExtraFile'; + +type ExtraFilesAppState = AppSectionState; + +export default ExtraFilesAppState; diff --git a/frontend/src/App/State/HistoryAppState.ts b/frontend/src/App/State/HistoryAppState.ts index e368ff86ee..fd2bf01062 100644 --- a/frontend/src/App/State/HistoryAppState.ts +++ b/frontend/src/App/State/HistoryAppState.ts @@ -1,10 +1,16 @@ import AppSectionState, { AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState, } from 'App/State/AppSectionState'; import History from 'typings/History'; +export type MovieHistoryAppState = AppSectionState; + interface HistoryAppState extends AppSectionState, - AppSectionFilterState {} + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState {} export default HistoryAppState; diff --git a/frontend/src/App/State/InteractiveImportAppState.ts b/frontend/src/App/State/InteractiveImportAppState.ts index cf86f620dc..84fd9f4c14 100644 --- a/frontend/src/App/State/InteractiveImportAppState.ts +++ b/frontend/src/App/State/InteractiveImportAppState.ts @@ -1,11 +1,20 @@ import AppSectionState from 'App/State/AppSectionState'; -import RecentFolder from 'InteractiveImport/Folder/RecentFolder'; import ImportMode from 'InteractiveImport/ImportMode'; import InteractiveImport from 'InteractiveImport/InteractiveImport'; +interface FavoriteFolder { + folder: string; +} + +interface RecentFolder { + folder: string; + lastUsed: string; +} + interface InteractiveImportAppState extends AppSectionState { originalItems: InteractiveImport[]; importMode: ImportMode; + favoriteFolders: FavoriteFolder[]; recentFolders: RecentFolder[]; } diff --git a/frontend/src/App/State/MessagesAppState.ts b/frontend/src/App/State/MessagesAppState.ts new file mode 100644 index 0000000000..9f258ba4b7 --- /dev/null +++ b/frontend/src/App/State/MessagesAppState.ts @@ -0,0 +1,15 @@ +import ModelBase from 'App/ModelBase'; +import AppSectionState from 'App/State/AppSectionState'; + +export type MessageType = 'error' | 'info' | 'success' | 'warning'; + +export interface Message extends ModelBase { + hideAfter: number; + message: string; + name: string; + type: MessageType; +} + +type MessagesAppState = AppSectionState; + +export default MessagesAppState; diff --git a/frontend/src/App/State/MetadataAppState.ts b/frontend/src/App/State/MetadataAppState.ts new file mode 100644 index 0000000000..495f109d8b --- /dev/null +++ b/frontend/src/App/State/MetadataAppState.ts @@ -0,0 +1,6 @@ +import { AppSectionProviderState } from 'App/State/AppSectionState'; +import Metadata from 'typings/Metadata'; + +type MetadataAppState = AppSectionProviderState; + +export default MetadataAppState; diff --git a/frontend/src/App/State/MovieBlocklistAppState.ts b/frontend/src/App/State/MovieBlocklistAppState.ts new file mode 100644 index 0000000000..1b3d25ab03 --- /dev/null +++ b/frontend/src/App/State/MovieBlocklistAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Blocklist from 'typings/Blocklist'; + +type MovieBlocklistAppState = AppSectionState; + +export default MovieBlocklistAppState; diff --git a/frontend/src/App/State/MovieCollectionAppState.ts b/frontend/src/App/State/MovieCollectionAppState.ts index 06d143674e..6e32527850 100644 --- a/frontend/src/App/State/MovieCollectionAppState.ts +++ b/frontend/src/App/State/MovieCollectionAppState.ts @@ -1,8 +1,20 @@ -import AppSectionState from 'App/State/AppSectionState'; +import AppSectionState, { + AppSectionFilterState, + AppSectionSaveState, + Error, +} from 'App/State/AppSectionState'; import MovieCollection from 'typings/MovieCollection'; -interface MovieCollectionAppState extends AppSectionState { +interface MovieCollectionAppState + extends AppSectionState, + AppSectionFilterState, + AppSectionSaveState { itemMap: Record; + + isAdding: boolean; + addError: Error; + + pendingChanges: Partial; } export default MovieCollectionAppState; diff --git a/frontend/src/App/State/MovieCreditAppState.ts b/frontend/src/App/State/MovieCreditAppState.ts index 424de2ca46..31c418616c 100644 --- a/frontend/src/App/State/MovieCreditAppState.ts +++ b/frontend/src/App/State/MovieCreditAppState.ts @@ -1,6 +1,6 @@ import AppSectionState from 'App/State/AppSectionState'; import MovieCredit from 'typings/MovieCredit'; -interface MovieCreditAppState extends AppSectionState {} +type MovieCreditAppState = AppSectionState; export default MovieCreditAppState; diff --git a/frontend/src/App/State/MoviesAppState.ts b/frontend/src/App/State/MoviesAppState.ts index 20b706c249..d9b78d8cab 100644 --- a/frontend/src/App/State/MoviesAppState.ts +++ b/frontend/src/App/State/MoviesAppState.ts @@ -3,7 +3,7 @@ import AppSectionState, { AppSectionSaveState, } from 'App/State/AppSectionState'; import Column from 'Components/Table/Column'; -import SortDirection from 'Helpers/Props/SortDirection'; +import { SortDirection } from 'Helpers/Props/sortDirections'; import Movie from 'Movie/Movie'; import { Filter, FilterBuilderProp } from './AppState'; @@ -64,6 +64,8 @@ interface MoviesAppState deleteOptions: { addImportExclusion: boolean; }; + + pendingChanges: Partial; } export default MoviesAppState; diff --git a/frontend/src/App/State/OAuthAppState.ts b/frontend/src/App/State/OAuthAppState.ts new file mode 100644 index 0000000000..619767929c --- /dev/null +++ b/frontend/src/App/State/OAuthAppState.ts @@ -0,0 +1,9 @@ +import { Error } from './AppSectionState'; + +interface OAuthAppState { + authorizing: boolean; + result: Record | null; + error: Error; +} + +export default OAuthAppState; diff --git a/frontend/src/App/State/OrganizePreviewAppState.ts b/frontend/src/App/State/OrganizePreviewAppState.ts new file mode 100644 index 0000000000..b8b907852b --- /dev/null +++ b/frontend/src/App/State/OrganizePreviewAppState.ts @@ -0,0 +1,13 @@ +import ModelBase from 'App/ModelBase'; +import AppSectionState from 'App/State/AppSectionState'; + +export interface OrganizePreviewModel extends ModelBase { + movieId: number; + movieFileId: number; + existingPath: string; + newPath: string; +} + +type OrganizePreviewAppState = AppSectionState; + +export default OrganizePreviewAppState; diff --git a/frontend/src/App/State/PathsAppState.ts b/frontend/src/App/State/PathsAppState.ts new file mode 100644 index 0000000000..068a48dc09 --- /dev/null +++ b/frontend/src/App/State/PathsAppState.ts @@ -0,0 +1,29 @@ +interface BasePath { + name: string; + path: string; + size: number; + lastModified: string; +} + +interface File extends BasePath { + type: 'file'; +} + +interface Folder extends BasePath { + type: 'folder'; +} + +export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent'; +export type Path = File | Folder; + +interface PathsAppState { + currentPath: string; + isFetching: boolean; + isPopulated: boolean; + error: Error; + directories: Folder[]; + files: File[]; + parent: string | null; +} + +export default PathsAppState; diff --git a/frontend/src/App/State/ProviderOptionsAppState.ts b/frontend/src/App/State/ProviderOptionsAppState.ts new file mode 100644 index 0000000000..7fb5df02b8 --- /dev/null +++ b/frontend/src/App/State/ProviderOptionsAppState.ts @@ -0,0 +1,22 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Field, { FieldSelectOption } from 'typings/Field'; + +export interface ProviderOptions { + fields?: Field[]; +} + +interface ProviderOptionsDevice { + id: string; + name: string; +} + +interface ProviderOptionsAppState { + devices: AppSectionState; + servers: AppSectionState>; + newznabCategories: AppSectionState>; + getProfiles: AppSectionState>; + getTags: AppSectionState>; + getRootFolders: AppSectionState>; +} + +export default ProviderOptionsAppState; diff --git a/frontend/src/App/State/ReleasesAppState.ts b/frontend/src/App/State/ReleasesAppState.ts new file mode 100644 index 0000000000..350f6eac8e --- /dev/null +++ b/frontend/src/App/State/ReleasesAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; +import Release from 'typings/Release'; + +interface ReleasesAppState + extends AppSectionState, + AppSectionFilterState {} + +export default ReleasesAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 40dd2656d1..a970afb8be 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,12 +1,15 @@ import AppSectionState, { AppSectionDeleteState, + AppSectionItemSchemaState, AppSectionItemState, AppSectionSaveState, AppSectionSchemaState, PagedAppSectionState, } from 'App/State/AppSectionState'; import Language from 'Language/Language'; +import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging'; import CustomFormat from 'typings/CustomFormat'; +import DelayProfile from 'typings/DelayProfile'; import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import ImportListExclusion from 'typings/ImportListExclusion'; @@ -16,10 +19,33 @@ import IndexerFlag from 'typings/IndexerFlag'; import Notification from 'typings/Notification'; import QualityProfile from 'typings/QualityProfile'; import General from 'typings/Settings/General'; +import IndexerOptions from 'typings/Settings/IndexerOptions'; +import MediaManagement from 'typings/Settings/MediaManagement'; import NamingConfig from 'typings/Settings/NamingConfig'; import NamingExample from 'typings/Settings/NamingExample'; import ReleaseProfile from 'typings/Settings/ReleaseProfile'; import UiSettings from 'typings/Settings/UiSettings'; +import MetadataAppState from './MetadataAppState'; + +type Presets = T & { + presets: T[]; +}; + +export interface AutoTaggingAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface AutoTaggingSpecificationAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState, + AppSectionSchemaState {} + +export interface DelayProfileAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} export interface DownloadClientAppState extends AppSectionState, @@ -32,22 +58,33 @@ export interface GeneralAppState extends AppSectionItemState, AppSectionSaveState {} +export interface MediaManagementAppState + extends AppSectionItemState, + AppSectionSaveState {} + export interface NamingAppState extends AppSectionItemState, AppSectionSaveState {} -export interface NamingExamplesAppState - extends AppSectionItemState {} +export type NamingExamplesAppState = AppSectionItemState; export interface ImportListAppState extends AppSectionState, AppSectionDeleteState, + AppSectionSaveState, + AppSectionSchemaState> { + isTestingAll: boolean; +} + +export interface IndexerOptionsAppState + extends AppSectionItemState, AppSectionSaveState {} export interface IndexerAppState extends AppSectionState, AppSectionDeleteState, - AppSectionSaveState { + AppSectionSaveState, + AppSectionSchemaState> { isTestingAll: boolean; } @@ -57,7 +94,7 @@ export interface NotificationAppState export interface QualityProfilesAppState extends AppSectionState, - AppSectionSchemaState {} + AppSectionItemSchemaState {} export interface ReleaseProfilesAppState extends AppSectionState, @@ -88,15 +125,21 @@ export type UiSettingsAppState = AppSectionItemState; interface SettingsAppState { advancedSettings: boolean; + autoTaggings: AutoTaggingAppState; + autoTaggingSpecifications: AutoTaggingSpecificationAppState; customFormats: CustomFormatAppState; + delayProfiles: DelayProfileAppState; downloadClients: DownloadClientAppState; general: GeneralAppState; importListExclusions: ImportListExclusionsSettingsAppState; importListOptions: ImportListOptionsSettingsAppState; importLists: ImportListAppState; indexerFlags: IndexerFlagSettingsAppState; + indexerOptions: IndexerOptionsAppState; indexers: IndexerAppState; languages: LanguageSettingsAppState; + mediaManagement: MediaManagementAppState; + metadata: MetadataAppState; naming: NamingAppState; namingExamples: NamingExamplesAppState; notifications: NotificationAppState; diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts index 7bfc6d5fb1..9737ac71c0 100644 --- a/frontend/src/App/State/SystemAppState.ts +++ b/frontend/src/App/State/SystemAppState.ts @@ -1,19 +1,26 @@ import DiskSpace from 'typings/DiskSpace'; import Health from 'typings/Health'; +import LogFile from 'typings/LogFile'; import SystemStatus from 'typings/SystemStatus'; import Task from 'typings/Task'; +import Update from 'typings/Update'; import AppSectionState, { AppSectionItemState } from './AppSectionState'; export type DiskSpaceAppState = AppSectionState; export type HealthAppState = AppSectionState; export type SystemStatusAppState = AppSectionItemState; export type TaskAppState = AppSectionState; +export type LogFilesAppState = AppSectionState; +export type UpdateAppState = AppSectionState; interface SystemAppState { diskSpace: DiskSpaceAppState; health: HealthAppState; + logFiles: LogFilesAppState; status: SystemStatusAppState; tasks: TaskAppState; + updateLogFiles: LogFilesAppState; + updates: UpdateAppState; } export default SystemAppState; diff --git a/frontend/src/App/State/TagsAppState.ts b/frontend/src/App/State/TagsAppState.ts index f22873606b..9b66303316 100644 --- a/frontend/src/App/State/TagsAppState.ts +++ b/frontend/src/App/State/TagsAppState.ts @@ -17,7 +17,7 @@ export interface TagDetail extends ModelBase { indexerIds: number[]; movieIds: number[]; notificationIds: number[]; - restrictionIds: number[]; + releaseProfileIds: number[]; } export interface TagDetailAppState diff --git a/frontend/src/App/State/WantedAppState.ts b/frontend/src/App/State/WantedAppState.ts new file mode 100644 index 0000000000..ef63a8d38f --- /dev/null +++ b/frontend/src/App/State/WantedAppState.ts @@ -0,0 +1,29 @@ +import AppSectionState, { + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState, +} from 'App/State/AppSectionState'; +import Movie from 'Movie/Movie'; + +interface WantedMovie extends Movie { + isSaving?: boolean; +} + +interface WantedCutoffUnmetAppState + extends AppSectionState, + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState {} + +interface WantedMissingAppState + extends AppSectionState, + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState {} + +interface WantedAppState { + cutoffUnmet: WantedCutoffUnmetAppState; + missing: WantedMissingAppState; +} + +export default WantedAppState; diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js deleted file mode 100644 index 48526407e4..0000000000 --- a/frontend/src/Calendar/Agenda/Agenda.js +++ /dev/null @@ -1,69 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React from 'react'; -import AgendaEventConnector from './AgendaEventConnector'; -import styles from './Agenda.css'; - -function Agenda(props) { - const { - items, - start, - end - } = props; - - const startDateParsed = Date.parse(start); - const endDateParsed = Date.parse(end); - - items.forEach((item) => { - const cinemaDateParsed = Date.parse(item.inCinemas); - const digitalDateParsed = Date.parse(item.digitalRelease); - const physicalDateParsed = Date.parse(item.physicalRelease); - const dates = []; - - if (cinemaDateParsed > 0 && cinemaDateParsed >= startDateParsed && cinemaDateParsed <= endDateParsed) { - dates.push(cinemaDateParsed); - } - if (digitalDateParsed > 0 && digitalDateParsed >= startDateParsed && digitalDateParsed <= endDateParsed) { - dates.push(digitalDateParsed); - } - if (physicalDateParsed > 0 && physicalDateParsed >= startDateParsed && physicalDateParsed <= endDateParsed) { - dates.push(physicalDateParsed); - } - - item.sortDate = Math.min(...dates); - item.cinemaDateParsed = cinemaDateParsed; - item.digitalDateParsed = digitalDateParsed; - item.physicalDateParsed = physicalDateParsed; - }); - - items.sort((a, b) => ((a.sortDate > b.sortDate) ? 1 : -1)); - - return ( -
- { - items.map((item, index) => { - const momentDate = moment(item.sortDate); - const showDate = index === 0 || - !moment(items[index - 1].sortDate).isSame(momentDate, 'day'); - - return ( - - ); - }) - } -
- ); -} - -Agenda.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - start: PropTypes.string.isRequired, - end: PropTypes.string.isRequired -}; - -export default Agenda; diff --git a/frontend/src/Calendar/Agenda/Agenda.tsx b/frontend/src/Calendar/Agenda/Agenda.tsx new file mode 100644 index 0000000000..a4856d2927 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.tsx @@ -0,0 +1,81 @@ +import moment from 'moment'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Movie from 'Movie/Movie'; +import AgendaEvent from './AgendaEvent'; +import styles from './Agenda.css'; + +interface AgendaMovie extends Movie { + sortDate: moment.Moment; +} + +function Agenda() { + const { start, end, items } = useSelector( + (state: AppState) => state.calendar + ); + + const events = useMemo(() => { + const result = items.map((item): AgendaMovie => { + const { inCinemas, digitalRelease, physicalRelease } = item; + + const dates = []; + + if (inCinemas) { + const inCinemasMoment = moment(inCinemas); + + if (inCinemasMoment.isAfter(start) && inCinemasMoment.isBefore(end)) { + dates.push(inCinemasMoment); + } + } + + if (digitalRelease) { + const digitalReleaseMoment = moment(digitalRelease); + + if ( + digitalReleaseMoment.isAfter(start) && + digitalReleaseMoment.isBefore(end) + ) { + dates.push(digitalReleaseMoment); + } + } + + if (physicalRelease) { + const physicalReleaseMoment = moment(physicalRelease); + + if ( + physicalReleaseMoment.isAfter(start) && + physicalReleaseMoment.isBefore(end) + ) { + dates.push(physicalReleaseMoment); + } + } + + const sortDate = moment.min(...dates); + + return { + ...item, + sortDate, + }; + }); + + result.sort((a, b) => (a.sortDate > b.sortDate ? 1 : -1)); + + return result; + }, [items, start, end]); + + return ( +
+ {events.map((item, index) => { + const momentDate = moment(item.sortDate); + const showDate = + index === 0 || + !moment(events[index - 1].sortDate).isSame(momentDate, 'day'); + + return ; + })} +
+ ); +} + +export default Agenda; diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js deleted file mode 100644 index b6f2388736..0000000000 --- a/frontend/src/Calendar/Agenda/AgendaConnector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import Agenda from './Agenda'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (calendar) => { - return calendar; - } - ); -} - -export default connect(createMapStateToProps)(Agenda); diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css index 28de0b1ca5..8d954fa715 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.css +++ b/frontend/src/Calendar/Agenda/AgendaEvent.css @@ -53,6 +53,13 @@ margin-right: 10px; } +.releaseIcon { + margin-right: 20px; + width: 25px; + cursor: default; + pointer-events: all; +} + .statusIcon { margin-left: 3px; cursor: default; @@ -107,8 +114,3 @@ flex: 0 0 100%; } } - -.releaseIcon { - margin-right: 20px; - width: 25px; -} diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js deleted file mode 100644 index 3d31371c9d..0000000000 --- a/frontend/src/Calendar/Agenda/AgendaEvent.js +++ /dev/null @@ -1,190 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons, kinds } from 'Helpers/Props'; -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 { - movieFile, - title, - titleSlug, - genres, - isAvailable, - inCinemas, - digitalRelease, - physicalRelease, - monitored, - hasFile, - grabbed, - queueItem, - showDate, - showMovieInformation, - showCutoffUnmetIcon, - longDateFormat, - colorImpairedMode, - cinemaDateParsed, - digitalDateParsed, - physicalDateParsed, - sortDate - } = this.props; - - let startTime = null; - let releaseIcon = null; - - if (physicalDateParsed === sortDate) { - startTime = physicalRelease; - releaseIcon = icons.DISC; - } - - if (digitalDateParsed === sortDate) { - startTime = digitalRelease; - releaseIcon = icons.MOVIE_FILE; - } - - if (cinemaDateParsed === sortDate) { - startTime = inCinemas; - releaseIcon = icons.IN_CINEMAS; - } - - startTime = moment(startTime); - const downloading = !!(queueItem || grabbed); - const isMonitored = monitored; - const statusStyle = getStatusStyle(hasFile, downloading, isMonitored, isAvailable); - const joinedGenres = genres.slice(0, 2).join(', '); - const link = `/movie/${titleSlug}`; - - return ( -
- - -
-
- {showDate ? startTime.format(longDateFormat) : null} -
- -
- -
- -
-
- {title} -
- - { - showMovieInformation && -
- {joinedGenres} -
- } - - { - !!queueItem && - - - - } - - { - !queueItem && grabbed && - - } - - { - showCutoffUnmetIcon && !!movieFile && movieFile.qualityCutoffNotMet && - - } -
-
-
- ); - } -} - -AgendaEvent.propTypes = { - id: PropTypes.number.isRequired, - movieFile: PropTypes.object, - title: PropTypes.string.isRequired, - titleSlug: PropTypes.string.isRequired, - genres: PropTypes.arrayOf(PropTypes.string).isRequired, - isAvailable: PropTypes.bool.isRequired, - inCinemas: PropTypes.string, - digitalRelease: PropTypes.string, - physicalRelease: PropTypes.string, - monitored: PropTypes.bool.isRequired, - hasFile: PropTypes.bool.isRequired, - grabbed: PropTypes.bool, - queueItem: PropTypes.object, - showDate: PropTypes.bool.isRequired, - showMovieInformation: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - colorImpairedMode: PropTypes.bool.isRequired, - cinemaDateParsed: PropTypes.number, - digitalDateParsed: PropTypes.number, - physicalDateParsed: PropTypes.number, - sortDate: PropTypes.number -}; - -AgendaEvent.defaultProps = { - genres: [] -}; - -export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx new file mode 100644 index 0000000000..a312f1017c --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -0,0 +1,160 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useMemo } 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 { icons, kinds } from 'Helpers/Props'; +import useMovieFile from 'MovieFile/useMovieFile'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import styles from './AgendaEvent.css'; + +interface AgendaEventProps { + id: number; + movieFileId: number; + title: string; + titleSlug: string; + genres: string[]; + inCinemas?: string; + digitalRelease?: string; + physicalRelease?: string; + sortDate: moment.Moment; + isAvailable: boolean; + monitored: boolean; + hasFile: boolean; + grabbed?: boolean; + showDate: boolean; +} + +function AgendaEvent({ + id, + movieFileId, + title, + titleSlug, + genres = [], + inCinemas, + digitalRelease, + physicalRelease, + sortDate, + isAvailable, + monitored: isMonitored, + hasFile, + grabbed, + showDate, +}: AgendaEventProps) { + const movieFile = useMovieFile(movieFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + const { longDateFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { showMovieInformation, showCutoffUnmetIcon } = useSelector( + (state: AppState) => state.calendar.options + ); + + const { eventDate, eventTitle, releaseIcon } = useMemo(() => { + if (physicalRelease && sortDate.isSame(moment(physicalRelease), 'day')) { + return { + eventDate: physicalRelease, + eventTitle: translate('PhysicalRelease'), + releaseIcon: icons.DISC, + }; + } + + if (digitalRelease && sortDate.isSame(moment(digitalRelease), 'day')) { + return { + eventDate: digitalRelease, + eventTitle: translate('DigitalRelease'), + releaseIcon: icons.MOVIE_FILE, + }; + } + + if (inCinemas && sortDate.isSame(moment(inCinemas), 'day')) { + return { + eventDate: inCinemas, + eventTitle: translate('InCinemas'), + releaseIcon: icons.IN_CINEMAS, + }; + } + + return { + eventDate: null, + eventTitle: null, + releaseIcon: null, + }; + }, [inCinemas, digitalRelease, physicalRelease, sortDate]); + + const downloading = !!(queueItem || grabbed); + const statusStyle = getStatusStyle( + hasFile, + downloading, + isMonitored, + isAvailable + ); + const joinedGenres = genres.slice(0, 2).join(', '); + const link = `/movie/${titleSlug}`; + + return ( +
+ + +
+
+ {showDate && eventDate + ? moment(eventDate).format(longDateFormat) + : null} +
+ +
+ {releaseIcon ? ( + + ) : null} +
+ +
+
{title}
+ + {showMovieInformation ? ( +
{joinedGenres}
+ ) : null} + + {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && movieFile && movieFile.qualityCutoffNotMet ? ( + + ) : null} +
+
+
+ ); +} + +export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js deleted file mode 100644 index ea653364ae..0000000000 --- a/frontend/src/Calendar/Agenda/AgendaEventConnector.js +++ /dev/null @@ -1,30 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import AgendaEvent from './AgendaEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createMovieSelector(), - createMovieFileSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, movie, movieFile, queueItem, uiSettings) => { - return { - movie, - movieFile, - queueItem, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - longDateFormat: uiSettings.longDateFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(AgendaEvent); diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js deleted file mode 100644 index 0a2fd671d2..0000000000 --- a/frontend/src/Calendar/Calendar.js +++ /dev/null @@ -1,67 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import { kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import AgendaConnector from './Agenda/AgendaConnector'; -import * as calendarViews from './calendarViews'; -import CalendarDaysConnector from './Day/CalendarDaysConnector'; -import DaysOfWeekConnector from './Day/DaysOfWeekConnector'; -import CalendarHeaderConnector from './Header/CalendarHeaderConnector'; -import styles from './Calendar.css'; - -class Calendar extends Component { - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - view - } = this.props; - - return ( -
- { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && - {translate('CalendarLoadError')} - } - - { - !error && isPopulated && view === calendarViews.AGENDA && -
- - -
- } - - { - !error && isPopulated && view !== calendarViews.AGENDA && -
- - - -
- } -
- ); - } -} - -Calendar.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - view: PropTypes.string.isRequired -}; - -export default Calendar; diff --git a/frontend/src/Calendar/Calendar.tsx b/frontend/src/Calendar/Calendar.tsx new file mode 100644 index 0000000000..dca63c9c07 --- /dev/null +++ b/frontend/src/Calendar/Calendar.tsx @@ -0,0 +1,164 @@ +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 useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds } from 'Helpers/Props'; +import Movie from 'Movie/Movie'; +import { + clearCalendar, + fetchCalendar, + gotoCalendarToday, +} from 'Store/Actions/calendarActions'; +import { + clearMovieFiles, + fetchMovieFiles, +} from 'Store/Actions/movieFileActions'; +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 isRefreshingMovie = useSelector( + createCommandExecutingSelector(commandNames.REFRESH_MOVIE) + ); + + const firstDayOfWeek = useSelector( + (state: AppState) => state.settings.ui.item.firstDayOfWeek + ); + + const wasRefreshingMovie = usePrevious(isRefreshingMovie); + 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(clearMovieFiles()); + 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, ['movieFileUpdated', 'movieFileDeleted']); + + 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 (wasRefreshingMovie && !isRefreshingMovie) { + dispatch(fetchCalendar({ time, view })); + } + }, [time, view, isRefreshingMovie, wasRefreshingMovie, dispatch]); + + useEffect(() => { + if (!previousItems || hasDifferentItems(items, previousItems)) { + const movieIds = selectUniqueIds(items, 'id'); + const movieFileIds = selectUniqueIds(items, 'movieFileId'); + + if (items.length) { + dispatch(fetchQueueDetails({ movieIds })); + } + + if (movieFileIds.length) { + dispatch(fetchMovieFiles({ movieFileIds })); + } + } + }, [items, previousItems, dispatch]); + + return ( +
+ {isFetching && !isPopulated ? : null} + + {!isFetching && error ? ( + {translate('CalendarLoadError')} + ) : null} + + {!error && isPopulated && view === 'agenda' ? ( +
+ + +
+ ) : null} + + {!error && isPopulated && view !== 'agenda' ? ( +
+ + + +
+ ) : null} +
+ ); +} + +export default Calendar; diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js deleted file mode 100644 index cf3722b244..0000000000 --- a/frontend/src/Calendar/CalendarConnector.js +++ /dev/null @@ -1,195 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import * as calendarActions from 'Store/Actions/calendarActions'; -import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions'; -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_MOVIE), - (calendar, firstDayOfWeek, isRefreshingMovie) => { - return { - ...calendar, - isRefreshingMovie, - firstDayOfWeek - }; - } - ); -} - -const mapDispatchToProps = { - ...calendarActions, - fetchMovieFiles, - clearMovieFiles, - 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, ['movieFileUpdated', 'movieFileDeleted']); - - if (useCurrentPage) { - fetchCalendar(); - } else { - gotoCalendarToday(); - } - - this.scheduleUpdate(); - } - - componentDidUpdate(prevProps) { - const { - items, - time, - view, - isRefreshingMovie, - firstDayOfWeek - } = this.props; - - if (hasDifferentItems(prevProps.items, items)) { - const movieFileIds = selectUniqueIds(items, 'movieFileId'); - - if (movieFileIds.length) { - this.props.fetchMovieFiles({ movieFileIds }); - } - - if (items.length) { - this.props.fetchQueueDetails(); - } - } - - if (prevProps.time !== time) { - this.scheduleUpdate(); - } - - if (prevProps.firstDayOfWeek !== firstDayOfWeek) { - this.props.fetchCalendar({ time, view }); - } - - if (prevProps.isRefreshingMovie && !isRefreshingMovie) { - this.props.fetchCalendar({ time, view }); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearCalendar(); - this.props.clearQueueDetails(); - this.props.clearMovieFiles(); - 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, - isRefreshingMovie: 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, - fetchMovieFiles: PropTypes.func.isRequired, - clearMovieFiles: PropTypes.func.isRequired, - fetchQueueDetails: PropTypes.func.isRequired, - clearQueueDetails: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector); diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js deleted file mode 100644 index 5a75e64cef..0000000000 --- a/frontend/src/Calendar/CalendarPage.js +++ /dev/null @@ -1,224 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -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 NoMovie from 'Movie/NoMovie'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -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 { - missingMovieIds, - onSearchMissingPress - } = this.props; - - onSearchMissingPress(missingMovieIds); - }; - - // - // Render - - render() { - const { - selectedFilterKey, - filters, - hasMovie, - movieError, - movieIsFetching, - movieIsPopulated, - missingMovieIds, - customFilters, - isRssSyncExecuting, - isSearchingForMissing, - useCurrentPage, - onRssSyncPress, - onFilterSelect - } = this.props; - - const { - isCalendarLinkModalOpen, - isOptionsModalOpen - } = this.state; - - const isMeasured = this.state.width > 0; - - return ( - - - - - - - - - - - - - - - - - - - - - { - movieIsFetching && !movieIsPopulated && - - } - - { - movieError && -
- {getErrorMessage(movieError, 'Failed to load movies from API')} -
- } - - { - !movieError && movieIsPopulated && hasMovie && - - { - isMeasured ? - : -
- } - - } - - { - !movieError && movieIsPopulated && !hasMovie && - - } - - { - hasMovie && !movieError && - - } - - - - - - - ); - } -} - -CalendarPage.propTypes = { - selectedFilterKey: PropTypes.string.isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - hasMovie: PropTypes.bool.isRequired, - movieError: PropTypes.object, - movieIsFetching: PropTypes.bool.isRequired, - movieIsPopulated: PropTypes.bool.isRequired, - missingMovieIds: PropTypes.arrayOf(PropTypes.number).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - isRssSyncExecuting: PropTypes.bool.isRequired, - isSearchingForMissing: PropTypes.bool.isRequired, - useCurrentPage: PropTypes.bool.isRequired, - onSearchMissingPress: PropTypes.func.isRequired, - onDaysCountChange: PropTypes.func.isRequired, - onRssSyncPress: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired -}; - -export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPage.tsx b/frontend/src/Calendar/CalendarPage.tsx new file mode 100644 index 0000000000..96e59cc444 --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.tsx @@ -0,0 +1,224 @@ +import moment from 'moment'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import 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 useMeasure from 'Helpers/Hooks/useMeasure'; +import { align, icons } from 'Helpers/Props'; +import NoMovie from 'Movie/NoMovie'; +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 createMovieCountSelector from 'Store/Selectors/createMovieCountSelector'; +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 createMissingMovieIdsSelector() { + 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, movies, queueDetails) => { + return movies.reduce((acc, movie) => { + const { inCinemas } = movie; + + if ( + !movie.movieFileId && + inCinemas && + moment(inCinemas).isAfter(start) && + moment(inCinemas).isBefore(end) && + isBefore(inCinemas) && + !queueDetails.some( + (details) => !!details.movie && details.movie.id === movie.id + ) + ) { + acc.push(movie.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 missingMovieIds = useSelector(createMissingMovieIdsSelector()); + const isSearchingForMissing = useSelector(createIsSearchingSelector()); + const isRssSyncExecuting = useSelector( + createCommandExecutingSelector(commandNames.RSS_SYNC) + ); + const customFilters = useSelector(createCustomFiltersSelector('calendar')); + const hasMovies = !!useSelector(createMovieCountSelector()); + + const [pageContentRef, { width }] = useMeasure(); + const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + + const isMeasured = width > 0; + const PageComponent = hasMovies ? Calendar : NoMovie; + + 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({ movieIds: missingMovieIds })); + }, [missingMovieIds, dispatch]); + + const handleFilterSelect = useCallback( + (key: string | number) => { + dispatch(setCalendarFilter({ selectedFilterKey: key })); + }, + [dispatch] + ); + + useEffect(() => { + if (width === 0) { + return; + } + + const dayCount = Math.max( + 3, + Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)) + ); + + dispatch(setCalendarDaysCount({ dayCount })); + }, [width, dispatch]); + + return ( + + + + + + + + + + + + + + + + + + + + + {isMeasured ? :
} + {hasMovies && } + + + + + + + ); +} + +export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js deleted file mode 100644 index fea28465e7..0000000000 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ /dev/null @@ -1,120 +0,0 @@ -import moment from 'moment'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withCurrentPage from 'Components/withCurrentPage'; -import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import createMovieCountSelector from 'Store/Selectors/createMovieCountSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import { isCommandExecuting } from 'Utilities/Command'; -import isBefore from 'Utilities/Date/isBefore'; -import CalendarPage from './CalendarPage'; - -function createMissingMovieIdsSelector() { - return createSelector( - (state) => state.calendar.start, - (state) => state.calendar.end, - (state) => state.calendar.items, - (state) => state.queue.details.items, - (start, end, movies, queueDetails) => { - return movies.reduce((acc, movie) => { - const inCinemas = movie.inCinemas; - - if ( - !movie.hasFile && - moment(inCinemas).isAfter(start) && - moment(inCinemas).isBefore(end) && - isBefore(movie.inCinemas) && - !queueDetails.some((details) => details.movieId === movie.id) - ) { - acc.push(movie.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'), - createMovieCountSelector(), - createUISettingsSelector(), - createMissingMovieIdsSelector(), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createIsSearchingSelector(), - ( - selectedFilterKey, - filters, - customFilters, - movieCount, - uiSettings, - missingMovieIds, - isRssSyncExecuting, - isSearchingForMissing - ) => { - return { - selectedFilterKey, - filters, - customFilters, - colorImpairedMode: uiSettings.enableColorImpairedMode, - hasMovie: !!movieCount.count, - movieError: movieCount.error, - movieIsFetching: movieCount.isFetching, - movieIsPopulated: movieCount.isPopulated, - missingMovieIds, - isRssSyncExecuting, - isSearchingForMissing - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - }, - - onSearchMissingPress(movieIds) { - dispatch(searchMissing({ movieIds })); - }, - - 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 36a2f02ccb..1f02df3459 100644 --- a/frontend/src/Calendar/Day/CalendarDay.tsx +++ b/frontend/src/Calendar/Day/CalendarDay.tsx @@ -1,23 +1,61 @@ import classNames from 'classnames'; import moment from 'moment'; import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import * as calendarViews from 'Calendar/calendarViews'; -import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; -import CalendarEvent from 'typings/CalendarEvent'; +import CalendarEvent from 'Calendar/Events/CalendarEvent'; +import { CalendarEvent as CalendarEventModel } from 'typings/Calendar'; import styles from './CalendarDay.css'; +function sort(items: CalendarEventModel[]) { + return items.sort((a, b) => { + const aDate = moment(a.inCinemas).unix(); + const bDate = moment(b.inCinemas).unix(); + + return aDate - bDate; + }); +} + +function createCalendarEventsConnector(date: string) { + return createSelector( + (state: AppState) => state.calendar.items, + (state: AppState) => state.calendar.options, + (items, options) => { + const { showCinemaRelease, showDigitalRelease, showPhysicalRelease } = + options; + const momentDate = moment(date); + + const filtered = items.filter( + ({ inCinemas, digitalRelease, physicalRelease }) => { + return ( + (showCinemaRelease && + inCinemas && + momentDate.isSame(moment(inCinemas), 'day')) || + (showDigitalRelease && + digitalRelease && + momentDate.isSame(moment(digitalRelease), 'day')) || + (showPhysicalRelease && + physicalRelease && + momentDate.isSame(moment(physicalRelease), 'day')) + ); + } + ); + + return sort(filtered); + } + ); +} + interface CalendarDayProps { date: string; - time: string; isTodaysDate: boolean; - events: CalendarEvent[]; - view: string; - onEventModalOpenToggle(...args: unknown[]): unknown; } -function CalendarDay(props: CalendarDayProps) { - const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } = - props; +function CalendarDay({ date, isTodaysDate }: CalendarDayProps) { + const { time, view } = useSelector((state: AppState) => state.calendar); + const events = useSelector(createCalendarEventsConnector(date)); const ref = React.useRef(null); @@ -50,13 +88,7 @@ function CalendarDay(props: CalendarDayProps) {
{events.map((event) => { return ( - + ); })}
diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js deleted file mode 100644 index 33fa1baa4a..0000000000 --- a/frontend/src/Calendar/Day/CalendarDayConnector.js +++ /dev/null @@ -1,67 +0,0 @@ -import _ from 'lodash'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import CalendarDay from './CalendarDay'; - -function sort(items) { - return _.sortBy(items, (item) => { - if (item.isGroup) { - return moment(item.events[0].inCinemas).unix(); - } - - return moment(item.inCinemas).unix(); - }); -} - -function createCalendarEventsConnector() { - return createSelector( - (state, { date }) => date, - (state) => state.calendar.items, - (date, items) => { - const filtered = _.filter(items, (item) => { - return (item.inCinemas && moment(date).isSame(moment(item.inCinemas), 'day')) || - (item.physicalRelease && moment(date).isSame(moment(item.physicalRelease), 'day')) || - (item.digitalRelease && moment(date).isSame(moment(item.digitalRelease), 'day')); - }); - - return sort(filtered); - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createCalendarEventsConnector(), - (calendar, events) => { - return { - time: calendar.time, - view: calendar.view, - events - }; - } - ); -} - -class CalendarDayConnector extends Component { - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarDayConnector.propTypes = { - date: PropTypes.string.isRequired -}; - -export default connect(createMapStateToProps)(CalendarDayConnector); diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js deleted file mode 100644 index f2bb4c8d45..0000000000 --- a/frontend/src/Calendar/Day/CalendarDays.js +++ /dev/null @@ -1,164 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import isToday from 'Utilities/Date/isToday'; -import CalendarDayConnector from './CalendarDayConnector'; -import styles from './CalendarDays.css'; - -class CalendarDays extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._touchStart = null; - - this.state = { - todaysDate: moment().startOf('day').toISOString(), - isEventModalOpen: false - }; - - this.updateTimeoutId = null; - } - - // Lifecycle - - componentDidMount() { - const view = this.props.view; - - if (view === calendarViews.MONTH) { - this.scheduleUpdate(); - } - - window.addEventListener('touchstart', this.onTouchStart); - window.addEventListener('touchend', this.onTouchEnd); - window.addEventListener('touchcancel', this.onTouchCancel); - window.addEventListener('touchmove', this.onTouchMove); - } - - componentWillUnmount() { - this.clearUpdateTimeout(); - - window.removeEventListener('touchstart', this.onTouchStart); - window.removeEventListener('touchend', this.onTouchEnd); - window.removeEventListener('touchcancel', this.onTouchCancel); - window.removeEventListener('touchmove', this.onTouchMove); - } - - // - // Control - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - const todaysDate = moment().startOf('day'); - const diff = moment().diff(todaysDate.clone().add(1, 'day')); - - this.setState({ todaysDate: todaysDate.toISOString() }); - - this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - // - // Listeners - - onEventModalOpenToggle = (isEventModalOpen) => { - this.setState({ isEventModalOpen }); - }; - - onTouchStart = (event) => { - const touches = event.touches; - const touchStart = touches[0].pageX; - - if (touches.length !== 1) { - return; - } - - if ( - touchStart < 50 || - this.props.isSidebarVisible || - this.state.isEventModalOpen - ) { - return; - } - - this._touchStart = touchStart; - }; - - onTouchEnd = (event) => { - const touches = event.changedTouches; - const currentTouch = touches[0].pageX; - - if (!this._touchStart) { - return; - } - - if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) { - this.props.onNavigatePrevious(); - } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) { - this.props.onNavigateNext(); - } - - this._touchStart = null; - }; - - onTouchCancel = (event) => { - this._touchStart = null; - }; - - onTouchMove = (event) => { - if (!this._touchStart) { - return; - } - }; - - // - // Render - - render() { - const { - dates, - view - } = this.props; - - return ( -
- { - dates.map((date) => { - return ( - - ); - }) - } -
- ); - } -} - -CalendarDays.propTypes = { - dates: PropTypes.arrayOf(PropTypes.string).isRequired, - view: PropTypes.string.isRequired, - isSidebarVisible: PropTypes.bool.isRequired, - onNavigatePrevious: PropTypes.func.isRequired, - onNavigateNext: PropTypes.func.isRequired -}; - -export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDays.tsx b/frontend/src/Calendar/Day/CalendarDays.tsx new file mode 100644 index 0000000000..cd9367cd71 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.tsx @@ -0,0 +1,129 @@ +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 [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); + }, []); + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + const touches = event.touches; + const currentTouch = touches[0].pageX; + + if (touches.length !== 1) { + return; + } + + if (currentTouch < 50 || isSidebarVisible) { + return; + } + + touchStart.current = currentTouch; + }, + [isSidebarVisible] + ); + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!touchStart.current) { + return; + } + + if ( + currentTouch > touchStart.current && + currentTouch - touchStart.current > 100 + ) { + dispatch(gotoCalendarPreviousRange()); + } else if ( + currentTouch < touchStart.current && + touchStart.current - currentTouch > 100 + ) { + dispatch(gotoCalendarNextRange()); + } + + touchStart.current = null; + }, + [dispatch] + ); + + const handleTouchCancel = useCallback(() => { + touchStart.current = null; + }, []); + + const handleTouchMove = useCallback(() => { + if (!touchStart.current) { + return; + } + }, []); + + useEffect(() => { + if (view === calendarViews.MONTH) { + scheduleUpdate(); + } + }, [view, scheduleUpdate]); + + useEffect(() => { + window.addEventListener('touchstart', handleTouchStart); + window.addEventListener('touchend', handleTouchEnd); + window.addEventListener('touchcancel', handleTouchCancel); + window.addEventListener('touchmove', handleTouchMove); + + return () => { + window.removeEventListener('touchstart', handleTouchStart); + window.removeEventListener('touchend', handleTouchEnd); + window.removeEventListener('touchcancel', handleTouchCancel); + window.removeEventListener('touchmove', handleTouchMove); + }; + }, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]); + + return ( +
+ {dates.map((date) => { + return ( + + ); + })} +
+ ); +} + +export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js deleted file mode 100644 index 0acce70b90..0000000000 --- a/frontend/src/Calendar/Day/CalendarDaysConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions'; -import CalendarDays from './CalendarDays'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (state) => state.app.isSidebarVisible, - (calendar, isSidebarVisible) => { - return { - dates: calendar.dates, - view: calendar.view, - isSidebarVisible - }; - } - ); -} - -const mapDispatchToProps = { - onNavigatePrevious: gotoCalendarPreviousRange, - onNavigateNext: gotoCalendarNextRange -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays); diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js deleted file mode 100644 index 0f1d38f0b8..0000000000 --- a/frontend/src/Calendar/Day/DayOfWeek.js +++ /dev/null @@ -1,56 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import styles from './DayOfWeek.css'; - -class DayOfWeek extends Component { - - // - // Render - - render() { - const { - date, - view, - isTodaysDate, - calendarWeekColumnHeader, - shortDateFormat, - showRelativeDates - } = this.props; - - const highlightToday = view !== calendarViews.MONTH && isTodaysDate; - const momentDate = moment(date); - let formatedDate = momentDate.format('dddd'); - - if (view === calendarViews.WEEK) { - formatedDate = momentDate.format(calendarWeekColumnHeader); - } else if (view === calendarViews.FORECAST) { - formatedDate = getRelativeDate({ date, shortDateFormat, showRelativeDates }); - } - - return ( -
- {formatedDate} -
- ); - } -} - -DayOfWeek.propTypes = { - date: PropTypes.string.isRequired, - view: PropTypes.string.isRequired, - isTodaysDate: PropTypes.bool.isRequired, - calendarWeekColumnHeader: PropTypes.string.isRequired, - shortDateFormat: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired -}; - -export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DayOfWeek.tsx b/frontend/src/Calendar/Day/DayOfWeek.tsx new file mode 100644 index 0000000000..c8b493b7c9 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.tsx @@ -0,0 +1,54 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React from 'react'; +import * as calendarViews from 'Calendar/calendarViews'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import styles from './DayOfWeek.css'; + +interface DayOfWeekProps { + date: string; + view: string; + isTodaysDate: boolean; + calendarWeekColumnHeader: string; + shortDateFormat: string; + showRelativeDates: boolean; +} + +function DayOfWeek(props: DayOfWeekProps) { + const { + date, + view, + isTodaysDate, + calendarWeekColumnHeader, + shortDateFormat, + showRelativeDates, + } = props; + + const highlightToday = view !== calendarViews.MONTH && isTodaysDate; + const momentDate = moment(date); + let formatedDate = momentDate.format('dddd'); + + if (view === calendarViews.WEEK) { + formatedDate = momentDate.format(calendarWeekColumnHeader); + } else if (view === calendarViews.FORECAST) { + formatedDate = getRelativeDate({ + date, + shortDateFormat, + showRelativeDates, + }); + } + + return ( +
+ {formatedDate} +
+ ); +} + +export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js deleted file mode 100644 index 9f94b1079d..0000000000 --- a/frontend/src/Calendar/Day/DaysOfWeek.js +++ /dev/null @@ -1,97 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import DayOfWeek from './DayOfWeek'; -import styles from './DaysOfWeek.css'; - -class DaysOfWeek extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - todaysDate: moment().startOf('day').toISOString() - }; - - this.updateTimeoutId = null; - } - - // Lifecycle - - componentDidMount() { - const view = this.props.view; - - if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) { - this.scheduleUpdate(); - } - } - - componentWillUnmount() { - this.clearUpdateTimeout(); - } - - // - // Control - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - const todaysDate = moment().startOf('day'); - const diff = todaysDate.clone().add(1, 'day').diff(moment()); - - this.setState({ - todaysDate: todaysDate.toISOString() - }); - - this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - // - // Render - - render() { - const { - dates, - view, - ...otherProps - } = this.props; - - if (view === calendarViews.AGENDA) { - return null; - } - - return ( -
- { - dates.map((date) => { - return ( - - ); - }) - } -
- ); - } -} - -DaysOfWeek.propTypes = { - dates: PropTypes.arrayOf(PropTypes.string), - view: PropTypes.string.isRequired -}; - -export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.tsx b/frontend/src/Calendar/Day/DaysOfWeek.tsx new file mode 100644 index 0000000000..64bc886ccd --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.tsx @@ -0,0 +1,60 @@ +import moment from 'moment'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as calendarViews from 'Calendar/calendarViews'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import DayOfWeek from './DayOfWeek'; +import styles from './DaysOfWeek.css'; + +function DaysOfWeek() { + const { dates, view } = useSelector((state: AppState) => state.calendar); + const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } = + useSelector(createUISettingsSelector()); + + const updateTimeout = useRef>(); + const [todaysDate, setTodaysDate] = useState( + moment().startOf('day').toISOString() + ); + + const scheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + setTodaysDate(todaysDate.toISOString()); + + updateTimeout.current = setTimeout(scheduleUpdate, diff); + }, []); + + useEffect(() => { + if (view !== calendarViews.AGENDA && view !== calendarViews.MONTH) { + scheduleUpdate(); + } + }, [view, scheduleUpdate]); + + if (view === calendarViews.AGENDA) { + return null; + } + + return ( +
+ {dates.map((date) => { + return ( + + ); + })} +
+ ); +} + +export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js deleted file mode 100644 index 7f5cdef198..0000000000 --- a/frontend/src/Calendar/Day/DaysOfWeekConnector.js +++ /dev/null @@ -1,22 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import DaysOfWeek from './DaysOfWeek'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createUISettingsSelector(), - (calendar, UiSettings) => { - return { - dates: calendar.dates.slice(0, 7), - view: calendar.view, - calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader, - shortDateFormat: UiSettings.shortDateFormat, - showRelativeDates: UiSettings.showRelativeDates - }; - } - ); -} - -export default connect(createMapStateToProps)(DaysOfWeek); diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css index f5b914cb6d..2b173ce958 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.css +++ b/frontend/src/Calendar/Events/CalendarEvent.css @@ -34,7 +34,8 @@ $fullColorGradient: rgba(244, 245, 246, 0.2); } .movieTitle, -.genres { +.genres, +.eventType { @add-mixin truncate; flex: 1 0 1px; margin-right: 10px; diff --git a/frontend/src/Calendar/Events/CalendarEvent.css.d.ts b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts index b7867e7acb..7ee7c26591 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.css.d.ts +++ b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts @@ -4,6 +4,7 @@ interface CssExports { 'continuing': string; 'downloaded': string; 'event': string; + 'eventType': string; 'genres': string; 'info': string; 'missingMonitored': string; diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js deleted file mode 100644 index a877d20619..0000000000 --- a/frontend/src/Calendar/Events/CalendarEvent.js +++ /dev/null @@ -1,177 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons, kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import CalendarEventQueueDetails from './CalendarEventQueueDetails'; -import styles from './CalendarEvent.css'; - -class CalendarEvent extends Component { - - // - // Render - - render() { - const { - movieFile, - isAvailable, - inCinemas, - physicalRelease, - digitalRelease, - title, - titleSlug, - genres, - date, - monitored, - certification, - hasFile, - grabbed, - queueItem, - showMovieInformation, - showCutoffUnmetIcon, - fullColorEvents, - colorImpairedMode - } = this.props; - - const isDownloading = !!(queueItem || grabbed); - const isMonitored = monitored; - const statusStyle = getStatusStyle(hasFile, isDownloading, isMonitored, isAvailable); - const joinedGenres = genres.slice(0, 2).join(', '); - const link = `/movie/${titleSlug}`; - const eventType = []; - - if (inCinemas && moment(date).isSame(moment(inCinemas), 'day')) { - eventType.push('Cinemas'); - } - - if (physicalRelease && moment(date).isSame(moment(physicalRelease), 'day')) { - eventType.push('Physical'); - } - - if (digitalRelease && moment(date).isSame(moment(digitalRelease), 'day')) { - eventType.push('Digital'); - } - - return ( -
- - -
-
-
- {title} -
- -
- { - queueItem ? - - - : - null - } - - { - !queueItem && grabbed ? - : - null - } - - { - showCutoffUnmetIcon && - !!movieFile && - movieFile.qualityCutoffNotMet ? - : - null - } -
-
- - { - showMovieInformation ? -
-
- {joinedGenres} -
-
: - null - } - - { - showMovieInformation ? -
-
- {eventType.join(', ')} -
-
- {certification} -
-
: - null - } -
-
- ); - } -} - -CalendarEvent.propTypes = { - id: PropTypes.number.isRequired, - genres: PropTypes.arrayOf(PropTypes.string).isRequired, - movieFile: PropTypes.object, - title: PropTypes.string.isRequired, - titleSlug: PropTypes.string.isRequired, - isAvailable: PropTypes.bool.isRequired, - inCinemas: PropTypes.string, - physicalRelease: PropTypes.string, - digitalRelease: PropTypes.string, - date: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - certification: 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. - showMovieInformation: PropTypes.bool, - showCutoffUnmetIcon: PropTypes.bool, - fullColorEvents: PropTypes.bool, - timeFormat: PropTypes.string, - colorImpairedMode: PropTypes.bool -}; - -CalendarEvent.defaultProps = { - genres: [] -}; - -export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx new file mode 100644 index 0000000000..c4800fceca --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.tsx @@ -0,0 +1,180 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useMemo } 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 { icons, kinds } from 'Helpers/Props'; +import useMovieFile from 'MovieFile/useMovieFile'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import CalendarEventQueueDetails from './CalendarEventQueueDetails'; +import styles from './CalendarEvent.css'; + +interface CalendarEventProps { + id: number; + movieFileId?: number; + title: string; + titleSlug: string; + genres: string[]; + certification?: string; + date: string; + inCinemas?: string; + digitalRelease?: string; + physicalRelease?: string; + isAvailable: boolean; + monitored: boolean; + hasFile: boolean; + grabbed?: boolean; +} + +function CalendarEvent({ + id, + movieFileId, + title, + titleSlug, + genres = [], + certification, + date, + inCinemas, + digitalRelease, + physicalRelease, + isAvailable, + monitored: isMonitored, + hasFile, + grabbed, +}: CalendarEventProps) { + const movieFile = useMovieFile(movieFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + + const { enableColorImpairedMode } = useSelector(createUISettingsSelector()); + + const { + showMovieInformation, + showCinemaRelease, + showDigitalRelease, + showPhysicalRelease, + showCutoffUnmetIcon, + fullColorEvents, + } = useSelector((state: AppState) => state.calendar.options); + + const isDownloading = !!(queueItem || grabbed); + const statusStyle = getStatusStyle( + hasFile, + isDownloading, + isMonitored, + isAvailable + ); + const joinedGenres = genres.slice(0, 2).join(', '); + const link = `/movie/${titleSlug}`; + + const eventTypes = useMemo(() => { + const momentDate = moment(date); + + const types = []; + + if ( + showCinemaRelease && + inCinemas && + momentDate.isSame(moment(inCinemas), 'day') + ) { + types.push('Cinemas'); + } + + if ( + showDigitalRelease && + digitalRelease && + momentDate.isSame(moment(digitalRelease), 'day') + ) { + types.push('Digital'); + } + + if ( + showPhysicalRelease && + physicalRelease && + momentDate.isSame(moment(physicalRelease), 'day') + ) { + types.push('Physical'); + } + + return types; + }, [ + date, + showCinemaRelease, + showDigitalRelease, + showPhysicalRelease, + inCinemas, + digitalRelease, + physicalRelease, + ]); + + return ( +
+ + +
+
+
{title}
+ +
+ {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && + !!movieFile && + movieFile.qualityCutoffNotMet ? ( + + ) : null} +
+
+ + {showMovieInformation ? ( + <> +
+
{joinedGenres}
+
+ +
+
{eventTypes.join(', ')}
+ +
{certification}
+
+ + ) : null} +
+
+ ); +} + +export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js deleted file mode 100644 index 0a38db7758..0000000000 --- a/frontend/src/Calendar/Events/CalendarEventConnector.js +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarEvent from './CalendarEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createMovieSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, movie, queueItem, uiSettings) => { - return { - movie, - queueItem, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarEvent); diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js deleted file mode 100644 index db26eb1d28..0000000000 --- a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js +++ /dev/null @@ -1,56 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import QueueDetails from 'Activity/Queue/QueueDetails'; -import CircularProgressBar from 'Components/CircularProgressBar'; - -function CalendarEventQueueDetails(props) { - const { - title, - size, - sizeleft, - estimatedCompletionTime, - status, - trackedDownloadState, - trackedDownloadStatus, - statusMessages, - errorMessage - } = props; - - const progress = size ? (100 - sizeleft / size * 100) : 0; - - return ( - - } - /> - ); -} - -CalendarEventQueueDetails.propTypes = { - title: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - estimatedCompletionTime: PropTypes.string, - status: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string -}; - -export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx new file mode 100644 index 0000000000..2372bc78ee --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import QueueDetails from 'Activity/Queue/QueueDetails'; +import CircularProgressBar from 'Components/CircularProgressBar'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; + +interface CalendarEventQueueDetailsProps { + title: string; + size: number; + sizeleft: number; + estimatedCompletionTime?: string; + status: string; + trackedDownloadState: QueueTrackedDownloadState; + trackedDownloadStatus: QueueTrackedDownloadStatus; + statusMessages?: StatusMessage[]; + errorMessage?: string; +} + +function CalendarEventQueueDetails({ + title, + size, + sizeleft, + estimatedCompletionTime, + status, + trackedDownloadState, + trackedDownloadStatus, + statusMessages, + errorMessage, +}: CalendarEventQueueDetailsProps) { + const progress = size ? 100 - (sizeleft / size) * 100 : 0; + + return ( + + } + /> + ); +} + +export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js deleted file mode 100644 index 1df7b0c4b1..0000000000 --- a/frontend/src/Calendar/Header/CalendarHeader.js +++ /dev/null @@ -1,268 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Menu from 'Components/Menu/Menu'; -import MenuButton from 'Components/Menu/MenuButton'; -import MenuContent from 'Components/Menu/MenuContent'; -import ViewMenuItem from 'Components/Menu/ViewMenuItem'; -import { align, icons } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import CalendarHeaderViewButton from './CalendarHeaderViewButton'; -import styles from './CalendarHeader.css'; - -function getTitle(time, start, end, view, longDateFormat) { - const timeMoment = moment(time); - const startMoment = moment(start); - const endMoment = moment(end); - - if (view === 'day') { - return timeMoment.format(longDateFormat); - } else if (view === 'month') { - return timeMoment.format('MMMM YYYY'); - } else if (view === 'agenda') { - return `Agenda: ${startMoment.format('MMM D')} - ${endMoment.format('MMM D')}`; - } - - let startFormat = 'MMM D YYYY'; - let endFormat = 'MMM D YYYY'; - - if (startMoment.isSame(endMoment, 'month')) { - startFormat = 'MMM D'; - endFormat = 'D YYYY'; - } else if (startMoment.isSame(endMoment, 'year')) { - startFormat = 'MMM D'; - endFormat = 'MMM D YYYY'; - } - - return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`; -} - -// TODO Convert to a stateful Component so we can track view internally when changed - -class CalendarHeader extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - view: props.view - }; - } - - componentDidUpdate(prevProps) { - const view = this.props.view; - - if (prevProps.view !== view) { - this.setState({ view }); - } - } - - // - // Listeners - - onViewChange = (view) => { - this.setState({ view }, () => { - this.props.onViewChange(view); - }); - }; - - // - // Render - - render() { - const { - isFetching, - time, - start, - end, - longDateFormat, - isSmallScreen, - collapseViewButtons, - onTodayPress, - onPreviousPress, - onNextPress - } = this.props; - - const view = this.state.view; - - const title = getTitle(time, start, end, view, longDateFormat); - - return ( -
- { - isSmallScreen && -
- {title} -
- } - -
-
- - - - - -
- - { - !isSmallScreen && -
- {title} -
- } - -
- { - isFetching && - - } - - { - collapseViewButtons ? - - - - - - - { - isSmallScreen ? - null : - - {translate('Month')} - - } - - - {translate('Week')} - - - - {translate('Forecast')} - - - - {translate('Day')} - - - - {translate('Agenda')} - - - : - -
- - - - - - - - - -
- } -
-
-
- ); - } -} - -CalendarHeader.propTypes = { - isFetching: PropTypes.bool.isRequired, - time: PropTypes.string.isRequired, - start: PropTypes.string.isRequired, - end: PropTypes.string.isRequired, - view: PropTypes.oneOf(calendarViews.all).isRequired, - isSmallScreen: PropTypes.bool.isRequired, - collapseViewButtons: PropTypes.bool.isRequired, - longDateFormat: PropTypes.string.isRequired, - onViewChange: PropTypes.func.isRequired, - onTodayPress: PropTypes.func.isRequired, - onPreviousPress: PropTypes.func.isRequired, - onNextPress: PropTypes.func.isRequired -}; - -export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeader.tsx b/frontend/src/Calendar/Header/CalendarHeader.tsx new file mode 100644 index 0000000000..62b200bd58 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.tsx @@ -0,0 +1,218 @@ +import moment from 'moment'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +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: string) => { + 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'); + } + + let startFormat = 'MMM D YYYY'; + let endFormat = 'MMM D YYYY'; + + if (startMoment.isSame(endMoment, 'month')) { + startFormat = 'MMM D'; + endFormat = 'D YYYY'; + } else if (startMoment.isSame(endMoment, 'year')) { + startFormat = 'MMM D'; + endFormat = 'MMM D YYYY'; + } + + return `${startMoment.format(startFormat)} \u2014 ${endMoment.format( + endFormat + )}`; + }, [time, start, end, view, longDateFormat]); + + return ( +
+ {isSmallScreen ?
{title}
: null} + +
+
+ + + + + +
+ + {isSmallScreen ? null : ( +
{title}
+ )} + +
+ {isFetching ? ( + + ) : null} + + {isLargeScreen ? ( + + + + + + + {isSmallScreen ? null : ( + + {translate('Month')} + + )} + + + {translate('Week')} + + + + {translate('Forecast')} + + + + {translate('Day')} + + + + {translate('Agenda')} + + + + ) : ( + <> + + + + + + + + + + + )} +
+
+
+ ); +} + +export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js deleted file mode 100644 index d966c46fdc..0000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderConnector.js +++ /dev/null @@ -1,81 +0,0 @@ -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) => { - return { - isFetching: calendar.isFetching, - view: calendar.view, - time: calendar.time, - start: calendar.start, - end: calendar.end, - isSmallScreen: dimensions.isSmallScreen, - collapseViewButtons: dimensions.isLargeScreen, - longDateFormat: uiSettings.longDateFormat - }; - } - ); -} - -const mapDispatchToProps = { - setCalendarView, - gotoCalendarToday, - gotoCalendarPreviousRange, - gotoCalendarNextRange -}; - -class CalendarHeaderConnector extends Component { - - // - // Listeners - - onViewChange = (view) => { - this.props.setCalendarView({ view }); - }; - - onTodayPress = () => { - this.props.gotoCalendarToday(); - }; - - onPreviousPress = () => { - this.props.gotoCalendarPreviousRange(); - }; - - onNextPress = () => { - this.props.gotoCalendarNextRange(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarHeaderConnector.propTypes = { - setCalendarView: PropTypes.func.isRequired, - gotoCalendarToday: PropTypes.func.isRequired, - gotoCalendarPreviousRange: PropTypes.func.isRequired, - gotoCalendarNextRange: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector); diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js deleted file mode 100644 index 98958af038..0000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js +++ /dev/null @@ -1,45 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import Button from 'Components/Link/Button'; -import titleCase from 'Utilities/String/titleCase'; -// import styles from './CalendarHeaderViewButton.css'; - -class CalendarHeaderViewButton extends Component { - - // - // Listeners - - onPress = () => { - this.props.onPress(this.props.view); - }; - - // - // Render - - render() { - const { - view, - selectedView, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -CalendarHeaderViewButton.propTypes = { - view: PropTypes.oneOf(calendarViews.all).isRequired, - selectedView: PropTypes.oneOf(calendarViews.all).isRequired, - onPress: PropTypes.func.isRequired -}; - -export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx new file mode 100644 index 0000000000..c9366f9ef8 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; +import { CalendarView } from 'Calendar/calendarViews'; +import Button, { ButtonProps } from 'Components/Link/Button'; +import titleCase from 'Utilities/String/titleCase'; + +interface CalendarHeaderViewButtonProps + extends Omit { + view: CalendarView; + selectedView: CalendarView; + onPress: (view: CalendarView) => void; +} + +function CalendarHeaderViewButton({ + view, + selectedView, + onPress, + ...otherProps +}: CalendarHeaderViewButtonProps) { + const handlePress = useCallback(() => { + onPress(view); + }, [view, onPress]); + + return ( + + ); +} + +export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.tsx similarity index 69% rename from frontend/src/Calendar/Legend/Legend.js rename to frontend/src/Calendar/Legend/Legend.tsx index 1884055d28..8f2ec10c20 100644 --- a/frontend/src/Calendar/Legend/Legend.js +++ b/frontend/src/Calendar/Legend/Legend.tsx @@ -1,18 +1,19 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; import { icons, kinds } from 'Helpers/Props'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import translate from 'Utilities/String/translate'; import LegendIconItem from './LegendIconItem'; import LegendItem from './LegendItem'; import styles from './Legend.css'; -function Legend(props) { - const { - view, - showCutoffUnmetIcon, - fullColorEvents, - colorImpairedMode - } = props; +function Legend() { + const view = useSelector((state: AppState) => state.calendar.view); + const { showCutoffUnmetIcon, fullColorEvents } = useSelector( + (state: AppState) => state.calendar.options + ); + const { enableColorImpairedMode } = useSelector(createUISettingsSelector()); const iconsToShow = []; const isAgendaView = view === 'agenda'; @@ -37,7 +38,7 @@ function Legend(props) { name={translate('DownloadedAndMonitored')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} />
@@ -55,7 +56,7 @@ function Legend(props) { name={translate('MissingMonitoredAndConsideredAvailable')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} />
@@ -73,7 +74,7 @@ function Legend(props) { name={translate('Queued')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} /> - { - iconsToShow.length > 0 && -
- {iconsToShow[0]} -
- } + {iconsToShow.length > 0 ?
{iconsToShow[0]}
: null} ); } -Legend.propTypes = { - view: PropTypes.string.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - export default Legend; diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js deleted file mode 100644 index 889b7a0024..0000000000 --- a/frontend/src/Calendar/Legend/LegendConnector.js +++ /dev/null @@ -1,21 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import Legend from './Legend'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - (state) => state.calendar.view, - createUISettingsSelector(), - (calendarOptions, view, uiSettings) => { - return { - ...calendarOptions, - view, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(Legend); diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js deleted file mode 100644 index b6bdeeff78..0000000000 --- a/frontend/src/Calendar/Legend/LegendIconItem.js +++ /dev/null @@ -1,43 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import styles from './LegendIconItem.css'; - -function LegendIconItem(props) { - const { - name, - fullColorEvents, - icon, - kind, - tooltip - } = props; - - return ( -
- - - {name} -
- ); -} - -LegendIconItem.propTypes = { - name: PropTypes.string.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - icon: PropTypes.object.isRequired, - kind: PropTypes.string.isRequired, - tooltip: PropTypes.string.isRequired -}; - -export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendIconItem.tsx b/frontend/src/Calendar/Legend/LegendIconItem.tsx new file mode 100644 index 0000000000..88a758c449 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.tsx @@ -0,0 +1,33 @@ +import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome'; +import classNames from 'classnames'; +import React from 'react'; +import Icon, { IconProps } from 'Components/Icon'; +import styles from './LegendIconItem.css'; + +interface LegendIconItemProps extends Pick { + name: string; + fullColorEvents: boolean; + icon: FontAwesomeIconProps['icon']; + tooltip: string; +} + +function LegendIconItem(props: LegendIconItemProps) { + const { name, fullColorEvents, icon, kind, tooltip } = props; + + return ( +
+ + + {name} +
+ ); +} + +export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js deleted file mode 100644 index 840a3674c0..0000000000 --- a/frontend/src/Calendar/Legend/LegendItem.js +++ /dev/null @@ -1,37 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './LegendItem.css'; - -function LegendItem(props) { - const { - name, - status, - isAgendaView, - fullColorEvents, - colorImpairedMode - } = props; - - return ( -
- {name} -
- ); -} - -LegendItem.propTypes = { - name: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - isAgendaView: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - -export default LegendItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.tsx b/frontend/src/Calendar/Legend/LegendItem.tsx new file mode 100644 index 0000000000..d532d85ed0 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.tsx @@ -0,0 +1,35 @@ +import classNames from 'classnames'; +import React from 'react'; +import { CalendarStatus } from 'typings/Calendar'; +import styles from './LegendItem.css'; + +interface LegendItemProps { + name: string; + status: CalendarStatus; + isAgendaView: boolean; + fullColorEvents: boolean; + colorImpairedMode: boolean; +} + +function LegendItem({ + name, + status, + isAgendaView, + fullColorEvents, + colorImpairedMode, +}: LegendItemProps) { + return ( +
+ {name} +
+ ); +} + +export default LegendItem; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js deleted file mode 100644 index b68c83f301..0000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector'; - -function CalendarOptionsModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -CalendarOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.tsx b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx new file mode 100644 index 0000000000..ae782a684b --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarOptionsModalContent from './CalendarOptionsModalContent'; + +interface CalendarOptionsModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function CalendarOptionsModal({ + isOpen, + onModalClose, +}: CalendarOptionsModalProps) { + return ( + + + + ); +} + +export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js deleted file mode 100644 index 5933281f71..0000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js +++ /dev/null @@ -1,234 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FieldSet from 'Components/FieldSet'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes } from 'Helpers/Props'; -import { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings'; -import translate from 'Utilities/String/translate'; - -class CalendarOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode, - fullColorEvents - } = props; - - this.state = { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode, - fullColorEvents - }; - } - - componentDidUpdate(prevProps) { - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - } = this.props; - - if ( - prevProps.firstDayOfWeek !== firstDayOfWeek || - prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader || - prevProps.timeFormat !== timeFormat || - prevProps.enableColorImpairedMode !== enableColorImpairedMode - ) { - this.setState({ - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - }); - } - } - - // - // Listeners - - onOptionInputChange = ({ name, value }) => { - const { - dispatchSetCalendarOption - } = this.props; - - dispatchSetCalendarOption({ [name]: value }); - }; - - onGlobalInputChange = ({ name, value }) => { - const { - dispatchSaveUISettings - } = this.props; - - const setting = { [name]: value }; - - this.setState(setting, () => { - dispatchSaveUISettings(setting); - }); - }; - - onLinkFocus = (event) => { - event.target.select(); - }; - - // - // Render - - render() { - const { - showMovieInformation, - showCutoffUnmetIcon, - fullColorEvents, - onModalClose - } = this.props; - - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - } = this.state; - - return ( - - - {translate('CalendarOptions')} - - - -
- - - {translate('ShowMovieInformation')} - - - - - - {translate('IconForCutoffUnmet')} - - - - - - {translate('FullColorEvents')} - - - - -
- -
-
- - {translate('FirstDayOfWeek')} - - - - - - {translate('WeekColumnHeader')} - - - - - - {translate('TimeFormat')} - - - - - - {translate('EnableColorImpairedMode')} - - - -
-
-
- - - - -
- ); - } -} - -CalendarOptionsModalContent.propTypes = { - showMovieInformation: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - firstDayOfWeek: PropTypes.number.isRequired, - calendarWeekColumnHeader: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - enableColorImpairedMode: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - dispatchSetCalendarOption: PropTypes.func.isRequired, - dispatchSaveUISettings: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx new file mode 100644 index 0000000000..32806092a4 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx @@ -0,0 +1,243 @@ +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 { + showMovieInformation, + showCinemaRelease, + showDigitalRelease, + showPhysicalRelease, + 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('ShowMovieInformation')} + + + + + + {translate('ShowCinemaRelease')} + + + + + + {translate('ShowDigitalRelease')} + + + + + + {translate('ShowPhysicalRelease')} + + + + + + {translate('IconForCutoffUnmet')} + + + + + + {translate('FullColorEvents')} + + + +
+
+ +
+
+ + {translate('FirstDayOfWeek')} + + + + + + {translate('WeekColumnHeader')} + + + + + + {translate('TimeFormat')} + + + + + + {translate('EnableColorImpairedMode')} + + + +
+
+
+ + + + +
+ ); +} + +export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js deleted file mode 100644 index 1f517b6989..0000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setCalendarOption } from 'Store/Actions/calendarActions'; -import { saveUISettings } from 'Store/Actions/settingsActions'; -import CalendarOptionsModalContent from './CalendarOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - (state) => state.settings.ui.item, - (options, uiSettings) => { - return { - ...options, - ...uiSettings - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetCalendarOption: setCalendarOption, - dispatchSaveUISettings: saveUISettings -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent); diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.ts similarity index 72% rename from frontend/src/Calendar/calendarViews.js rename to frontend/src/Calendar/calendarViews.ts index 929958b66c..4f5549dbd1 100644 --- a/frontend/src/Calendar/calendarViews.js +++ b/frontend/src/Calendar/calendarViews.ts @@ -5,3 +5,5 @@ export const FORECAST = 'forecast'; export const AGENDA = 'agenda'; export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA]; + +export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week'; diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.ts similarity index 75% rename from frontend/src/Calendar/getStatusStyle.js rename to frontend/src/Calendar/getStatusStyle.ts index 81ef830675..539faa4021 100644 --- a/frontend/src/Calendar/getStatusStyle.js +++ b/frontend/src/Calendar/getStatusStyle.ts @@ -1,4 +1,9 @@ -function getStatusStyle(hasFile, downloading, isMonitored, isAvailable) { +function getStatusStyle( + hasFile: boolean, + downloading: boolean, + isMonitored: boolean, + isAvailable: boolean +) { if (downloading) { return 'queue'; } diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js deleted file mode 100644 index 8cc487c162..0000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector'; - -function CalendarLinkModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -CalendarLinkModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.tsx b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx new file mode 100644 index 0000000000..f0eecbd4a4 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarLinkModalContent from './CalendarLinkModalContent'; + +interface CalendarLinkModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function CalendarLinkModal(props: CalendarLinkModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js deleted file mode 100644 index dd239d6156..0000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js +++ /dev/null @@ -1,203 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputButton from 'Components/Form/FormInputButton'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import ClipboardButton from 'Components/Link/ClipboardButton'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -function getUrls(state) { - const { - unmonitored, - asAllDay, - tags - } = state; - - let icalUrl = `${window.location.host}${window.Radarr.urlBase}/feed/v3/calendar/Radarr.ics?`; - - if (unmonitored) { - icalUrl += 'unmonitored=true&'; - } - - if (asAllDay) { - icalUrl += 'asAllDay=true&'; - } - - if (tags.length) { - icalUrl += `tags=${tags.toString()}&`; - } - - icalUrl += `apikey=${encodeURIComponent(window.Radarr.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, - 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, - asAllDay, - tags, - iCalHttpUrl, - iCalWebCalUrl - } = this.state; - - return ( - - - {translate('CalendarFeed')} - - - -
- - {translate('IncludeUnmonitored')} - - - - - - {translate('ICalShowAsAllDayEvents')} - - - - - - {translate('Tags')} - - - - - - {translate('ICalFeed')} - - , - - - - - ]} - onChange={this.onInputChange} - onFocus={this.onLinkFocus} - /> - -
-
- - - - -
- ); - } -} - -CalendarLinkModalContent.propTypes = { - tagList: PropTypes.arrayOf(PropTypes.object).isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx new file mode 100644 index 0000000000..d2e8c4ea22 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx @@ -0,0 +1,196 @@ +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 { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput'; +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'; + +const releaseTypeOptions: EnhancedSelectInputValue[] = [ + { + key: 'cinemaRelease', + get value() { + return translate('CinemaRelease'); + }, + }, + { + key: 'digitalRelease', + get value() { + return translate('DigitalRelease'); + }, + }, + { + key: 'physicalRelease', + get value() { + return translate('PhysicalRelease'); + }, + }, +]; + +interface CalendarLinkModalContentProps { + onModalClose: () => void; +} + +function CalendarLinkModalContent({ + onModalClose, +}: CalendarLinkModalContentProps) { + const [state, setState] = useState<{ + unmonitored: boolean; + asAllDay: boolean; + releaseTypes: string[]; + tags: number[]; + }>({ + unmonitored: false, + asAllDay: false, + releaseTypes: [], + tags: [], + }); + + const { unmonitored, asAllDay, releaseTypes, 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.Radarr.urlBase}/feed/v3/calendar/Radarr.ics?`; + + if (unmonitored) { + icalUrl += 'unmonitored=true&'; + } + + if (asAllDay) { + icalUrl += 'asAllDay=true&'; + } + + if (releaseTypes.length) { + releaseTypes.forEach((releaseType) => { + icalUrl += `releaseTypes=${releaseType}&`; + }); + } + + if (tags.length) { + icalUrl += `tags=${tags.toString()}&`; + } + + icalUrl += `apikey=${encodeURIComponent(window.Radarr.apiKey)}`; + + return { + iCalHttpUrl: `${window.location.protocol}//${icalUrl}`, + iCalWebCalUrl: `webcal://${icalUrl}`, + }; + }, [unmonitored, asAllDay, releaseTypes, tags]); + + return ( + + {translate('CalendarFeed')} + + +
+ + {translate('IncludeUnmonitored')} + + + + + + {translate('ICalShowAsAllDayEvents')} + + + + + + {translate('ICalReleaseTypes')} + + + + + + {translate('Tags')} + + + + + + {translate('ICalFeed')} + + , + + + + , + ]} + onChange={handleInputChange} + onFocus={handleLinkFocus} + /> + +
+
+ + + + +
+ ); +} + +export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js deleted file mode 100644 index e10c5c3f90..0000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import CalendarLinkModalContent from './CalendarLinkModalContent'; - -function createMapStateToProps() { - return createSelector( - createTagsSelector(), - (tagList) => { - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarLinkModalContent); diff --git a/frontend/src/Collection/AddNewCollectionMovieModal.js b/frontend/src/Collection/AddNewCollectionMovieModal.js deleted file mode 100644 index d32820f368..0000000000 --- a/frontend/src/Collection/AddNewCollectionMovieModal.js +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import AddNewCollectionMovieModalContentConnector from './AddNewCollectionMovieModalContentConnector'; - -function AddNewCollectionMovieModal(props) { - const { - isOpen, - onModalClose, - ...otherProps - } = props; - - return ( - - - - ); -} - -AddNewCollectionMovieModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AddNewCollectionMovieModal; diff --git a/frontend/src/Collection/AddNewCollectionMovieModalContent.js b/frontend/src/Collection/AddNewCollectionMovieModalContent.js deleted file mode 100644 index 545b876753..0000000000 --- a/frontend/src/Collection/AddNewCollectionMovieModalContent.js +++ /dev/null @@ -1,204 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CheckInput from 'Components/Form/CheckInput'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes, kinds } from 'Helpers/Props'; -import MoviePoster from 'Movie/MoviePoster'; -import translate from 'Utilities/String/translate'; -import styles from './AddNewCollectionMovieModalContent.css'; - -class AddNewCollectionMovieModalContent extends Component { - - // - // Listeners - - onQualityProfileIdChange = ({ value }) => { - this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) }); - }; - - onAddMoviePress = () => { - this.props.onAddMoviePress(); - }; - - // - // Render - - render() { - const { - title, - year, - overview, - images, - isAdding, - folder, - tags, - isSmallScreen, - isWindows, - onModalClose, - onInputChange, - rootFolderPath, - monitor, - qualityProfileId, - minimumAvailability, - searchForMovie - } = this.props; - - return ( - - - {title} - - { - !title.contains(year) && !!year && - ({year}) - } - - - -
- { - !isSmallScreen && -
- -
- } - -
-
- {overview} -
- -
- - {translate('RootFolder')} - - - - - - - {translate('Monitor')} - - - - - - - {translate('MinimumAvailability')} - - - - - - {translate('QualityProfile')} - - - - - - {translate('Tags')} - - - -
-
-
-
- - - - - - {translate('AddMovie')} - - -
- ); - } -} - -AddNewCollectionMovieModalContent.propTypes = { - title: PropTypes.string.isRequired, - year: PropTypes.number.isRequired, - overview: PropTypes.string, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - isAdding: PropTypes.bool.isRequired, - addError: PropTypes.object, - rootFolderPath: PropTypes.object, - monitor: PropTypes.object.isRequired, - qualityProfileId: PropTypes.object, - minimumAvailability: PropTypes.object.isRequired, - searchForMovie: PropTypes.object.isRequired, - folder: PropTypes.string.isRequired, - tags: PropTypes.object.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - isWindows: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired, - onInputChange: PropTypes.func.isRequired, - onAddMoviePress: PropTypes.func.isRequired -}; - -export default AddNewCollectionMovieModalContent; diff --git a/frontend/src/Collection/AddNewCollectionMovieModalContentConnector.js b/frontend/src/Collection/AddNewCollectionMovieModalContentConnector.js deleted file mode 100644 index 46351b3036..0000000000 --- a/frontend/src/Collection/AddNewCollectionMovieModalContentConnector.js +++ /dev/null @@ -1,121 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { addMovie, setMovieCollectionValue } from 'Store/Actions/movieCollectionActions'; -import createCollectionSelector from 'Store/Selectors/createCollectionSelector'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import selectSettings from 'Store/Selectors/selectSettings'; -import AddNewMovieModalContent from './AddNewCollectionMovieModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.movieCollections, - createCollectionSelector(), - createDimensionsSelector(), - createSystemStatusSelector(), - (discoverMovieState, collection, dimensions, systemStatus) => { - const { - isAdding, - addError, - pendingChanges - } = discoverMovieState; - - const collectionDefaults = { - rootFolderPath: collection.rootFolderPath, - monitor: 'movieOnly', - qualityProfileId: collection.qualityProfileId, - minimumAvailability: collection.minimumAvailability, - searchForMovie: collection.searchOnAdd, - tags: collection.tags || [] - }; - - const { - settings, - validationErrors, - validationWarnings - } = selectSettings(collectionDefaults, pendingChanges, addError); - - return { - isAdding, - addError, - isSmallScreen: dimensions.isSmallScreen, - validationErrors, - validationWarnings, - isWindows: systemStatus.isWindows, - ...settings - }; - } - ); -} - -const mapDispatchToProps = { - addMovie, - setMovieCollectionValue -}; - -class AddNewCollectionMovieModalContentConnector extends Component { - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.props.setMovieCollectionValue({ name, value }); - }; - - onAddMoviePress = () => { - const { - tmdbId, - title, - rootFolderPath, - monitor, - qualityProfileId, - minimumAvailability, - searchForMovie, - tags - } = this.props; - - this.props.addMovie({ - tmdbId, - title, - rootFolderPath: rootFolderPath.value, - monitor: monitor.value, - qualityProfileId: qualityProfileId.value, - minimumAvailability: minimumAvailability.value, - searchForMovie: searchForMovie.value, - tags: tags.value - }); - - this.props.onModalClose(true); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -AddNewCollectionMovieModalContentConnector.propTypes = { - tmdbId: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, - rootFolderPath: PropTypes.object, - monitor: PropTypes.object.isRequired, - qualityProfileId: PropTypes.object, - minimumAvailability: PropTypes.object.isRequired, - searchForMovie: PropTypes.object.isRequired, - tags: PropTypes.object.isRequired, - onModalClose: PropTypes.func.isRequired, - addMovie: PropTypes.func.isRequired, - setMovieCollectionValue: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(AddNewCollectionMovieModalContentConnector); diff --git a/frontend/src/Collection/AddNewMovieCollectionMovieModal.tsx b/frontend/src/Collection/AddNewMovieCollectionMovieModal.tsx new file mode 100644 index 0000000000..656d9db7fe --- /dev/null +++ b/frontend/src/Collection/AddNewMovieCollectionMovieModal.tsx @@ -0,0 +1,45 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import AddNewMovieCollectionMovieModalContent, { + AddNewMovieCollectionMovieModalContentProps, +} from './AddNewMovieCollectionMovieModalContent'; + +interface AddNewCollectionMovieModalProps + extends AddNewMovieCollectionMovieModalContentProps { + isOpen: boolean; +} + +function AddNewMovieCollectionMovieModal({ + isOpen, + onModalClose, + ...otherProps +}: AddNewCollectionMovieModalProps) { + const dispatch = useDispatch(); + + const wasOpen = usePrevious(isOpen); + + const handleModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'movieCollections' })); + onModalClose(); + }, [dispatch, onModalClose]); + + useEffect(() => { + if (wasOpen && !isOpen) { + dispatch(clearPendingChanges({ section: 'movieCollections' })); + } + }, [wasOpen, isOpen, dispatch]); + + return ( + + + + ); +} + +export default AddNewMovieCollectionMovieModal; diff --git a/frontend/src/Collection/AddNewCollectionMovieModalContent.css b/frontend/src/Collection/AddNewMovieCollectionMovieModalContent.css similarity index 100% rename from frontend/src/Collection/AddNewCollectionMovieModalContent.css rename to frontend/src/Collection/AddNewMovieCollectionMovieModalContent.css diff --git a/frontend/src/Collection/AddNewCollectionMovieModalContent.css.d.ts b/frontend/src/Collection/AddNewMovieCollectionMovieModalContent.css.d.ts similarity index 100% rename from frontend/src/Collection/AddNewCollectionMovieModalContent.css.d.ts rename to frontend/src/Collection/AddNewMovieCollectionMovieModalContent.css.d.ts diff --git a/frontend/src/Collection/AddNewMovieCollectionMovieModalContent.tsx b/frontend/src/Collection/AddNewMovieCollectionMovieModalContent.tsx new file mode 100644 index 0000000000..b622dc8b7a --- /dev/null +++ b/frontend/src/Collection/AddNewMovieCollectionMovieModalContent.tsx @@ -0,0 +1,269 @@ +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import MovieMinimumAvailabilityPopoverContent from 'AddMovie/MovieMinimumAvailabilityPopoverContent'; +import AppState from 'App/State/AppState'; +import useMovieCollection from 'Collection/useMovieCollection'; +import CheckInput from 'Components/Form/CheckInput'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Icon from 'Components/Icon'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Popover from 'Components/Tooltip/Popover'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; +import { Image } from 'Movie/Movie'; +import MoviePoster from 'Movie/MoviePoster'; +import { + addMovie, + setMovieCollectionValue, +} from 'Store/Actions/movieCollectionActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import selectSettings from 'Store/Selectors/selectSettings'; +import useIsWindows from 'System/useIsWindows'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import styles from './AddNewMovieCollectionMovieModalContent.css'; + +export interface AddNewMovieCollectionMovieModalContentProps { + tmdbId: number; + title: string; + year: number; + overview?: string; + images: Image[]; + collectionId: number; + folder: string; + onModalClose: () => void; +} + +function AddNewMovieCollectionMovieModalContent({ + tmdbId, + title, + year, + overview, + images, + collectionId, + folder, + onModalClose, +}: AddNewMovieCollectionMovieModalContentProps) { + const dispatch = useDispatch(); + + const collection = useMovieCollection(collectionId)!; + + const { isSmallScreen } = useSelector(createDimensionsSelector()); + const isWindows = useIsWindows(); + + const { isAdding, addError, pendingChanges } = useSelector( + (state: AppState) => state.movieCollections + ); + + const wasAdding = usePrevious(isAdding); + + const { settings, validationErrors, validationWarnings } = useMemo(() => { + const options = { + rootFolderPath: collection.rootFolderPath, + monitor: collection.monitored ? 'movieOnly' : 'none', + qualityProfileId: collection.qualityProfileId, + minimumAvailability: collection.minimumAvailability, + searchForMovie: collection.searchOnAdd, + tags: collection.tags || [], + }; + + return selectSettings(options, pendingChanges, addError); + }, [collection, pendingChanges, addError]); + + const { + monitor, + qualityProfileId, + minimumAvailability, + rootFolderPath, + searchForMovie, + tags, + } = settings; + + const handleInputChange = useCallback( + ({ name, value }: InputChanged) => { + // @ts-expect-error actions aren't typed + dispatch(setMovieCollectionValue({ name, value })); + }, + [dispatch] + ); + + const handleAddMoviePress = useCallback(() => { + dispatch( + addMovie({ + tmdbId, + title, + rootFolderPath: rootFolderPath.value, + monitor: monitor.value, + qualityProfileId: qualityProfileId.value, + minimumAvailability: minimumAvailability.value, + searchForMovie: searchForMovie.value, + tags: tags.value, + }) + ); + }, [ + tmdbId, + title, + rootFolderPath, + monitor, + qualityProfileId, + minimumAvailability, + searchForMovie, + tags, + dispatch, + ]); + + useEffect(() => { + if (!isAdding && wasAdding && !addError) { + onModalClose(); + } + }, [isAdding, wasAdding, addError, onModalClose]); + + return ( + + + {title} + + {!title.includes(String(year)) && year ? ( + ({year}) + ) : null} + + + +
+ {isSmallScreen ? null : ( +
+ +
+ )} + +
+ {overview ? ( +
{overview}
+ ) : null} + +
+ + {translate('RootFolder')} + + + + + + {translate('Monitor')} + + + + + + + {translate('MinimumAvailability')} + + + } + title={translate('MinimumAvailability')} + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + {translate('QualityProfile')} + + + + + + {translate('Tags')} + + + +
+
+
+
+ + + + + + {translate('AddMovie')} + + +
+ ); +} + +export default AddNewMovieCollectionMovieModalContent; diff --git a/frontend/src/Collection/Collection.js b/frontend/src/Collection/Collection.js index c617d46676..7982cafd7f 100644 --- a/frontend/src/Collection/Collection.js +++ b/frontend/src/Collection/Collection.js @@ -18,9 +18,9 @@ import getSelectedIds from 'Utilities/Table/getSelectedIds'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; import CollectionFooter from './CollectionFooter'; -import CollectionFilterMenu from './Menus/CollectionFilterMenu'; -import CollectionSortMenu from './Menus/CollectionSortMenu'; -import NoCollection from './NoCollection'; +import MovieCollectionFilterMenu from './Menus/MovieCollectionFilterMenu'; +import MovieCollectionSortMenu from './Menus/MovieCollectionSortMenu'; +import NoMovieCollections from './NoMovieCollections'; import CollectionOverviewsConnector from './Overview/CollectionOverviewsConnector'; import CollectionOverviewOptionsModal from './Overview/Options/CollectionOverviewOptionsModal'; @@ -224,6 +224,7 @@ class Collection extends Component { view, onSortSelect, onFilterSelect, + initialScrollTop, onScroll, isRefreshingCollections, isSaving, @@ -247,7 +248,7 @@ class Collection extends Component { const hasNoCollection = !totalItems; return ( - + } - - { isFetching && !isPopulated && @@ -334,6 +336,7 @@ class Collection extends Component { onSelectedChange={this.onSelectedChange} onSelectAllChange={this.onSelectAllChange} selectedState={selectedState} + scrollTop={initialScrollTop} {...otherProps} /> @@ -341,7 +344,7 @@ class Collection extends Component { { !error && isPopulated && !items.length && - + }
@@ -374,6 +377,7 @@ class Collection extends Component { } Collection.propTypes = { + initialScrollTop: PropTypes.number, isFetching: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired, diff --git a/frontend/src/Collection/CollectionConnector.js b/frontend/src/Collection/CollectionConnector.js index c56f024ab9..7a1caeb878 100644 --- a/frontend/src/Collection/CollectionConnector.js +++ b/frontend/src/Collection/CollectionConnector.js @@ -5,14 +5,17 @@ import { createSelector } from 'reselect'; import * as commandNames from 'Commands/commandNames'; import withScrollPosition from 'Components/withScrollPosition'; import { executeCommand } from 'Store/Actions/commandActions'; -import { saveMovieCollections, setMovieCollectionsFilter, setMovieCollectionsSort } from 'Store/Actions/movieCollectionActions'; +import { + fetchMovieCollections, + saveMovieCollections, + setMovieCollectionsFilter, + setMovieCollectionsSort +} from 'Store/Actions/movieCollectionActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import scrollPositions from 'Store/scrollPositions'; import createCollectionClientSideCollectionItemsSelector from 'Store/Selectors/createCollectionClientSideCollectionItemsSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import Collection from './Collection'; function createMapStateToProps() { @@ -36,8 +39,8 @@ function createMapStateToProps() { function createMapDispatchToProps(dispatch, props) { return { - dispatchFetchRootFolders() { - dispatch(fetchRootFolders()); + dispatchFetchMovieCollections() { + dispatch(fetchMovieCollections()); }, dispatchFetchQueueDetails() { dispatch(fetchQueueDetails()); @@ -68,13 +71,11 @@ class CollectionConnector extends Component { // Lifecycle componentDidMount() { - registerPagePopulator(this.repopulate); - this.props.dispatchFetchRootFolders(); + this.props.dispatchFetchMovieCollections(); this.props.dispatchFetchQueueDetails(); } componentWillUnmount() { - unregisterPagePopulator(this.repopulate); this.props.dispatchClearQueueDetails(); } @@ -93,9 +94,16 @@ class CollectionConnector extends Component { // Render render() { + const { + dispatchFetchMovieCollections, + dispatchFetchQueueDetails, + dispatchClearQueueDetails, + ...otherProps + } = this.props; + return ( state.movieCollections.items, - (state) => state.movieCollections.filterBuilderProps, - (sectionItems, filterBuilderProps) => { - return { - sectionItems, - filterBuilderProps, - customFilterType: 'movieCollections' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetFilter: setMovieCollectionsFilter -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Collection/CollectionFooter.js b/frontend/src/Collection/CollectionFooter.js deleted file mode 100644 index 4fe345fc3f..0000000000 --- a/frontend/src/Collection/CollectionFooter.js +++ /dev/null @@ -1,296 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import AvailabilitySelectInput from 'Components/Form/AvailabilitySelectInput'; -import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector'; -import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector'; -import SelectInput from 'Components/Form/SelectInput'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import PageContentFooter from 'Components/Page/PageContentFooter'; -import { kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import CollectionFooterLabel from './CollectionFooterLabel'; -import styles from './CollectionFooter.css'; - -const NO_CHANGE = 'noChange'; - -const monitoredOptions = [ - { - key: NO_CHANGE, - get value() { - return translate('NoChange'); - }, - isDisabled: true - }, - { - key: 'monitored', - get value() { - return translate('Monitored'); - } - }, - { - key: 'unmonitored', - get value() { - return translate('Unmonitored'); - } - } -]; - -const searchOnAddOptions = [ - { - key: NO_CHANGE, - get value() { - return translate('NoChange'); - }, - isDisabled: true - }, - { - key: 'yes', - get value() { - return translate('Yes'); - } - }, - { - key: 'no', - get value() { - return translate('No'); - } - } -]; - -class CollectionFooter extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - monitored: NO_CHANGE, - monitor: NO_CHANGE, - qualityProfileId: NO_CHANGE, - minimumAvailability: NO_CHANGE, - rootFolderPath: NO_CHANGE, - searchOnAdd: NO_CHANGE - }; - } - - componentDidUpdate(prevProps) { - const { - isSaving, - saveError - } = this.props; - - const newState = {}; - if (prevProps.isSaving && !isSaving && !saveError) { - this.setState({ - monitored: NO_CHANGE, - monitor: NO_CHANGE, - qualityProfileId: NO_CHANGE, - minimumAvailability: NO_CHANGE, - rootFolderPath: NO_CHANGE, - searchOnAdd: NO_CHANGE - }); - } - - if (!_.isEmpty(newState)) { - this.setState(newState); - } - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.setState({ [name]: value }); - }; - - onUpdateSelectedPress = () => { - const { - monitored, - monitor, - qualityProfileId, - minimumAvailability, - rootFolderPath, - searchOnAdd - } = this.state; - - const changes = {}; - - if (monitored !== NO_CHANGE) { - changes.monitored = monitored === 'monitored'; - } - - if (monitor !== NO_CHANGE) { - changes.monitor = monitor; - } - - if (qualityProfileId !== NO_CHANGE) { - changes.qualityProfileId = qualityProfileId; - } - - if (minimumAvailability !== NO_CHANGE) { - changes.minimumAvailability = minimumAvailability; - } - - if (rootFolderPath !== NO_CHANGE) { - changes.rootFolderPath = rootFolderPath; - } - - if (searchOnAdd !== NO_CHANGE) { - changes.searchOnAdd = searchOnAdd === 'yes'; - } - - this.props.onUpdateSelectedPress(changes); - }; - - // - // Render - - render() { - const { - selectedIds, - isSaving - } = this.props; - - const { - monitored, - monitor, - qualityProfileId, - minimumAvailability, - rootFolderPath, - searchOnAdd - } = this.state; - - const selectedCount = selectedIds.length; - - return ( - -
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
- -
-
- - -
-
- - {translate('UpdateSelected')} - -
-
-
-
-
- ); - } -} - -CollectionFooter.propTypes = { - selectedIds: PropTypes.arrayOf(PropTypes.number).isRequired, - isAdding: PropTypes.bool.isRequired, - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - onUpdateSelectedPress: PropTypes.func.isRequired -}; - -export default CollectionFooter; diff --git a/frontend/src/Collection/CollectionFooter.tsx b/frontend/src/Collection/CollectionFooter.tsx new file mode 100644 index 0000000000..ad3c86ac35 --- /dev/null +++ b/frontend/src/Collection/CollectionFooter.tsx @@ -0,0 +1,317 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Error } from 'App/State/AppSectionState'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import CollectionFooterLabel from './CollectionFooterLabel'; +import styles from './CollectionFooter.css'; + +interface SavePayload { + monitored?: boolean; + monitor?: string; + qualityProfileId?: number; + minimumAvailability?: string; + rootFolderPath?: string; + searchOnAdd?: boolean; +} + +interface CollectionFooterProps { + selectedIds: number[]; + isAdding: boolean; + isSaving: boolean; + saveError: Error; + onUpdateSelectedPress(payload: object): void; +} + +const NO_CHANGE = 'noChange'; + +const monitoredOptions: EnhancedSelectInputValue[] = [ + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + }, + { + key: 'monitored', + get value() { + return translate('Monitored'); + }, + }, + { + key: 'unmonitored', + get value() { + return translate('Unmonitored'); + }, + }, +]; + +const searchOnAddOptions: EnhancedSelectInputValue[] = [ + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + }, + { + key: 'yes', + get value() { + return translate('Yes'); + }, + }, + { + key: 'no', + get value() { + return translate('No'); + }, + }, +]; + +function CollectionFooter({ + selectedIds, + isSaving, + saveError, + onUpdateSelectedPress, +}: CollectionFooterProps) { + const [monitored, setMonitored] = useState(NO_CHANGE); + const [monitor, setMonitor] = useState(NO_CHANGE); + const [qualityProfileId, setQualityProfileId] = useState( + NO_CHANGE + ); + const [minimumAvailability, setMinimumAvailability] = useState(NO_CHANGE); + const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE); + const [searchOnAdd, setSearchOnAdd] = useState(NO_CHANGE); + + const wasSaving = usePrevious(isSaving); + + const handleSavePress = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (monitored !== NO_CHANGE) { + hasChanges = true; + payload.monitored = monitored === 'monitored'; + } + + if (monitor !== NO_CHANGE) { + hasChanges = true; + payload.monitor = monitor; + } + + if (qualityProfileId !== NO_CHANGE) { + hasChanges = true; + payload.qualityProfileId = qualityProfileId as number; + } + + if (minimumAvailability !== NO_CHANGE) { + hasChanges = true; + payload.minimumAvailability = minimumAvailability as string; + } + + if (rootFolderPath !== NO_CHANGE) { + hasChanges = true; + payload.rootFolderPath = rootFolderPath; + } + + if (searchOnAdd !== NO_CHANGE) { + hasChanges = true; + payload.searchOnAdd = searchOnAdd === 'yes'; + } + + if (hasChanges) { + onUpdateSelectedPress(payload); + } + }, [ + monitor, + monitored, + qualityProfileId, + minimumAvailability, + rootFolderPath, + searchOnAdd, + onUpdateSelectedPress, + ]); + + const handleInputChange = useCallback(({ name, value }: InputChanged) => { + switch (name) { + case 'monitored': + setMonitored(value as string); + break; + case 'monitor': + setMonitor(value as string); + break; + case 'qualityProfileId': + setQualityProfileId(value as string); + break; + case 'minimumAvailability': + setMinimumAvailability(value as string); + break; + case 'rootFolderPath': + setRootFolderPath(value as string); + break; + case 'searchOnAdd': + setSearchOnAdd(value as string); + break; + default: + console.warn(`CollectionFooter Unknown Input: '${name}'`); + } + }, []); + + useEffect(() => { + if (!isSaving && wasSaving && !saveError) { + setMonitored(NO_CHANGE); + setMonitor(NO_CHANGE); + setQualityProfileId(NO_CHANGE); + setMinimumAvailability(NO_CHANGE); + setRootFolderPath(NO_CHANGE); + setSearchOnAdd(NO_CHANGE); + } + }, [ + isSaving, + wasSaving, + saveError, + setMonitored, + setMonitor, + setQualityProfileId, + setMinimumAvailability, + setRootFolderPath, + setSearchOnAdd, + ]); + + const selectedCount = selectedIds.length; + + return ( + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
+ + +
+
+ + {translate('UpdateSelected')} + +
+
+
+
+
+ ); +} + +export default CollectionFooter; diff --git a/frontend/src/Collection/CollectionFooterLabel.js b/frontend/src/Collection/CollectionFooterLabel.js deleted file mode 100644 index 6f8b578df1..0000000000 --- a/frontend/src/Collection/CollectionFooterLabel.js +++ /dev/null @@ -1,40 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import SpinnerIcon from 'Components/SpinnerIcon'; -import { icons } from 'Helpers/Props'; -import styles from './CollectionFooterLabel.css'; - -function CollectionFooterLabel(props) { - const { - className, - label, - isSaving - } = props; - - return ( -
- {label} - - { - isSaving && - - } -
- ); -} - -CollectionFooterLabel.propTypes = { - className: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - isSaving: PropTypes.bool.isRequired -}; - -CollectionFooterLabel.defaultProps = { - className: styles.label -}; - -export default CollectionFooterLabel; diff --git a/frontend/src/Collection/CollectionFooterLabel.tsx b/frontend/src/Collection/CollectionFooterLabel.tsx new file mode 100644 index 0000000000..97c938fbd8 --- /dev/null +++ b/frontend/src/Collection/CollectionFooterLabel.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import { icons } from 'Helpers/Props'; +import styles from './CollectionFooterLabel.css'; + +interface CollectionFooterLabelProps { + className?: string; + label: string; + isSaving: boolean; +} + +function CollectionFooterLabel({ + className = styles.label, + label, + isSaving, +}: CollectionFooterLabelProps) { + return ( +
+ {label} + + {isSaving ? ( + + ) : null} +
+ ); +} + +export default CollectionFooterLabel; diff --git a/frontend/src/Collection/CollectionItemConnector.js b/frontend/src/Collection/CollectionItemConnector.js index 64c8ca40a6..e3ca347729 100644 --- a/frontend/src/Collection/CollectionItemConnector.js +++ b/frontend/src/Collection/CollectionItemConnector.js @@ -9,7 +9,7 @@ function createMapStateToProps() { createCollectionSelector(), (collection) => { // If a movie is deleted this selector may fire before the parent - // selecors, which will result in an undefined movie, if that happens + // selectors, which will result in an undefined movie, if that happens // we want to return early here and again in the render function to avoid // trying to show a movie that has no information available. @@ -22,7 +22,7 @@ function createMapStateToProps() { return { ...collection, movies: [...collection.movies].sort((a, b) => b.year - a.year), - genres: Array.from(new Set(allGenres)).slice(0, 3) + genres: Array.from(new Set(allGenres)) }; } ); diff --git a/frontend/src/Collection/Edit/EditCollectionModal.js b/frontend/src/Collection/Edit/EditCollectionModal.js deleted file mode 100644 index 1017aad0ea..0000000000 --- a/frontend/src/Collection/Edit/EditCollectionModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import EditCollectionModalContentConnector from './EditCollectionModalContentConnector'; - -function EditCollectionModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -EditCollectionModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default EditCollectionModal; diff --git a/frontend/src/Collection/Edit/EditCollectionModalConnector.js b/frontend/src/Collection/Edit/EditCollectionModalConnector.js deleted file mode 100644 index c73e7f186c..0000000000 --- a/frontend/src/Collection/Edit/EditCollectionModalConnector.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { clearPendingChanges } from 'Store/Actions/baseActions'; -import EditCollectionModal from './EditCollectionModal'; - -const mapDispatchToProps = { - clearPendingChanges -}; - -class EditCollectionModalConnector extends Component { - - // - // Listeners - - onModalClose = () => { - this.props.clearPendingChanges({ section: 'movieCollections' }); - this.props.onModalClose(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -EditCollectionModalConnector.propTypes = { - onModalClose: PropTypes.func.isRequired, - clearPendingChanges: PropTypes.func.isRequired -}; - -export default connect(undefined, mapDispatchToProps)(EditCollectionModalConnector); diff --git a/frontend/src/Collection/Edit/EditCollectionModalContent.js b/frontend/src/Collection/Edit/EditCollectionModalContent.js deleted file mode 100644 index 5063fb28d7..0000000000 --- a/frontend/src/Collection/Edit/EditCollectionModalContent.js +++ /dev/null @@ -1,190 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes } from 'Helpers/Props'; -import MoviePoster from 'Movie/MoviePoster'; -import translate from 'Utilities/String/translate'; -import styles from './EditCollectionModalContent.css'; - -class EditCollectionModalContent extends Component { - - // - // Listeners - - onSavePress = () => { - const { - onSavePress - } = this.props; - - onSavePress(false); - }; - - // - // Render - - render() { - const { - title, - images, - overview, - item, - isSaving, - onInputChange, - onModalClose, - isSmallScreen, - ...otherProps - } = this.props; - - const { - monitored, - qualityProfileId, - minimumAvailability, - // Id, - rootFolderPath, - tags, - searchOnAdd - } = item; - - return ( - - - {translate('Edit')} - {title} - - - -
- { - !isSmallScreen && -
- -
- } - -
-
- {overview} -
- -
- - {translate('Monitored')} - - - - - - {translate('MinimumAvailability')} - - - - - - {translate('QualityProfile')} - - - - - - {translate('RootFolder')} - - - - - - {translate('Tags')} - - - - - - {translate('SearchOnAdd')} - - - -
-
-
-
- - - - - - {translate('Save')} - - -
- ); - } -} - -EditCollectionModalContent.propTypes = { - collectionId: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, - overview: PropTypes.string.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - item: PropTypes.object.isRequired, - isSaving: PropTypes.bool.isRequired, - isPathChanging: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onInputChange: PropTypes.func.isRequired, - onSavePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default EditCollectionModalContent; diff --git a/frontend/src/Collection/Edit/EditCollectionModalContentConnector.js b/frontend/src/Collection/Edit/EditCollectionModalContentConnector.js deleted file mode 100644 index 32f639ca98..0000000000 --- a/frontend/src/Collection/Edit/EditCollectionModalContentConnector.js +++ /dev/null @@ -1,120 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { saveMovieCollection, setMovieCollectionValue } from 'Store/Actions/movieCollectionActions'; -import createCollectionSelector from 'Store/Selectors/createCollectionSelector'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import selectSettings from 'Store/Selectors/selectSettings'; -import EditCollectionModalContent from './EditCollectionModalContent'; - -function createIsPathChangingSelector() { - return createSelector( - (state) => state.movieCollections.pendingChanges, - createCollectionSelector(), - (pendingChanges, collection) => { - const rootFolderPath = pendingChanges.rootFolderPath; - - if (rootFolderPath == null) { - return false; - } - - return collection.rootFolderPath !== rootFolderPath; - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.movieCollections, - createCollectionSelector(), - createIsPathChangingSelector(), - createDimensionsSelector(), - (moviesState, collection, isPathChanging, dimensions) => { - const { - isSaving, - saveError, - pendingChanges - } = moviesState; - - const movieSettings = { - monitored: collection.monitored, - qualityProfileId: collection.qualityProfileId, - minimumAvailability: collection.minimumAvailability, - rootFolderPath: collection.rootFolderPath, - tags: collection.tags, - searchOnAdd: collection.searchOnAdd - }; - - const settings = selectSettings(movieSettings, pendingChanges, saveError); - - return { - title: collection.title, - images: collection.images, - overview: collection.overview, - isSaving, - saveError, - isPathChanging, - originalPath: collection.path, - item: settings.settings, - isSmallScreen: dimensions.isSmallScreen, - ...settings - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetMovieCollectionValue: setMovieCollectionValue, - dispatchSaveMovieCollection: saveMovieCollection -}; - -class EditCollectionModalContentConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps, prevState) { - if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { - this.props.onModalClose(); - } - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.props.dispatchSetMovieCollectionValue({ name, value }); - }; - - onSavePress = () => { - this.props.dispatchSaveMovieCollection({ - id: this.props.collectionId - }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -EditCollectionModalContentConnector.propTypes = { - collectionId: PropTypes.number, - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - dispatchSetMovieCollectionValue: PropTypes.func.isRequired, - dispatchSaveMovieCollection: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(EditCollectionModalContentConnector); diff --git a/frontend/src/Collection/Edit/EditMovieCollectionModal.tsx b/frontend/src/Collection/Edit/EditMovieCollectionModal.tsx new file mode 100644 index 0000000000..02c3c7b21a --- /dev/null +++ b/frontend/src/Collection/Edit/EditMovieCollectionModal.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditMovieCollectionModalContent, { + EditMovieCollectionModalContentProps, +} from './EditMovieCollectionModalContent'; + +interface EditMovieCollectionModalProps + extends EditMovieCollectionModalContentProps { + isOpen: boolean; +} + +function EditMovieCollectionModal({ + isOpen, + onModalClose, + ...otherProps +}: EditMovieCollectionModalProps) { + const dispatch = useDispatch(); + + const handleModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'movieCollections' })); + onModalClose(); + }, [dispatch, onModalClose]); + + return ( + + + + ); +} + +export default EditMovieCollectionModal; diff --git a/frontend/src/Collection/Edit/EditCollectionModalContent.css b/frontend/src/Collection/Edit/EditMovieCollectionModalContent.css similarity index 100% rename from frontend/src/Collection/Edit/EditCollectionModalContent.css rename to frontend/src/Collection/Edit/EditMovieCollectionModalContent.css diff --git a/frontend/src/Collection/Edit/EditCollectionModalContent.css.d.ts b/frontend/src/Collection/Edit/EditMovieCollectionModalContent.css.d.ts similarity index 100% rename from frontend/src/Collection/Edit/EditCollectionModalContent.css.d.ts rename to frontend/src/Collection/Edit/EditMovieCollectionModalContent.css.d.ts diff --git a/frontend/src/Collection/Edit/EditMovieCollectionModalContent.tsx b/frontend/src/Collection/Edit/EditMovieCollectionModalContent.tsx new file mode 100644 index 0000000000..6d8dc1ba50 --- /dev/null +++ b/frontend/src/Collection/Edit/EditMovieCollectionModalContent.tsx @@ -0,0 +1,214 @@ +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import useMovieCollection from 'Collection/useMovieCollection'; +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 SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { inputTypes } from 'Helpers/Props'; +import MoviePoster from 'Movie/MoviePoster'; +import { + saveMovieCollection, + setMovieCollectionValue, +} from 'Store/Actions/movieCollectionActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import styles from './EditMovieCollectionModalContent.css'; + +export interface EditMovieCollectionModalContentProps { + collectionId: number; + onModalClose: () => void; +} + +function EditMovieCollectionModalContent({ + collectionId, + onModalClose, +}: EditMovieCollectionModalContentProps) { + const dispatch = useDispatch(); + + const { + title, + overview, + monitored, + qualityProfileId, + minimumAvailability, + rootFolderPath, + searchOnAdd, + images, + tags, + } = useMovieCollection(collectionId)!; + + const { isSaving, saveError, pendingChanges } = useSelector( + (state: AppState) => state.movieCollections + ); + const { isSmallScreen } = useSelector(createDimensionsSelector()); + + const wasSaving = usePrevious(isSaving); + + const { settings, ...otherSettings } = useMemo(() => { + return selectSettings( + { + monitored, + minimumAvailability, + qualityProfileId, + rootFolderPath, + searchOnAdd, + tags, + }, + pendingChanges, + saveError + ); + }, [ + monitored, + minimumAvailability, + qualityProfileId, + rootFolderPath, + searchOnAdd, + tags, + pendingChanges, + saveError, + ]); + + const handleInputChange = useCallback( + ({ name, value }: InputChanged) => { + // @ts-expect-error actions aren't typed + dispatch(setMovieCollectionValue({ name, value })); + }, + [dispatch] + ); + + const handleSavePress = useCallback(() => { + dispatch( + saveMovieCollection({ + id: collectionId, + }) + ); + }, [collectionId, dispatch]); + + useEffect(() => { + if (!isSaving && wasSaving && !saveError) { + onModalClose(); + } + }, [isSaving, wasSaving, saveError, onModalClose]); + + return ( + + + {translate('EditMovieCollectionModalHeader', { title })} + + + +
+ {isSmallScreen ? null : ( +
+ +
+ )} + +
+
{overview}
+ +
+ + {translate('Monitored')} + + + + + + {translate('MinimumAvailability')} + + + + + + {translate('QualityProfile')} + + + + + + {translate('RootFolder')} + + + + + + {translate('Tags')} + + + + + + {translate('SearchOnAdd')} + + + +
+
+
+
+ + + + + + {translate('Save')} + + +
+ ); +} + +export default EditMovieCollectionModalContent; diff --git a/frontend/src/Collection/Menus/CollectionFilterMenu.js b/frontend/src/Collection/Menus/CollectionFilterMenu.js deleted file mode 100644 index 652f2accfe..0000000000 --- a/frontend/src/Collection/Menus/CollectionFilterMenu.js +++ /dev/null @@ -1,41 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import CollectionFilterModalConnector from 'Collection/CollectionFilterModalConnector'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import { align } from 'Helpers/Props'; - -function CollectionFilterMenu(props) { - const { - selectedFilterKey, - filters, - customFilters, - isDisabled, - onFilterSelect - } = props; - - return ( - - ); -} - -CollectionFilterMenu.propTypes = { - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool.isRequired, - onFilterSelect: PropTypes.func.isRequired -}; - -CollectionFilterMenu.defaultProps = { - showCustomFilters: false -}; - -export default CollectionFilterMenu; diff --git a/frontend/src/Collection/Menus/MovieCollectionFilterMenu.tsx b/frontend/src/Collection/Menus/MovieCollectionFilterMenu.tsx new file mode 100644 index 0000000000..24059c5c65 --- /dev/null +++ b/frontend/src/Collection/Menus/MovieCollectionFilterMenu.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { CustomFilter, Filter } from 'App/State/AppState'; +import MovieCollectionFilterModal from 'Collection/MovieCollectionFilterModal'; +import FilterMenu from 'Components/Menu/FilterMenu'; + +interface MovieCollectionFilterMenuProps { + selectedFilterKey: string | number; + filters: Filter[]; + customFilters: CustomFilter[]; + isDisabled: boolean; + onFilterSelect: (filter: number | string) => void; +} + +function MovieCollectionFilterMenu({ + selectedFilterKey, + filters, + customFilters, + isDisabled, + onFilterSelect, +}: MovieCollectionFilterMenuProps) { + return ( + + ); +} + +export default MovieCollectionFilterMenu; diff --git a/frontend/src/Collection/Menus/CollectionSortMenu.js b/frontend/src/Collection/Menus/MovieCollectionSortMenu.tsx similarity index 58% rename from frontend/src/Collection/Menus/CollectionSortMenu.js rename to frontend/src/Collection/Menus/MovieCollectionSortMenu.tsx index 29c8cf12af..8b8875ea42 100644 --- a/frontend/src/Collection/Menus/CollectionSortMenu.js +++ b/frontend/src/Collection/Menus/MovieCollectionSortMenu.tsx @@ -1,24 +1,26 @@ -import PropTypes from 'prop-types'; import React from 'react'; import MenuContent from 'Components/Menu/MenuContent'; import SortMenu from 'Components/Menu/SortMenu'; import SortMenuItem from 'Components/Menu/SortMenuItem'; -import { align, sortDirections } from 'Helpers/Props'; +import { align } from 'Helpers/Props'; +import { SortDirection } from 'Helpers/Props/sortDirections'; import translate from 'Utilities/String/translate'; -function CollectionSortMenu(props) { - const { - sortKey, - sortDirection, - isDisabled, - onSortSelect - } = props; +interface MovieCollectionSortMenuProps { + sortKey?: string; + sortDirection?: SortDirection; + isDisabled: boolean; + onSortSelect(sortKey: string): void; +} +function MovieCollectionSortMenu({ + sortKey, + sortDirection, + isDisabled, + onSortSelect, +}: MovieCollectionSortMenuProps) { return ( - + {translate('Title')} + state.movieCollections.items + ); + const filterBuilderProps = useSelector( + (state: AppState) => state.movieCollections.filterBuilderProps + ); + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: { selectedFilterKey: string | number }) => { + dispatch(setMovieCollectionsFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Collection/NoCollection.css b/frontend/src/Collection/NoMovieCollections.css similarity index 100% rename from frontend/src/Collection/NoCollection.css rename to frontend/src/Collection/NoMovieCollections.css diff --git a/frontend/src/Collection/NoCollection.css.d.ts b/frontend/src/Collection/NoMovieCollections.css.d.ts similarity index 100% rename from frontend/src/Collection/NoCollection.css.d.ts rename to frontend/src/Collection/NoMovieCollections.css.d.ts diff --git a/frontend/src/Collection/NoCollection.js b/frontend/src/Collection/NoMovieCollections.tsx similarity index 56% rename from frontend/src/Collection/NoCollection.js rename to frontend/src/Collection/NoMovieCollections.tsx index 1e76fd0142..d4ccfa8cd3 100644 --- a/frontend/src/Collection/NoCollection.js +++ b/frontend/src/Collection/NoMovieCollections.tsx @@ -1,13 +1,14 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Button from 'Components/Link/Button'; import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; -import styles from './NoCollection.css'; +import styles from './NoMovieCollections.css'; -function NoCollection(props) { - const { totalItems } = props; +interface NoMovieCollectionsProps { + totalItems: number; +} +function NoMovieCollections({ totalItems }: NoMovieCollectionsProps) { if (totalItems > 0) { return (
@@ -20,24 +21,16 @@ function NoCollection(props) { return (
-
- {translate('NoCollections')} -
+
{translate('NoCollections')}
-
-
@@ -45,8 +38,4 @@ function NoCollection(props) { ); } -NoCollection.propTypes = { - totalItems: PropTypes.number.isRequired -}; - -export default NoCollection; +export default NoMovieCollections; diff --git a/frontend/src/Collection/Overview/CollectionMovie.css b/frontend/src/Collection/Overview/CollectionMovie.css index 74a0b7266d..3629bef4b7 100644 --- a/frontend/src/Collection/Overview/CollectionMovie.css +++ b/frontend/src/Collection/Overview/CollectionMovie.css @@ -9,12 +9,13 @@ $hoverScale: 1.05; box-shadow: 0 0 10px var(--black); transition: all 200ms ease-in; - .poster { + .poster, + .overlayTitle { opacity: 0.5; transition: opacity 100ms linear 100ms; } - .overlayTitle { + .overlayHoverTitle { opacity: 1; transition: opacity 100ms linear 100ms; } @@ -31,7 +32,22 @@ $hoverScale: 1.05; background-color: var(--defaultColor); } -.overlay { +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: var(--offWhite); + text-align: center; + font-size: 20px; +} + +.overlayHover { position: absolute; top: 0; left: 0; @@ -42,10 +58,10 @@ $hoverScale: 1.05; height: 100%; } -.overlayTitle { +.overlayHoverTitle { padding: 5px; color: var(--offWhite); - text-align: left; + text-align: center; font-weight: bold; font-size: 15px; opacity: 0; diff --git a/frontend/src/Collection/Overview/CollectionMovie.css.d.ts b/frontend/src/Collection/Overview/CollectionMovie.css.d.ts index 278e2d3272..ddfdbb1d6f 100644 --- a/frontend/src/Collection/Overview/CollectionMovie.css.d.ts +++ b/frontend/src/Collection/Overview/CollectionMovie.css.d.ts @@ -10,7 +10,8 @@ interface CssExports { 'externalLinks': string; 'link': string; 'monitorToggleButton': string; - 'overlay': string; + 'overlayHover': string; + 'overlayHoverTitle': string; 'overlayTitle': string; 'poster': string; 'posterContainer': string; diff --git a/frontend/src/Collection/Overview/CollectionMovie.js b/frontend/src/Collection/Overview/CollectionMovie.js index 2eb42afdc0..9043222578 100644 --- a/frontend/src/Collection/Overview/CollectionMovie.js +++ b/frontend/src/Collection/Overview/CollectionMovie.js @@ -1,12 +1,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import AddNewMovieCollectionMovieModal from 'Collection/AddNewMovieCollectionMovieModal'; import Link from 'Components/Link/Link'; import MonitorToggleButton from 'Components/MonitorToggleButton'; -import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import EditMovieModal from 'Movie/Edit/EditMovieModal'; import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; import MoviePoster from 'Movie/MoviePoster'; import translate from 'Utilities/String/translate'; -import AddNewCollectionMovieModal from './../AddNewCollectionMovieModal'; import styles from './CollectionMovie.css'; class CollectionMovie extends Component { @@ -82,6 +82,7 @@ class CollectionMovie extends Component { } = this.props; const { + hasPosterError, isEditMovieModalOpen, isNewAddMovieModalOpen } = this.state; @@ -134,33 +135,38 @@ class CollectionMovie extends Component { onLoad={this.onPosterLoad} /> -
-
+ { + hasPosterError && +
+ {title} +
+ } + +
+
{title} {year > 0 ? `(${year})` : ''}
{ id ? -
- -
: + : null }
- - - {`${missingMovies} missing movie(s)`} + {translate('CountMissingMoviesFromLibrary', { count: missingMovies })} @@ -211,7 +212,7 @@ class CollectionOverview extends Component { /> { - } @@ -242,12 +243,10 @@ class CollectionOverview extends Component { size={sizes.MEDIUM} > - - {genres.join(', ')} - + } @@ -312,7 +311,7 @@ class CollectionOverview extends Component {
- { + const scrollOffset = this._grid.getOffsetForCell({ + rowIndex, + columnIndex + }); + + this._gridScrollToPosition(scrollOffset); + }; + + _gridScrollToPosition = ({ scrollTop = 0, scrollLeft = 0 }) => { + this.props.scroller?.scrollTo({ top: scrollTop, left: scrollLeft }); + }; + // // Listeners diff --git a/frontend/src/Collection/useMovieCollection.ts b/frontend/src/Collection/useMovieCollection.ts new file mode 100644 index 0000000000..d9e4598126 --- /dev/null +++ b/frontend/src/Collection/useMovieCollection.ts @@ -0,0 +1,21 @@ +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +export function createMovieCollectionSelector(collectionId?: number) { + return createSelector( + (state: AppState) => state.movieCollections.itemMap, + (state: AppState) => state.movieCollections.items, + (itemMap, allMovieCollections) => { + return collectionId + ? allMovieCollections[itemMap[collectionId]] + : undefined; + } + ); +} + +function useMovieCollection(collectionId: number | undefined) { + return useSelector(createMovieCollectionSelector(collectionId)); +} + +export default useMovieCollection; diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js deleted file mode 100644 index 418cbf5e64..0000000000 --- a/frontend/src/Components/Alert.js +++ /dev/null @@ -1,34 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { kinds } from 'Helpers/Props'; -import styles from './Alert.css'; - -function Alert(props) { - const { className, kind, children, ...otherProps } = props; - - return ( -
- {children} -
- ); -} - -Alert.propTypes = { - className: PropTypes.string, - kind: PropTypes.oneOf(kinds.all), - children: PropTypes.node.isRequired -}; - -Alert.defaultProps = { - className: styles.alert, - kind: kinds.INFO -}; - -export default Alert; diff --git a/frontend/src/Components/Alert.tsx b/frontend/src/Components/Alert.tsx new file mode 100644 index 0000000000..92c89e7413 --- /dev/null +++ b/frontend/src/Components/Alert.tsx @@ -0,0 +1,18 @@ +import classNames from 'classnames'; +import React from 'react'; +import { Kind } from 'Helpers/Props/kinds'; +import styles from './Alert.css'; + +interface AlertProps { + className?: string; + kind?: Extract; + children: React.ReactNode; +} + +function Alert(props: AlertProps) { + const { className = styles.alert, kind = 'info', children } = props; + + return
{children}
; +} + +export default Alert; diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js deleted file mode 100644 index c5a4d164c1..0000000000 --- a/frontend/src/Components/Card.js +++ /dev/null @@ -1,60 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Link from 'Components/Link/Link'; -import styles from './Card.css'; - -class Card extends Component { - - // - // Render - - render() { - const { - className, - overlayClassName, - overlayContent, - children, - onPress - } = this.props; - - if (overlayContent) { - return ( -
- - -
- {children} -
-
- ); - } - - return ( - - {children} - - ); - } -} - -Card.propTypes = { - className: PropTypes.string.isRequired, - overlayClassName: PropTypes.string.isRequired, - overlayContent: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, - onPress: PropTypes.func.isRequired -}; - -Card.defaultProps = { - className: styles.card, - overlayClassName: styles.overlay, - overlayContent: false -}; - -export default Card; diff --git a/frontend/src/Components/Card.tsx b/frontend/src/Components/Card.tsx new file mode 100644 index 0000000000..24588c841c --- /dev/null +++ b/frontend/src/Components/Card.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import Link, { LinkProps } from 'Components/Link/Link'; +import styles from './Card.css'; + +interface CardProps extends Pick { + // TODO: Consider using different properties for classname depending if it's overlaying content or not + className?: string; + overlayClassName?: string; + overlayContent?: boolean; + children: React.ReactNode; +} + +function Card(props: CardProps) { + const { + className = styles.card, + overlayClassName = styles.overlay, + overlayContent = false, + children, + onPress, + } = props; + + if (overlayContent) { + return ( +
+ + +
{children}
+
+ ); + } + + return ( + + {children} + + ); +} + +export default Card; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js deleted file mode 100644 index be2c87c550..0000000000 --- a/frontend/src/Components/DescriptionList/DescriptionList.js +++ /dev/null @@ -1,33 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import styles from './DescriptionList.css'; - -class DescriptionList extends Component { - - // - // Render - - render() { - const { - className, - children - } = this.props; - - return ( -
- {children} -
- ); - } -} - -DescriptionList.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node -}; - -DescriptionList.defaultProps = { - className: styles.descriptionList -}; - -export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.tsx b/frontend/src/Components/DescriptionList/DescriptionList.tsx new file mode 100644 index 0000000000..6deee77e5e --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import styles from './DescriptionList.css'; + +interface DescriptionListProps { + className?: string; + children?: React.ReactNode; +} + +function DescriptionList(props: DescriptionListProps) { + const { className = styles.descriptionList, children } = props; + + return
{children}
; +} + +export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js deleted file mode 100644 index 9315570458..0000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItem.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import DescriptionListItemDescription from './DescriptionListItemDescription'; -import DescriptionListItemTitle from './DescriptionListItemTitle'; - -class DescriptionListItem extends Component { - - // - // Render - - render() { - const { - className, - titleClassName, - descriptionClassName, - title, - data - } = this.props; - - return ( -
- - {title} - - - - {data} - -
- ); - } -} - -DescriptionListItem.propTypes = { - className: PropTypes.string, - titleClassName: PropTypes.string, - descriptionClassName: PropTypes.string, - title: PropTypes.string, - data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) -}; - -export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.tsx b/frontend/src/Components/DescriptionList/DescriptionListItem.tsx new file mode 100644 index 0000000000..13a7efdd03 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import DescriptionListItemDescription, { + DescriptionListItemDescriptionProps, +} from './DescriptionListItemDescription'; +import DescriptionListItemTitle, { + DescriptionListItemTitleProps, +} from './DescriptionListItemTitle'; + +interface DescriptionListItemProps { + className?: string; + titleClassName?: DescriptionListItemTitleProps['className']; + descriptionClassName?: DescriptionListItemDescriptionProps['className']; + title?: DescriptionListItemTitleProps['children']; + data?: DescriptionListItemDescriptionProps['children']; +} + +function DescriptionListItem(props: DescriptionListItemProps) { + const { className, titleClassName, descriptionClassName, title, data } = + props; + + return ( +
+ + {title} + + + + {data} + +
+ ); +} + +export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js deleted file mode 100644 index 4ef3c015e6..0000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js +++ /dev/null @@ -1,27 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './DescriptionListItemDescription.css'; - -function DescriptionListItemDescription(props) { - const { - className, - children - } = props; - - return ( -
- {children} -
- ); -} - -DescriptionListItemDescription.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) -}; - -DescriptionListItemDescription.defaultProps = { - className: styles.description -}; - -export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx new file mode 100644 index 0000000000..e08c117dc8 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx @@ -0,0 +1,17 @@ +import React, { ReactNode } from 'react'; +import styles from './DescriptionListItemDescription.css'; + +export interface DescriptionListItemDescriptionProps { + className?: string; + children?: ReactNode; +} + +function DescriptionListItemDescription( + props: DescriptionListItemDescriptionProps +) { + const { className = styles.description, children } = props; + + return
{children}
; +} + +export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js deleted file mode 100644 index e1632c1cfe..0000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js +++ /dev/null @@ -1,27 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './DescriptionListItemTitle.css'; - -function DescriptionListItemTitle(props) { - const { - className, - children - } = props; - - return ( -
- {children} -
- ); -} - -DescriptionListItemTitle.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.string -}; - -DescriptionListItemTitle.defaultProps = { - className: styles.title -}; - -export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx new file mode 100644 index 0000000000..59ea6955c0 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx @@ -0,0 +1,15 @@ +import React, { ReactNode } from 'react'; +import styles from './DescriptionListItemTitle.css'; + +export interface DescriptionListItemTitleProps { + className?: string; + children?: ReactNode; +} + +function DescriptionListItemTitle(props: DescriptionListItemTitleProps) { + const { className = styles.title, children } = props; + + return
{children}
; +} + +export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js deleted file mode 100644 index a111df70e4..0000000000 --- a/frontend/src/Components/DragPreviewLayer.js +++ /dev/null @@ -1,22 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './DragPreviewLayer.css'; - -function DragPreviewLayer({ children, ...otherProps }) { - return ( -
- {children} -
- ); -} - -DragPreviewLayer.propTypes = { - children: PropTypes.node, - className: PropTypes.string -}; - -DragPreviewLayer.defaultProps = { - className: styles.dragLayer -}; - -export default DragPreviewLayer; diff --git a/frontend/src/Components/DragPreviewLayer.tsx b/frontend/src/Components/DragPreviewLayer.tsx new file mode 100644 index 0000000000..2e578504bc --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './DragPreviewLayer.css'; + +interface DragPreviewLayerProps { + className?: string; + children?: React.ReactNode; +} + +function DragPreviewLayer({ + className = styles.dragLayer, + children, + ...otherProps +}: DragPreviewLayerProps) { + return ( +
+ {children} +
+ ); +} + +export default DragPreviewLayer; diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js deleted file mode 100644 index 88412ad19a..0000000000 --- a/frontend/src/Components/Error/ErrorBoundary.js +++ /dev/null @@ -1,62 +0,0 @@ -import * as sentry from '@sentry/browser'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -class ErrorBoundary extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - error: null, - info: null - }; - } - - componentDidCatch(error, info) { - this.setState({ - error, - info - }); - - sentry.captureException(error); - } - - // - // Render - - render() { - const { - children, - errorComponent: ErrorComponent, - ...otherProps - } = this.props; - - const { - error, - info - } = this.state; - - if (error) { - return ( - - ); - } - - return children; - } -} - -ErrorBoundary.propTypes = { - children: PropTypes.node.isRequired, - errorComponent: PropTypes.elementType.isRequired -}; - -export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundary.tsx b/frontend/src/Components/Error/ErrorBoundary.tsx new file mode 100644 index 0000000000..3dd9ebff2f --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundary.tsx @@ -0,0 +1,53 @@ +import * as sentry from '@sentry/browser'; +import React, { Component, ErrorInfo } from 'react'; + +interface ErrorBoundaryProps { + children: React.ReactNode; + errorComponent: React.ElementType; + onModalClose?: () => void; +} + +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, + onModalClose, + } = 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 1a67d210c8..708172dda7 100644 --- a/frontend/src/Components/Error/ErrorBoundaryError.tsx +++ b/frontend/src/Components/Error/ErrorBoundaryError.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; import StackTrace from 'stacktrace-js'; +import translate from 'Utilities/String/translate'; import styles from './ErrorBoundaryError.css'; -interface ErrorBoundaryErrorProps { +export interface ErrorBoundaryErrorProps { className: string; messageClassName: string; detailsClassName: string; @@ -18,7 +19,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) { className = styles.container, messageClassName = styles.message, detailsClassName = styles.details, - message = 'There was an error loading this content', + message = translate('ErrorLoadingContent'), error, info, } = props; diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js deleted file mode 100644 index 8243fd00c9..0000000000 --- a/frontend/src/Components/FieldSet.js +++ /dev/null @@ -1,41 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { sizes } from 'Helpers/Props'; -import styles from './FieldSet.css'; - -class FieldSet extends Component { - - // - // Render - - render() { - const { - size, - legend, - children - } = this.props; - - return ( -
- - {legend} - - {children} -
- ); - } - -} - -FieldSet.propTypes = { - size: PropTypes.oneOf(sizes.all).isRequired, - legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), - children: PropTypes.node -}; - -FieldSet.defaultProps = { - size: sizes.MEDIUM -}; - -export default FieldSet; diff --git a/frontend/src/Components/FieldSet.tsx b/frontend/src/Components/FieldSet.tsx new file mode 100644 index 0000000000..c2ff03a7f5 --- /dev/null +++ b/frontend/src/Components/FieldSet.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames'; +import React, { ComponentProps } from 'react'; +import { sizes } from 'Helpers/Props'; +import { Size } from 'Helpers/Props/sizes'; +import styles from './FieldSet.css'; + +interface FieldSetProps { + size?: Size; + legend?: ComponentProps<'legend'>['children']; + children?: React.ReactNode; +} + +function FieldSet({ size = sizes.MEDIUM, legend, children }: FieldSetProps) { + return ( +
+ + {legend} + + {children} +
+ ); +} + +export default FieldSet; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js deleted file mode 100644 index 6b58dbb8c2..0000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModal.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import FileBrowserModalContentConnector from './FileBrowserModalContentConnector'; -import styles from './FileBrowserModal.css'; - -class FileBrowserModal extends Component { - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -FileBrowserModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.tsx b/frontend/src/Components/FileBrowser/FileBrowserModal.tsx new file mode 100644 index 0000000000..0925890de2 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import FileBrowserModalContent, { + FileBrowserModalContentProps, +} from './FileBrowserModalContent'; +import styles from './FileBrowserModal.css'; + +interface FileBrowserModalProps extends FileBrowserModalContentProps { + isOpen: boolean; + onModalClose: () => void; +} + +function FileBrowserModal(props: FileBrowserModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js deleted file mode 100644 index 4241bdf6dd..0000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js +++ /dev/null @@ -1,250 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import PathInput from 'Components/Form/PathInput'; -import Button from 'Components/Link/Button'; -import Link from 'Components/Link/Link'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -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 ( - - - File Browser - - - - { - isWindowsService && - - - {translate('MappedDrivesRunningAsService')} - . - - } - - - - - { - !!error && -
- {translate('ErrorLoadingContents')} -
- } - - { - isPopulated && !error && - - - { - emptyParent && - - } - - { - !emptyParent && parent && - - } - - { - directories.map((directory) => { - return ( - - ); - }) - } - - { - files.map((file) => { - return ( - - ); - }) - } - -
- } -
-
- - - { - isFetching && - - } - - - - - -
- ); - } -} - -FileBrowserModalContent.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - parent: PropTypes.string, - currentPath: PropTypes.string.isRequired, - directories: PropTypes.arrayOf(PropTypes.object).isRequired, - files: PropTypes.arrayOf(PropTypes.object).isRequired, - isWindowsService: PropTypes.bool.isRequired, - onFetchPaths: PropTypes.func.isRequired, - onClearPaths: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default FileBrowserModalContent; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx new file mode 100644 index 0000000000..7b2b9acf48 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +import { PathInputInternal } from 'Components/Form/PathInput'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Scroller from 'Components/Scroller/Scroller'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import { clearPaths, fetchPaths } from 'Store/Actions/pathActions'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import createPathsSelector from './createPathsSelector'; +import FileBrowserRow from './FileBrowserRow'; +import styles from './FileBrowserModalContent.css'; + +const columns: Column[] = [ + { + name: 'type', + label: () => translate('Type'), + isVisible: true, + }, + { + name: 'name', + label: () => translate('Name'), + isVisible: true, + }, +]; + +const handleClearPaths = () => {}; + +export interface FileBrowserModalContentProps { + name: string; + value: string; + includeFiles?: boolean; + onChange: (args: InputChanged) => unknown; + onModalClose: () => void; +} + +function FileBrowserModalContent(props: FileBrowserModalContentProps) { + const { name, value, includeFiles = true, onChange, onModalClose } = props; + + const dispatch = useDispatch(); + + const { isWindows, mode } = useSelector(createSystemStatusSelector()); + const { isFetching, isPopulated, error, parent, directories, files, paths } = + useSelector(createPathsSelector()); + + const [currentPath, setCurrentPath] = useState(value); + const scrollerRef = useRef(null); + const previousValue = usePrevious(value); + + const emptyParent = parent === ''; + const isWindowsService = isWindows && mode === 'service'; + + const handlePathInputChange = useCallback( + ({ value }: InputChanged) => { + setCurrentPath(value); + }, + [] + ); + + const handleRowPress = useCallback( + (path: string) => { + setCurrentPath(path); + + dispatch( + fetchPaths({ + path, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }) + ); + }, + [includeFiles, dispatch, setCurrentPath] + ); + + const handleOkPress = useCallback(() => { + onChange({ + name, + value: currentPath, + }); + + dispatch(clearPaths()); + onModalClose(); + }, [name, currentPath, dispatch, onChange, onModalClose]); + + const handleFetchPaths = useCallback( + (path: string) => { + dispatch( + fetchPaths({ + path, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }) + ); + }, + [includeFiles, dispatch] + ); + + useEffect(() => { + if (value !== previousValue && value !== currentPath) { + setCurrentPath(value); + } + }, [value, previousValue, currentPath, setCurrentPath]); + + useEffect( + () => { + dispatch( + fetchPaths({ + path: currentPath, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }) + ); + + return () => { + dispatch(clearPaths()); + }; + }, + // This should only run once when the component mounts, + // so we don't need to include the other dependencies. + // eslint-disable-next-line react-hooks/exhaustive-deps + [dispatch] + ); + + return ( + + {translate('FileBrowser')} + + + {isWindowsService ? ( + + + + ) : null} + + + + + {error ?
{translate('ErrorLoadingContents')}
: null} + + {isPopulated && !error ? ( + + + {emptyParent ? ( + + ) : null} + + {!emptyParent && parent ? ( + + ) : null} + + {directories.map((directory) => { + return ( + + ); + })} + + {files.map((file) => { + return ( + + ); + })} + +
+ ) : null} +
+
+ + + {isFetching ? ( + + ) : null} + + + + + +
+ ); +} + +export default FileBrowserModalContent; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js deleted file mode 100644 index 1a3a41ef0a..0000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js +++ /dev/null @@ -1,119 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearPaths, fetchPaths } from 'Store/Actions/pathActions'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import FileBrowserModalContent from './FileBrowserModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.paths, - createSystemStatusSelector(), - (paths, systemStatus) => { - const { - isFetching, - isPopulated, - error, - parent, - currentPath, - directories, - files - } = paths; - - const filteredPaths = _.filter([...directories, ...files], ({ path }) => { - return path.toLowerCase().startsWith(currentPath.toLowerCase()); - }); - - return { - isFetching, - isPopulated, - error, - parent, - currentPath, - directories, - files, - paths: filteredPaths, - isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchPaths: fetchPaths, - dispatchClearPaths: clearPaths -}; - -class FileBrowserModalContentConnector extends Component { - - // Lifecycle - - componentDidMount() { - const { - value, - includeFiles, - dispatchFetchPaths - } = this.props; - - dispatchFetchPaths({ - path: value, - allowFoldersWithoutTrailingSlashes: true, - includeFiles - }); - } - - // - // Listeners - - onFetchPaths = (path) => { - const { - includeFiles, - dispatchFetchPaths - } = this.props; - - dispatchFetchPaths({ - path, - allowFoldersWithoutTrailingSlashes: true, - includeFiles - }); - }; - - onClearPaths = () => { - // this.props.dispatchClearPaths(); - }; - - onModalClose = () => { - this.props.dispatchClearPaths(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -FileBrowserModalContentConnector.propTypes = { - value: PropTypes.string, - includeFiles: PropTypes.bool.isRequired, - dispatchFetchPaths: PropTypes.func.isRequired, - dispatchClearPaths: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -FileBrowserModalContentConnector.defaultProps = { - includeFiles: false -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector); diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js deleted file mode 100644 index 06bb3029dd..0000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserRow.js +++ /dev/null @@ -1,62 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRowButton from 'Components/Table/TableRowButton'; -import { icons } from 'Helpers/Props'; -import styles from './FileBrowserRow.css'; - -function getIconName(type) { - switch (type) { - case 'computer': - return icons.COMPUTER; - case 'drive': - return icons.DRIVE; - case 'file': - return icons.FILE; - case 'parent': - return icons.PARENT; - default: - return icons.FOLDER; - } -} - -class FileBrowserRow extends Component { - - // - // Listeners - - onPress = () => { - this.props.onPress(this.props.path); - }; - - // - // Render - - render() { - const { - type, - name - } = this.props; - - return ( - - - - - - {name} - - ); - } - -} - -FileBrowserRow.propTypes = { - type: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - onPress: PropTypes.func.isRequired -}; - -export default FileBrowserRow; diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.tsx b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx new file mode 100644 index 0000000000..fe47f1664f --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { PathType } from 'App/State/PathsAppState'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowButton from 'Components/Table/TableRowButton'; +import { icons } from 'Helpers/Props'; +import styles from './FileBrowserRow.css'; + +function getIconName(type: PathType) { + switch (type) { + case 'computer': + return icons.COMPUTER; + case 'drive': + return icons.DRIVE; + case 'file': + return icons.FILE; + case 'parent': + return icons.PARENT; + default: + return icons.FOLDER; + } +} + +interface FileBrowserRowProps { + type: PathType; + name: string; + path: string; + onPress: (path: string) => void; +} + +function FileBrowserRow(props: FileBrowserRowProps) { + const { type, name, path, onPress } = props; + + const handlePress = useCallback(() => { + onPress(path); + }, [path, onPress]); + + return ( + + + + + + {name} + + ); +} + +export default FileBrowserRow; diff --git a/frontend/src/Components/FileBrowser/createPathsSelector.ts b/frontend/src/Components/FileBrowser/createPathsSelector.ts new file mode 100644 index 0000000000..5da830bd5e --- /dev/null +++ b/frontend/src/Components/FileBrowser/createPathsSelector.ts @@ -0,0 +1,36 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createPathsSelector() { + return createSelector( + (state: AppState) => state.paths, + (paths) => { + const { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files, + } = paths; + + const filteredPaths = [...directories, ...files].filter(({ path }) => { + return path.toLowerCase().startsWith(currentPath.toLowerCase()); + }); + + return { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files, + paths: filteredPaths, + }; + } + ); +} + +export default createPathsSelector; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index b5f52bf8be..9fd450d2ef 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -16,6 +16,7 @@ import MovieFilterBuilderRowValue from './MovieFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; +import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue'; import ReleaseStatusFilterBuilderRowValue from './ReleaseStatusFilterBuilderRowValue'; import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; import styles from './FilterBuilderRow.css'; @@ -80,6 +81,9 @@ function getRowValueConnector(selectedFilterBuilderProp) { case filterBuilderValueTypes.QUALITY_PROFILE: return QualityProfileFilterBuilderRowValue; + case filterBuilderValueTypes.QUEUE_STATUS: + return QueueStatusFilterBuilderRowValue; + case filterBuilderValueTypes.MOVIE: return MovieFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js index a3f5a22115..edfcfc7f79 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import TagInput from 'Components/Form/TagInput'; +import TagInput from 'Components/Form/Tag/TagInput'; import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props'; import tagShape from 'Helpers/Props/Shapes/tagShape'; import convertToBytes from 'Utilities/Number/convertToBytes'; @@ -58,7 +58,7 @@ function getValue(input, selectedFilterBuilderProp) { if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) { const { numberFractionDigits = 0 } = selectedFilterBuilderProp; - return Number(input).toFixed(numberFractionDigits); + return Number(Number(input).toFixed(numberFractionDigits)); } return input; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js index 7b6d6313ad..063a973466 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import TagInputTag from 'Components/Form/TagInputTag'; +import TagInputTag from 'Components/Form/Tag/TagInputTag'; import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './FilterBuilderRowValueTag.css'; diff --git a/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx new file mode 100644 index 0000000000..1127493a5c --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +const statusTagList = [ + { + id: 'queued', + get name() { + return translate('Queued'); + }, + }, + { + id: 'paused', + get name() { + return translate('Paused'); + }, + }, + { + id: 'downloading', + get name() { + return translate('Downloading'); + }, + }, + { + id: 'completed', + get name() { + return translate('Completed'); + }, + }, + { + id: 'failed', + get name() { + return translate('Failed'); + }, + }, + { + id: 'warning', + get name() { + return translate('Warning'); + }, + }, + { + id: 'delay', + get name() { + return translate('Delay'); + }, + }, + { + id: 'downloadClientUnavailable', + get name() { + return translate('DownloadClientUnavailable'); + }, + }, + { + id: 'fallback', + get name() { + return translate('Fallback'); + }, + }, +]; + +function QueueStatusFilterBuilderRowValue(props: FilterBuilderRowValueProps) { + return ; +} + +export default QueueStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/ReleaseStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ReleaseStatusFilterBuilderRowValue.js index a2aabae20c..6501c147a2 100644 --- a/frontend/src/Components/Filter/Builder/ReleaseStatusFilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/ReleaseStatusFilterBuilderRowValue.js @@ -2,8 +2,11 @@ import React from 'react'; import translate from 'Utilities/String/translate'; import FilterBuilderRowValue from './FilterBuilderRowValue'; -const protocols = [ - { id: 'tba', name: 'TBA' }, +const statusTagList = [ + { id: 'tba', + get name() { + return translate('Tba'); + } }, { id: 'announced', get name() { @@ -33,7 +36,7 @@ const protocols = [ function ReleaseStatusFilterBuilderRowValue(props) { return ( ); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js index 3b0660040d..909ef9c007 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -53,9 +53,11 @@ function CustomFiltersModalContent(props) {
+
+ {translate('FilterMoviePropertiesOnlyNotFileWarning')} diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js deleted file mode 100644 index d35969c4c3..0000000000 --- a/frontend/src/Components/Form/AutoCompleteInput.js +++ /dev/null @@ -1,98 +0,0 @@ -import jdu from 'jdu'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import AutoSuggestInput from './AutoSuggestInput'; - -class AutoCompleteInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - suggestions: [] - }; - } - - // - // Control - - getSuggestionValue(item) { - return item; - } - - renderSuggestion(item) { - return item; - } - - // - // Listeners - - onInputChange = (event, { newValue }) => { - this.props.onChange({ - name: this.props.name, - value: newValue - }); - }; - - onInputBlur = () => { - this.setState({ suggestions: [] }); - }; - - onSuggestionsFetchRequested = ({ value }) => { - const { values } = this.props; - const lowerCaseValue = jdu.replace(value).toLowerCase(); - - const filteredValues = values.filter((v) => { - return jdu.replace(v).toLowerCase().contains(lowerCaseValue); - }); - - this.setState({ suggestions: filteredValues }); - }; - - onSuggestionsClearRequested = () => { - this.setState({ suggestions: [] }); - }; - - // - // Render - - render() { - const { - name, - value, - ...otherProps - } = this.props; - - const { suggestions } = this.state; - - return ( - - ); - } -} - -AutoCompleteInput.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string, - values: PropTypes.arrayOf(PropTypes.string).isRequired, - onChange: PropTypes.func.isRequired -}; - -AutoCompleteInput.defaultProps = { - value: '' -}; - -export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/AutoCompleteInput.tsx b/frontend/src/Components/Form/AutoCompleteInput.tsx new file mode 100644 index 0000000000..226b40c458 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.tsx @@ -0,0 +1,82 @@ +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'; + +export interface AutoCompleteInputProps { + name: string; + readOnly?: boolean; + value?: string; + values: string[]; + onChange: (change: InputChanged) => unknown; +} + +function AutoCompleteInput({ + name, + value = '', + values, + onChange, + ...otherProps +}: AutoCompleteInputProps) { + const [suggestions, setSuggestions] = useState([]); + + const getSuggestionValue = useCallback((item: string) => { + return item; + }, []); + + const renderSuggestion = useCallback((item: string) => { + return item; + }, []); + + const handleInputChange = useCallback( + (_event: SyntheticEvent, { newValue }: ChangeEvent) => { + onChange({ + name, + value: newValue, + }); + }, + [name, onChange] + ); + + const handleInputBlur = useCallback(() => { + setSuggestions([]); + }, [setSuggestions]); + + const handleSuggestionsFetchRequested = useCallback( + ({ value: newValue }: SuggestionsFetchRequestedParams) => { + const lowerCaseValue = jdu.replace(newValue).toLowerCase(); + + const filteredValues = values.filter((v) => { + return jdu.replace(v).toLowerCase().includes(lowerCaseValue); + }); + + setSuggestions(filteredValues); + }, + [values, setSuggestions] + ); + + const handleSuggestionsClearRequested = useCallback(() => { + setSuggestions([]); + }, [setSuggestions]); + + return ( + + ); +} + +export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/AutoSuggestInput.js b/frontend/src/Components/Form/AutoSuggestInput.js deleted file mode 100644 index 34ec7530bc..0000000000 --- a/frontend/src/Components/Form/AutoSuggestInput.js +++ /dev/null @@ -1,257 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Autosuggest from 'react-autosuggest'; -import { Manager, Popper, Reference } from 'react-popper'; -import Portal from 'Components/Portal'; -import styles from './AutoSuggestInput.css'; - -class AutoSuggestInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scheduleUpdate = null; - } - - componentDidUpdate(prevProps) { - if ( - this._scheduleUpdate && - prevProps.suggestions !== this.props.suggestions - ) { - this._scheduleUpdate(); - } - } - - // - // Control - - renderInputComponent = (inputProps) => { - const { renderInputComponent } = this.props; - - return ( - - {({ ref }) => { - if (renderInputComponent) { - return renderInputComponent(inputProps, ref); - } - - return ( -
- -
- ); - }} -
- ); - }; - - renderSuggestionsContainer = ({ containerProps, children }) => { - return ( - - - {({ ref: popperRef, style, scheduleUpdate }) => { - this._scheduleUpdate = scheduleUpdate; - - return ( -
-
- {children} -
-
- ); - }} -
-
- ); - }; - - // - // Listeners - - onComputeMaxHeight = (data) => { - const { - top, - bottom, - width - } = data.offsets.reference; - - const windowHeight = window.innerHeight; - - if ((/^botton/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom; - } else { - data.styles.maxHeight = top; - } - - data.styles.width = width; - - return data; - }; - - onInputChange = (event, { newValue }) => { - this.props.onChange({ - name: this.props.name, - value: newValue - }); - }; - - onInputKeyDown = (event) => { - const { - name, - value, - suggestions, - onChange - } = this.props; - - if ( - event.key === 'Tab' && - suggestions.length && - suggestions[0] !== this.props.value - ) { - event.preventDefault(); - - if (value) { - onChange({ - name, - value: suggestions[0] - }); - } - } - }; - - // - // Render - - render() { - const { - forwardedRef, - className, - inputContainerClassName, - name, - value, - placeholder, - suggestions, - hasError, - hasWarning, - getSuggestionValue, - renderSuggestion, - onInputChange, - onInputKeyDown, - onInputFocus, - onInputBlur, - onSuggestionsFetchRequested, - onSuggestionsClearRequested, - onSuggestionSelected, - ...otherProps - } = this.props; - - const inputProps = { - className: classNames( - className, - hasError && styles.hasError, - hasWarning && styles.hasWarning - ), - name, - value, - placeholder, - autoComplete: 'off', - spellCheck: false, - onChange: onInputChange || this.onInputChange, - onKeyDown: onInputKeyDown || this.onInputKeyDown, - onFocus: onInputFocus, - onBlur: onInputBlur - }; - - const theme = { - container: inputContainerClassName, - containerOpen: styles.suggestionsContainerOpen, - suggestionsContainer: styles.suggestionsContainer, - suggestionsList: styles.suggestionsList, - suggestion: styles.suggestion, - suggestionHighlighted: styles.suggestionHighlighted - }; - - return ( - - - - ); - } -} - -AutoSuggestInput.propTypes = { - forwardedRef: PropTypes.func, - className: PropTypes.string.isRequired, - inputContainerClassName: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - placeholder: PropTypes.string, - suggestions: PropTypes.array.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - enforceMaxHeight: PropTypes.bool.isRequired, - minHeight: PropTypes.number.isRequired, - maxHeight: PropTypes.number.isRequired, - getSuggestionValue: PropTypes.func.isRequired, - renderInputComponent: PropTypes.elementType, - renderSuggestion: PropTypes.func.isRequired, - onInputChange: PropTypes.func, - onInputKeyDown: PropTypes.func, - onInputFocus: PropTypes.func, - onInputBlur: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionSelected: PropTypes.func, - onChange: PropTypes.func.isRequired -}; - -AutoSuggestInput.defaultProps = { - className: styles.input, - inputContainerClassName: styles.inputContainer, - enforceMaxHeight: true, - minHeight: 50, - maxHeight: 200 -}; - -export default AutoSuggestInput; diff --git a/frontend/src/Components/Form/AutoSuggestInput.tsx b/frontend/src/Components/Form/AutoSuggestInput.tsx new file mode 100644 index 0000000000..b3a7c31b0f --- /dev/null +++ b/frontend/src/Components/Form/AutoSuggestInput.tsx @@ -0,0 +1,259 @@ +import classNames from 'classnames'; +import React, { + FocusEvent, + FormEvent, + KeyboardEvent, + KeyboardEventHandler, + MutableRefObject, + ReactNode, + Ref, + SyntheticEvent, + useCallback, + useEffect, + useRef, +} from 'react'; +import Autosuggest, { + AutosuggestPropsBase, + BlurEvent, + ChangeEvent, + RenderInputComponentProps, + RenderSuggestionsContainerParams, +} from 'react-autosuggest'; +import { Manager, Popper, Reference } from 'react-popper'; +import Portal from 'Components/Portal'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { InputChanged } from 'typings/inputs'; +import styles from './AutoSuggestInput.css'; + +interface AutoSuggestInputProps + extends Omit, 'renderInputComponent' | 'inputProps'> { + forwardedRef?: MutableRefObject | null>; + className?: string; + inputContainerClassName?: string; + name: string; + value?: string; + placeholder?: string; + suggestions: T[]; + hasError?: boolean; + hasWarning?: boolean; + enforceMaxHeight?: boolean; + minHeight?: number; + maxHeight?: number; + renderInputComponent?: ( + inputProps: RenderInputComponentProps, + ref: Ref + ) => ReactNode; + onInputChange: ( + event: FormEvent, + params: ChangeEvent + ) => unknown; + onInputKeyDown?: KeyboardEventHandler; + onInputFocus?: (event: SyntheticEvent) => unknown; + onInputBlur: ( + event: FocusEvent, + params?: BlurEvent + ) => unknown; + onChange?: (change: InputChanged) => unknown; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function AutoSuggestInput(props: AutoSuggestInputProps) { + const { + // TODO: forwaredRef should be replaces with React.forwardRef + forwardedRef, + className = styles.input, + inputContainerClassName = styles.inputContainer, + name, + value = '', + placeholder, + suggestions, + enforceMaxHeight = true, + hasError, + hasWarning, + minHeight = 50, + maxHeight = 200, + getSuggestionValue, + renderSuggestion, + renderInputComponent, + onInputChange, + onInputKeyDown, + onInputFocus, + onInputBlur, + onSuggestionsFetchRequested, + onSuggestionsClearRequested, + onSuggestionSelected, + onChange, + ...otherProps + } = props; + + const updater = useRef<(() => void) | null>(null); + const previousSuggestions = usePrevious(suggestions); + + const handleComputeMaxHeight = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data: any) => { + const { top, bottom, width } = data.offsets.reference; + + if (enforceMaxHeight) { + data.styles.maxHeight = maxHeight; + } else { + const windowHeight = window.innerHeight; + + if (/^botton/.test(data.placement)) { + data.styles.maxHeight = windowHeight - bottom; + } else { + data.styles.maxHeight = top; + } + } + + data.styles.width = width; + + return data; + }, + [enforceMaxHeight, maxHeight] + ); + + const createRenderInputComponent = useCallback( + (inputProps: RenderInputComponentProps) => { + return ( + + {({ ref }) => { + if (renderInputComponent) { + return renderInputComponent(inputProps, ref); + } + + return ( +
+ +
+ ); + }} +
+ ); + }, + [renderInputComponent] + ); + + const renderSuggestionsContainer = useCallback( + ({ containerProps, children }: RenderSuggestionsContainerParams) => { + return ( + + + {({ ref: popperRef, style, scheduleUpdate }) => { + updater.current = scheduleUpdate; + + return ( +
+
+ {children} +
+
+ ); + }} +
+
+ ); + }, + [minHeight, handleComputeMaxHeight] + ); + + const handleInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if ( + event.key === 'Tab' && + suggestions.length && + suggestions[0] !== value + ) { + event.preventDefault(); + + if (value) { + onSuggestionSelected?.(event, { + suggestion: suggestions[0], + suggestionValue: value, + suggestionIndex: 0, + sectionIndex: null, + method: 'enter', + }); + } + } + }, + [value, suggestions, onSuggestionSelected] + ); + + const inputProps = { + className: classNames( + className, + hasError && styles.hasError, + hasWarning && styles.hasWarning + ), + name, + value, + placeholder, + autoComplete: 'off', + spellCheck: false, + onChange: onInputChange, + onKeyDown: onInputKeyDown || handleInputKeyDown, + onFocus: onInputFocus, + onBlur: onInputBlur, + }; + + const theme = { + container: inputContainerClassName, + containerOpen: styles.suggestionsContainerOpen, + suggestionsContainer: styles.suggestionsContainer, + suggestionsList: styles.suggestionsList, + suggestion: styles.suggestion, + suggestionHighlighted: styles.suggestionHighlighted, + }; + + useEffect(() => { + if (updater.current && suggestions !== previousSuggestions) { + updater.current(); + } + }, [suggestions, previousSuggestions]); + + return ( + + + + ); +} + +export default AutoSuggestInput; diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js deleted file mode 100644 index b422198b5a..0000000000 --- a/frontend/src/Components/Form/CaptchaInput.js +++ /dev/null @@ -1,84 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ReCAPTCHA from 'react-google-recaptcha'; -import Icon from 'Components/Icon'; -import { icons } from 'Helpers/Props'; -import FormInputButton from './FormInputButton'; -import TextInput from './TextInput'; -import styles from './CaptchaInput.css'; - -function CaptchaInput(props) { - const { - className, - name, - value, - hasError, - hasWarning, - refreshing, - siteKey, - secretToken, - onChange, - onRefreshPress, - onCaptchaChange - } = props; - - return ( -
-
- - - - - -
- - { - !!siteKey && !!secretToken && -
- -
- } -
- ); -} - -CaptchaInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - refreshing: PropTypes.bool.isRequired, - siteKey: PropTypes.string, - secretToken: PropTypes.string, - onChange: PropTypes.func.isRequired, - onRefreshPress: PropTypes.func.isRequired, - onCaptchaChange: PropTypes.func.isRequired -}; - -CaptchaInput.defaultProps = { - className: styles.input, - value: '' -}; - -export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInput.tsx b/frontend/src/Components/Form/CaptchaInput.tsx new file mode 100644 index 0000000000..597b1ad4f0 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.tsx @@ -0,0 +1,118 @@ +import classNames from 'classnames'; +import React, { useCallback, useEffect } from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Icon from 'Components/Icon'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons } from 'Helpers/Props'; +import { + getCaptchaCookie, + refreshCaptcha, + resetCaptcha, +} from 'Store/Actions/captchaActions'; +import { InputChanged } from 'typings/inputs'; +import FormInputButton from './FormInputButton'; +import TextInput from './TextInput'; +import styles from './CaptchaInput.css'; + +export interface CaptchaInputProps { + className?: string; + name: string; + value?: string; + provider: string; + providerData: object; + hasError?: boolean; + hasWarning?: boolean; + refreshing: boolean; + siteKey?: string; + secretToken?: string; + onChange: (change: InputChanged) => unknown; +} + +function CaptchaInput({ + className = styles.input, + name, + value = '', + provider, + providerData, + hasError, + hasWarning, + refreshing, + siteKey, + secretToken, + onChange, +}: CaptchaInputProps) { + const { token } = useSelector((state: AppState) => state.captcha); + const dispatch = useDispatch(); + const previousToken = usePrevious(token); + + const handleCaptchaChange = useCallback( + (token: string | null) => { + // If the captcha has expired `captchaResponse` will be null. + // In the event it's null don't try to get the captchaCookie. + // TODO: Should we clear the cookie? or reset the captcha? + + if (!token) { + return; + } + + dispatch( + getCaptchaCookie({ + provider, + providerData, + captchaResponse: token, + }) + ); + }, + [provider, providerData, dispatch] + ); + + const handleRefreshPress = useCallback(() => { + dispatch(refreshCaptcha({ provider, providerData })); + }, [provider, providerData, dispatch]); + + useEffect(() => { + if (token && token !== previousToken) { + onChange({ name, value: token }); + } + }, [name, token, previousToken, onChange]); + + useEffect(() => { + dispatch(resetCaptcha()); + }, [dispatch]); + + return ( +
+
+ + + + + +
+ + {siteKey && secretToken ? ( +
+ +
+ ) : null} +
+ ); +} + +export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js deleted file mode 100644 index ad83bf02fb..0000000000 --- a/frontend/src/Components/Form/CaptchaInputConnector.js +++ /dev/null @@ -1,98 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions'; -import CaptchaInput from './CaptchaInput'; - -function createMapStateToProps() { - return createSelector( - (state) => state.captcha, - (captcha) => { - return captcha; - } - ); -} - -const mapDispatchToProps = { - refreshCaptcha, - getCaptchaCookie, - resetCaptcha -}; - -class CaptchaInputConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps) { - const { - name, - token, - onChange - } = this.props; - - if (token && token !== prevProps.token) { - onChange({ name, value: token }); - } - } - - componentWillUnmount = () => { - this.props.resetCaptcha(); - }; - - // - // Listeners - - onRefreshPress = () => { - const { - provider, - providerData - } = this.props; - - this.props.refreshCaptcha({ provider, providerData }); - }; - - onCaptchaChange = (captchaResponse) => { - // If the captcha has expired `captchaResponse` will be null. - // In the event it's null don't try to get the captchaCookie. - // TODO: Should we clear the cookie? or reset the captcha? - - if (!captchaResponse) { - return; - } - - const { - provider, - providerData - } = this.props; - - this.props.getCaptchaCookie({ provider, providerData, captchaResponse }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CaptchaInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - token: PropTypes.string, - onChange: PropTypes.func.isRequired, - refreshCaptcha: PropTypes.func.isRequired, - getCaptchaCookie: PropTypes.func.isRequired, - resetCaptcha: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector); diff --git a/frontend/src/Components/Form/CheckInput.css b/frontend/src/Components/Form/CheckInput.css index 1711214821..6e44bd609a 100644 --- a/frontend/src/Components/Form/CheckInput.css +++ b/frontend/src/Components/Form/CheckInput.css @@ -41,10 +41,11 @@ .checkbox:focus + .input { outline: 0; border-color: var(--inputFocusBorderColor); - box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor); + box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), + 0 0 8px var(--inputFocusBoxShadowColor); } -.dangerIsChecked { +.danger { border-color: var(--dangerColor); background-color: var(--dangerColor); @@ -53,7 +54,7 @@ } } -.primaryIsChecked { +.primary { border-color: var(--primaryColor); background-color: var(--primaryColor); @@ -62,7 +63,7 @@ } } -.successIsChecked { +.success { border-color: var(--successColor); background-color: var(--successColor); @@ -71,7 +72,7 @@ } } -.warningIsChecked { +.warning { border-color: var(--warningColor); background-color: var(--warningColor); diff --git a/frontend/src/Components/Form/CheckInput.css.d.ts b/frontend/src/Components/Form/CheckInput.css.d.ts index bba6b63bbf..850250b679 100644 --- a/frontend/src/Components/Form/CheckInput.css.d.ts +++ b/frontend/src/Components/Form/CheckInput.css.d.ts @@ -3,16 +3,16 @@ interface CssExports { 'checkbox': string; 'container': string; - 'dangerIsChecked': string; + 'danger': string; 'helpText': string; 'input': string; 'isDisabled': string; 'isIndeterminate': string; 'isNotChecked': string; 'label': string; - 'primaryIsChecked': string; - 'successIsChecked': string; - 'warningIsChecked': string; + 'primary': string; + 'success': string; + 'warning': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js deleted file mode 100644 index 26d9158803..0000000000 --- a/frontend/src/Components/Form/CheckInput.js +++ /dev/null @@ -1,191 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import { icons, kinds } from 'Helpers/Props'; -import FormInputHelpText from './FormInputHelpText'; -import styles from './CheckInput.css'; - -class CheckInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._checkbox = null; - } - - componentDidMount() { - this.setIndeterminate(); - } - - componentDidUpdate() { - this.setIndeterminate(); - } - - // - // Control - - setIndeterminate() { - if (!this._checkbox) { - return; - } - - const { - value, - uncheckedValue, - checkedValue - } = this.props; - - this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue; - } - - toggleChecked = (checked, shiftKey) => { - const { - name, - value, - checkedValue, - uncheckedValue - } = this.props; - - const newValue = checked ? checkedValue : uncheckedValue; - - if (value !== newValue) { - this.props.onChange({ - name, - value: newValue, - shiftKey - }); - } - }; - - // - // Listeners - - setRef = (ref) => { - this._checkbox = ref; - }; - - onClick = (event) => { - if (this.props.isDisabled) { - return; - } - - const shiftKey = event.nativeEvent.shiftKey; - const checked = !this._checkbox.checked; - - event.preventDefault(); - this.toggleChecked(checked, shiftKey); - }; - - onChange = (event) => { - const checked = event.target.checked; - const shiftKey = event.nativeEvent.shiftKey; - - this.toggleChecked(checked, shiftKey); - }; - - // - // Render - - render() { - const { - className, - containerClassName, - name, - value, - checkedValue, - uncheckedValue, - helpText, - helpTextWarning, - isDisabled, - kind - } = this.props; - - const isChecked = value === checkedValue; - const isUnchecked = value === uncheckedValue; - const isIndeterminate = !isChecked && !isUnchecked; - const isCheckClass = `${kind}IsChecked`; - - return ( -
- -
- ); - } -} - -CheckInput.propTypes = { - className: PropTypes.string.isRequired, - containerClassName: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - checkedValue: PropTypes.bool, - uncheckedValue: PropTypes.bool, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - helpText: PropTypes.string, - helpTextWarning: PropTypes.string, - isDisabled: PropTypes.bool, - kind: PropTypes.oneOf(kinds.all).isRequired, - onChange: PropTypes.func.isRequired -}; - -CheckInput.defaultProps = { - className: styles.input, - containerClassName: styles.container, - checkedValue: true, - uncheckedValue: false, - kind: kinds.PRIMARY -}; - -export default CheckInput; diff --git a/frontend/src/Components/Form/CheckInput.tsx b/frontend/src/Components/Form/CheckInput.tsx new file mode 100644 index 0000000000..107beaa580 --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.tsx @@ -0,0 +1,140 @@ +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; +} + +export interface CheckInputProps { + className?: string; + containerClassName?: string; + name: string; + checkedValue?: boolean; + uncheckedValue?: boolean; + value?: string | boolean | null; + 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 toggleChecked = useCallback( + (checked: boolean, shiftKey: boolean) => { + const newValue = checked ? checkedValue : uncheckedValue; + + if (value !== newValue) { + onChange({ + name, + value: newValue, + shiftKey, + }); + } + }, + [name, value, checkedValue, uncheckedValue, onChange] + ); + + const handleClick = useCallback( + (event: SyntheticEvent) => { + if (isDisabled) { + return; + } + + const shiftKey = event.nativeEvent.shiftKey; + const checked = !(inputRef.current?.checked ?? false); + + event.preventDefault(); + toggleChecked(checked, shiftKey); + }, + [isDisabled, toggleChecked] + ); + + const handleChange = useCallback( + (event: ChangeEvent) => { + const checked = event.target.checked; + const shiftKey = event.nativeEvent.shiftKey; + + toggleChecked(checked, shiftKey); + }, + [toggleChecked] + ); + + useEffect(() => { + if (!inputRef.current) { + return; + } + + inputRef.current.indeterminate = + value !== uncheckedValue && value !== checkedValue; + }, [value, uncheckedValue, checkedValue]); + + return ( +
+ +
+ ); +} + +export default CheckInput; diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js deleted file mode 100644 index 55c239cb82..0000000000 --- a/frontend/src/Components/Form/DeviceInput.js +++ /dev/null @@ -1,106 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import { icons } from 'Helpers/Props'; -import tagShape from 'Helpers/Props/Shapes/tagShape'; -import FormInputButton from './FormInputButton'; -import TagInput from './TagInput'; -import styles from './DeviceInput.css'; - -class DeviceInput extends Component { - - onTagAdd = (device) => { - const { - name, - value, - onChange - } = this.props; - - // New tags won't have an ID, only a name. - const deviceId = device.id || device.name; - - onChange({ - name, - value: [...value, deviceId] - }); - }; - - onTagDelete = ({ index }) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = value.slice(); - newValue.splice(index, 1); - - onChange({ - name, - value: newValue - }); - }; - - // - // Render - - render() { - const { - className, - name, - items, - selectedDevices, - hasError, - hasWarning, - isFetching, - onRefreshPress - } = this.props; - - return ( -
- - - - - -
- ); - } -} - -DeviceInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, - items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onRefreshPress: PropTypes.func.isRequired -}; - -DeviceInput.defaultProps = { - className: styles.deviceInputWrapper, - inputClassName: styles.input -}; - -export default DeviceInput; diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js deleted file mode 100644 index 2af9a79f6a..0000000000 --- a/frontend/src/Components/Form/DeviceInputConnector.js +++ /dev/null @@ -1,104 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; -import DeviceInput from './DeviceInput'; - -function createMapStateToProps() { - return createSelector( - (state, { value }) => value, - (state) => state.providerOptions.devices || defaultState, - (value, devices) => { - - return { - ...devices, - selectedDevices: value.map((valueDevice) => { - // Disable equality ESLint rule so we don't need to worry about - // a type mismatch between the value items and the device ID. - // eslint-disable-next-line eqeqeq - const device = devices.items.find((d) => d.id == valueDevice); - - if (device) { - return { - id: device.id, - name: `${device.name} (${device.id})` - }; - } - - return { - id: valueDevice, - name: `Unknown (${valueDevice})` - }; - }) - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchOptions: fetchOptions, - dispatchClearOptions: clearOptions -}; - -class DeviceInputConnector extends Component { - - // - // Lifecycle - - componentDidMount = () => { - this._populate(); - }; - - componentWillUnmount = () => { - this.props.dispatchClearOptions({ section: 'devices' }); - }; - - // - // Control - - _populate() { - const { - provider, - providerData, - dispatchFetchOptions - } = this.props; - - dispatchFetchOptions({ - section: 'devices', - action: 'getDevices', - provider, - providerData - }); - } - - // - // Listeners - - onRefreshPress = () => { - this._populate(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -DeviceInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - dispatchFetchOptions: PropTypes.func.isRequired, - dispatchClearOptions: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector); diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js deleted file mode 100644 index c21f0ded6b..0000000000 --- a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js +++ /dev/null @@ -1,102 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchDownloadClients } from 'Store/Actions/settingsActions'; -import sortByProp from 'Utilities/Array/sortByProp'; -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(sortByProp('name')), (downloadClient) => { - return { - key: downloadClient.id, - value: downloadClient.name, - hint: `(${downloadClient.id})` - }; - }); - - if (includeAny) { - values.unshift({ - key: 0, - value: `(${translate('Any')})` - }); - } - - return { - isFetching, - isPopulated, - error, - values - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchDownloadClients: fetchDownloadClients -}; - -class DownloadClientSelectInputConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - if (!this.props.isPopulated) { - this.props.dispatchFetchDownloadClients(); - } - } - - // - // Listeners - - onChange = ({ name, value }) => { - this.props.onChange({ name, value: parseInt(value) }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -DownloadClientSelectInputConnector.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - includeAny: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - dispatchFetchDownloadClients: PropTypes.func.isRequired -}; - -DownloadClientSelectInputConnector.defaultProps = { - includeAny: false, - protocol: 'torrent' -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js deleted file mode 100644 index d686665caf..0000000000 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ /dev/null @@ -1,616 +0,0 @@ -import classNames from 'classnames'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Manager, Popper, Reference } from 'react-popper'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Measure from 'Components/Measure'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import Portal from 'Components/Portal'; -import Scroller from 'Components/Scroller/Scroller'; -import { icons, scrollDirections, sizes } from 'Helpers/Props'; -import { isMobile as isMobileUtil } from 'Utilities/browser'; -import * as keyCodes from 'Utilities/Constants/keyCodes'; -import getUniqueElememtId from 'Utilities/getUniqueElementId'; -import HintedSelectInputOption from './HintedSelectInputOption'; -import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; -import TextInput from './TextInput'; -import styles from './EnhancedSelectInput.css'; - -function isArrowKey(keyCode) { - return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; -} - -function getSelectedOption(selectedIndex, values) { - return values[selectedIndex]; -} - -function findIndex(startingIndex, direction, values) { - let indexToTest = startingIndex + direction; - - while (indexToTest !== startingIndex) { - if (indexToTest < 0) { - indexToTest = values.length - 1; - } else if (indexToTest >= values.length) { - indexToTest = 0; - } - - if (getSelectedOption(indexToTest, values).isDisabled) { - indexToTest = indexToTest + direction; - } else { - return indexToTest; - } - } -} - -function previousIndex(selectedIndex, values) { - return findIndex(selectedIndex, -1, values); -} - -function nextIndex(selectedIndex, values) { - return findIndex(selectedIndex, 1, values); -} - -function getSelectedIndex(props) { - const { - value, - values - } = props; - - if (Array.isArray(value)) { - return values.findIndex((v) => { - return value.size && v.key === value[0]; - }); - } - - return values.findIndex((v) => { - return v.key === value; - }); -} - -function isSelectedItem(index, props) { - const { - value, - values - } = props; - - if (Array.isArray(value)) { - return value.includes(values[index].key); - } - - return values[index].key === value; -} - -function getKey(selectedIndex, values) { - return values[selectedIndex].key; -} - -class EnhancedSelectInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scheduleUpdate = null; - this._buttonId = getUniqueElememtId(); - this._optionsId = getUniqueElememtId(); - - this.state = { - isOpen: false, - selectedIndex: getSelectedIndex(props), - width: 0, - isMobile: isMobileUtil() - }; - } - - componentDidUpdate(prevProps) { - if (this._scheduleUpdate) { - this._scheduleUpdate(); - } - - if (!Array.isArray(this.props.value)) { - if (prevProps.value !== this.props.value || prevProps.values !== this.props.values) { - this.setState({ - selectedIndex: getSelectedIndex(this.props) - }); - } - } - } - - // - // Control - - _addListener() { - window.addEventListener('click', this.onWindowClick); - } - - _removeListener() { - window.removeEventListener('click', this.onWindowClick); - } - - // - // Listeners - - onComputeMaxHeight = (data) => { - const { - top, - bottom - } = data.offsets.reference; - - const windowHeight = window.innerHeight; - - if ((/^botton/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom; - } else { - data.styles.maxHeight = top; - } - - return data; - }; - - onWindowClick = (event) => { - const button = document.getElementById(this._buttonId); - const options = document.getElementById(this._optionsId); - - if (!button || !event.target.isConnected || this.state.isMobile) { - return; - } - - if ( - !button.contains(event.target) && - options && - !options.contains(event.target) && - this.state.isOpen - ) { - this.setState({ isOpen: false }); - this._removeListener(); - } - }; - - onFocus = () => { - if (this.state.isOpen) { - this._removeListener(); - this.setState({ isOpen: false }); - } - }; - - onBlur = () => { - if (!this.props.isEditable) { - // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) - const origIndex = getSelectedIndex(this.props); - - if (origIndex !== this.state.selectedIndex) { - this.setState({ selectedIndex: origIndex }); - } - } - }; - - onKeyDown = (event) => { - const { - values - } = this.props; - - const { - isOpen, - selectedIndex - } = this.state; - - const keyCode = event.keyCode; - const newState = {}; - - if (!isOpen) { - if (isArrowKey(keyCode)) { - event.preventDefault(); - newState.isOpen = true; - } - - if ( - selectedIndex == null || selectedIndex === -1 || - getSelectedOption(selectedIndex, values).isDisabled - ) { - if (keyCode === keyCodes.UP_ARROW) { - newState.selectedIndex = previousIndex(0, values); - } else if (keyCode === keyCodes.DOWN_ARROW) { - newState.selectedIndex = nextIndex(values.length - 1, values); - } - } - - this.setState(newState); - return; - } - - if (keyCode === keyCodes.UP_ARROW) { - event.preventDefault(); - newState.selectedIndex = previousIndex(selectedIndex, values); - } - - if (keyCode === keyCodes.DOWN_ARROW) { - event.preventDefault(); - newState.selectedIndex = nextIndex(selectedIndex, values); - } - - if (keyCode === keyCodes.ENTER) { - event.preventDefault(); - newState.isOpen = false; - this.onSelect(getKey(selectedIndex, values)); - } - - if (keyCode === keyCodes.TAB) { - newState.isOpen = false; - this.onSelect(getKey(selectedIndex, values)); - } - - if (keyCode === keyCodes.ESCAPE) { - event.preventDefault(); - event.stopPropagation(); - newState.isOpen = false; - newState.selectedIndex = getSelectedIndex(this.props); - } - - if (!_.isEmpty(newState)) { - this.setState(newState); - } - }; - - onPress = () => { - if (this.state.isOpen) { - this._removeListener(); - } else { - this._addListener(); - } - - if (!this.state.isOpen && this.props.onOpen) { - this.props.onOpen(); - } - - this.setState({ isOpen: !this.state.isOpen }); - }; - - onSelect = (newValue) => { - const { name, value, values, onChange } = this.props; - const additionalProperties = values.find((v) => v.key === newValue)?.additionalProperties; - - if (Array.isArray(value)) { - let arrayValue = null; - const index = value.indexOf(newValue); - - if (index === -1) { - arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v)); - } else { - arrayValue = [...value]; - arrayValue.splice(index, 1); - } - onChange({ - name, - value: arrayValue, - additionalProperties - }); - } else { - this.setState({ isOpen: false }); - - onChange({ - name, - value: newValue, - additionalProperties - }); - } - }; - - 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 && Array.isArray(value) && value.includes(v.parentKey); - return ( - - {v.value} - - ); - }) - } - : - null - } -
- ); - } - } -
-
-
- - { - isMobile ? - - - -
- - - -
- - { - values.map((v, index) => { - const hasParent = v.parentKey !== undefined; - const depth = hasParent ? 1 : 0; - const parentSelected = hasParent && value.includes(v.parentKey); - return ( - - {v.value} - - ); - }) - } -
-
-
: - null - } -
- ); - } -} - -EnhancedSelectInput.propTypes = { - className: PropTypes.string, - disabledClassName: PropTypes.string, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isEditable: PropTypes.bool.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - valueOptions: PropTypes.object.isRequired, - selectedValueOptions: PropTypes.object.isRequired, - selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, - optionComponent: PropTypes.elementType, - onOpen: PropTypes.func, - onChange: PropTypes.func.isRequired -}; - -EnhancedSelectInput.defaultProps = { - className: styles.enhancedSelect, - disabledClassName: styles.isDisabled, - isDisabled: false, - isFetching: false, - isEditable: false, - valueOptions: {}, - selectedValueOptions: {}, - selectedValueComponent: HintedSelectInputSelectedValue, - optionComponent: HintedSelectInputOption -}; - -export default EnhancedSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js deleted file mode 100644 index 73f1cf27a9..0000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputConnector.js +++ /dev/null @@ -1,162 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -const importantFieldNames = [ - 'baseUrl', - 'apiPath', - 'apiKey', - 'authToken' -]; - -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, - isDisabled: option.isDisabled, - additionalProperties: option.additionalProperties - }; - }); -} - -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.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - selectOptionsProviderAction: PropTypes.string, - onChange: PropTypes.func.isRequired, - isFetching: PropTypes.bool.isRequired, - dispatchFetchOptions: PropTypes.func.isRequired, - dispatchClearOptions: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js deleted file mode 100644 index b2783dbaad..0000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.js +++ /dev/null @@ -1,113 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons } from 'Helpers/Props'; -import CheckInput from './CheckInput'; -import styles from './EnhancedSelectInputOption.css'; - -class EnhancedSelectInputOption extends Component { - - // - // Listeners - - onPress = (e) => { - e.preventDefault(); - - const { - id, - onSelect - } = this.props; - - onSelect(id); - }; - - onCheckPress = () => { - // CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation. - }; - - // - // Render - - render() { - const { - className, - id, - depth, - isSelected, - isDisabled, - isHidden, - isMultiSelect, - isMobile, - children - } = this.props; - - return ( - - - { - depth !== 0 && -
- } - - { - isMultiSelect && - - } - - {children} - - { - isMobile && -
- -
- } - - ); - } -} - -EnhancedSelectInputOption.propTypes = { - className: PropTypes.string.isRequired, - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - depth: PropTypes.number.isRequired, - isSelected: PropTypes.bool.isRequired, - isDisabled: PropTypes.bool.isRequired, - isHidden: PropTypes.bool.isRequired, - isMultiSelect: PropTypes.bool.isRequired, - isMobile: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, - onSelect: PropTypes.func.isRequired -}; - -EnhancedSelectInputOption.defaultProps = { - className: styles.option, - depth: 0, - isDisabled: false, - isHidden: false, - isMultiSelect: false -}; - -export default EnhancedSelectInputOption; diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js deleted file mode 100644 index 21ddebb027..0000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js +++ /dev/null @@ -1,35 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './EnhancedSelectInputSelectedValue.css'; - -function EnhancedSelectInputSelectedValue(props) { - const { - className, - children, - isDisabled - } = props; - - return ( -
- {children} -
- ); -} - -EnhancedSelectInputSelectedValue.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node, - isDisabled: PropTypes.bool.isRequired -}; - -EnhancedSelectInputSelectedValue.defaultProps = { - className: styles.selectedValue, - isDisabled: false -}; - -export default EnhancedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js deleted file mode 100644 index 79ad3fe8ab..0000000000 --- a/frontend/src/Components/Form/Form.js +++ /dev/null @@ -1,66 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Alert from 'Components/Alert'; -import { kinds } from 'Helpers/Props'; -import styles from './Form.css'; - -function Form(props) { - const { - children, - validationErrors, - validationWarnings, - // eslint-disable-next-line no-unused-vars - ...otherProps - } = props; - - return ( -
- { - validationErrors.length || validationWarnings.length ? -
- { - validationErrors.map((error, index) => { - return ( - - {error.errorMessage} - - ); - }) - } - - { - validationWarnings.map((warning, index) => { - return ( - - {warning.errorMessage} - - ); - }) - } -
: - null - } - - {children} -
- ); -} - -Form.propTypes = { - children: PropTypes.node.isRequired, - validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired, - validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -Form.defaultProps = { - validationErrors: [], - validationWarnings: [] -}; - -export default Form; diff --git a/frontend/src/Components/Form/Form.tsx b/frontend/src/Components/Form/Form.tsx new file mode 100644 index 0000000000..055c8f80a6 --- /dev/null +++ b/frontend/src/Components/Form/Form.tsx @@ -0,0 +1,47 @@ +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 { + id?: string; + children: ReactNode; + validationErrors?: ValidationError[]; + validationWarnings?: ValidationWarning[]; +} + +function Form({ + id, + children, + validationErrors = [], + validationWarnings = [], +}: FormProps) { + return ( +
+ {validationErrors.length || validationWarnings.length ? ( +
+ {validationErrors.map((error, index) => { + return ( + + {error.errorMessage} + + ); + })} + + {validationWarnings.map((warning, index) => { + return ( + + {warning.errorMessage} + + ); + })} +
+ ) : null} + + {children} +
+ ); +} + +export default Form; diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js deleted file mode 100644 index f538daa2f1..0000000000 --- a/frontend/src/Components/Form/FormGroup.js +++ /dev/null @@ -1,56 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { map } from 'Helpers/elementChildren'; -import { sizes } from 'Helpers/Props'; -import styles from './FormGroup.css'; - -function FormGroup(props) { - const { - className, - children, - size, - advancedSettings, - isAdvanced, - ...otherProps - } = props; - - if (!advancedSettings && isAdvanced) { - return null; - } - - const childProps = isAdvanced ? { isAdvanced } : {}; - - return ( -
- { - map(children, (child) => { - return React.cloneElement(child, childProps); - }) - } -
- ); -} - -FormGroup.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, - size: PropTypes.oneOf(sizes.all).isRequired, - advancedSettings: PropTypes.bool.isRequired, - isAdvanced: PropTypes.bool.isRequired -}; - -FormGroup.defaultProps = { - className: styles.group, - size: sizes.SMALL, - advancedSettings: false, - isAdvanced: false -}; - -export default FormGroup; diff --git a/frontend/src/Components/Form/FormGroup.tsx b/frontend/src/Components/Form/FormGroup.tsx new file mode 100644 index 0000000000..1dd879897a --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames'; +import React, { Children, ComponentPropsWithoutRef, ReactNode } from 'react'; +import { Size } from 'Helpers/Props/sizes'; +import styles from './FormGroup.css'; + +interface FormGroupProps extends ComponentPropsWithoutRef<'div'> { + className?: string; + children: ReactNode; + size?: Extract; + advancedSettings?: boolean; + isAdvanced?: boolean; +} + +function FormGroup(props: FormGroupProps) { + const { + className = styles.group, + children, + size = 'small', + advancedSettings = false, + isAdvanced = false, + ...otherProps + } = props; + + if (!advancedSettings && isAdvanced) { + return null; + } + + const childProps = isAdvanced ? { isAdvanced } : {}; + + return ( +
+ {Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + return React.cloneElement(child, childProps); + })} +
+ ); +} + +export default FormGroup; diff --git a/frontend/src/Components/Form/FormInputButton.tsx b/frontend/src/Components/Form/FormInputButton.tsx index f617791221..1235010ebb 100644 --- a/frontend/src/Components/Form/FormInputButton.tsx +++ b/frontend/src/Components/Form/FormInputButton.tsx @@ -8,19 +8,23 @@ import styles from './FormInputButton.css'; export interface FormInputButtonProps extends ButtonProps { canSpin?: boolean; isLastButton?: boolean; + isSpinning?: boolean; } function FormInputButton({ className = styles.button, canSpin = false, isLastButton = true, + isSpinning = false, + kind = kinds.PRIMARY, ...otherProps }: FormInputButtonProps) { if (canSpin) { return ( ); @@ -29,7 +33,7 @@ function FormInputButton({ return (