1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-13 20:46:13 -04:00

Compare commits

..

6 Commits

Author SHA1 Message Date
Mark McDowall
974c4a601b Show save error in UI 2024-09-06 20:53:55 -07:00
Mark McDowall
9549038121 Convert QualityDefinitionLimits to static 2024-09-06 20:35:19 -07:00
Robert Dailey
2f193ac58a Add API validation and tests for quality limits 2024-09-06 09:32:11 -05:00
Robert Dailey
e893ca4f1c Support validation of collections in RestController 2024-09-06 09:32:11 -05:00
Robert Dailey
039d7775ed feat: Shift quality definition limits management to the backend
This update moves the minimum, maximum, and preferred quality limits to
the backend, accessible via the new `/qualitydefinition/limits`
endpoint. This change improves support for unofficial Sonarr API clients
and enables a more flexible frontend.
2024-09-06 09:32:11 -05:00
Robert Dailey
87bd5e62f2 Add .idea directory to gitignore
For users of the Jetbrains IDEs, the `.idea` directory isn't strictly
necessary for version control. It's better to ignore it than tie a repo
to specific tooling.
2024-09-06 09:32:11 -05:00
407 changed files with 11466 additions and 12295 deletions

View File

@@ -22,7 +22,7 @@ env:
FRAMEWORK: net6.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 4
VERSION: 4.0.10
VERSION: 4.0.9
jobs:
backend:

View File

@@ -1,29 +0,0 @@
name: 'Support Requests'
on:
issues:
types: [labeled, unlabeled, reopened]
permissions:
issues: write
jobs:
action:
runs-on: ubuntu-latest
if: github.repository == 'Sonarr/Sonarr'
steps:
- uses: dessant/support-requests@v4
with:
github-token: ${{ github.token }}
support-label: 'support'
issue-comment: >
:wave: @{issue-author}, we use the issue tracker exclusively
for bug reports and feature requests. However, this issue appears
to be a support request. Please use one of the support channels:
[forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/),
[discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr)
for support/questions.
close-issue: true
issue-close-reason: 'not planned'
lock-issue: false
issue-lock-reason: 'off-topic'

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-1.3318" y1="43.7371" x2="67.0419" y2="26.0967">
<stop offset="0.1237" style="stop-color:#7866FF"/>
<stop offset="0.5376" style="stop-color:#FE2EB6"/>
<stop offset="0.8548" style="stop-color:#FD0486"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="67.3,16 43.7,0 0,31.1 11.1,70 58.9,60.3 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="45.9148" y1="38.9098" x2="67.6577" y2="9.0989">
<stop offset="0.1237" style="stop-color:#FF0080"/>
<stop offset="0.2587" style="stop-color:#FE0385"/>
<stop offset="0.4109" style="stop-color:#FA0C92"/>
<stop offset="0.5713" style="stop-color:#F41BA9"/>
<stop offset="0.7363" style="stop-color:#EB2FC8"/>
<stop offset="0.8656" style="stop-color:#E343E6"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="67.3,16 43.7,0 38,15.7 38,47.8 70,47.8 "/>
</g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.4" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<g>
<path style="fill:#FFFFFF;" d="M17.4,19.1h6.9c5.6,0,9.5,3.8,9.5,8.9V28c0,5-3.9,8.9-9.5,8.9h-6.9V19.1z M21.4,22.7v10.7h3
c3.2,0,5.4-2.2,5.4-5.3V28c0-3.2-2.2-5.4-5.4-5.4H21.4z"/>
<polygon style="fill:#FFFFFF;" points="40.3,22.7 34.9,22.7 34.9,19.1 49.6,19.1 49.6,22.7 44.2,22.7 44.2,37 40.3,37 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="120.1px" height="130.2px" viewBox="0 0 120.1 130.2" style="enable-background:new 0 0 120.1 130.2;" xml:space="preserve"
>
<g>
<linearGradient id="XMLID_2_" gradientUnits="userSpaceOnUse" x1="31.8412" y1="120.5578" x2="110.2402" y2="73.24">
<stop offset="0" style="stop-color:#FCEE39"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3041_" style="fill:url(#XMLID_2_);" d="M118.6,71.8c0.9-0.8,1.4-1.9,1.5-3.2c0.1-2.6-1.8-4.7-4.4-4.9
c-1.2-0.1-2.4,0.4-3.3,1.1l0,0l-83.8,45.9c-1.9,0.8-3.6,2.2-4.7,4.1c-2.9,4.8-1.3,11,3.6,13.9c3.4,2,7.5,1.8,10.7-0.2l0,0l0,0
c0.2-0.2,0.5-0.3,0.7-0.5l78-54.8C117.3,72.9,118.4,72.1,118.6,71.8L118.6,71.8L118.6,71.8z"/>
<linearGradient id="XMLID_3_" gradientUnits="userSpaceOnUse" x1="48.3607" y1="6.9083" x2="119.9179" y2="69.5546">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.57" style="stop-color:#F26F4E"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3049_" style="fill:url(#XMLID_3_);" d="M118.8,65.1L118.8,65.1L55,2.5C53.6,1,51.6,0,49.3,0
c-4.3,0-7.7,3.5-7.7,7.7v0c0,2.1,0.8,3.9,2.1,5.3l0,0l0,0c0.4,0.4,0.8,0.7,1.2,1l67.4,57.7l0,0c0.8,0.7,1.8,1.2,3,1.3
c2.6,0.1,4.7-1.8,4.9-4.4C120.2,67.3,119.7,66,118.8,65.1z"/>
<linearGradient id="XMLID_4_" gradientUnits="userSpaceOnUse" x1="52.9467" y1="63.6407" x2="10.5379" y2="37.1562">
<stop offset="0" style="stop-color:#7C59A4"/>
<stop offset="0.3852" style="stop-color:#AF4C92"/>
<stop offset="0.7654" style="stop-color:#DC4183"/>
<stop offset="0.957" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3042_" style="fill:url(#XMLID_4_);" d="M57.1,59.5C57,59.5,17.7,28.5,16.9,28l0,0l0,0c-0.6-0.3-1.2-0.6-1.8-0.9
c-5.8-2.2-12.2,0.8-14.4,6.6c-1.9,5.1,0.2,10.7,4.6,13.4l0,0l0,0C6,47.5,6.6,47.8,7.3,48c0.4,0.2,45.4,18.8,45.4,18.8l0,0
c1.8,0.8,3.9,0.3,5.1-1.2C59.3,63.7,59,61,57.1,59.5z"/>
<linearGradient id="XMLID_5_" gradientUnits="userSpaceOnUse" x1="52.1736" y1="3.7019" x2="10.7706" y2="37.8971">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.364" style="stop-color:#EE4E72"/>
<stop offset="1" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3057_" style="fill:url(#XMLID_5_);" d="M49.3,0c-1.7,0-3.3,0.6-4.6,1.5L4.9,28.3c-0.1,0.1-0.2,0.1-0.2,0.2l-0.1,0
l0,0c-1.7,1.2-3.1,3-3.9,5.1C-1.5,39.4,1.5,45.9,7.3,48c3.6,1.4,7.5,0.7,10.4-1.4l0,0l0,0c0.7-0.5,1.3-1,1.8-1.6l34.6-31.2l0,0
c1.8-1.4,3-3.6,3-6.1v0C57.1,3.5,53.6,0,49.3,0z"/>
<g id="XMLID_3008_">
<rect id="XMLID_3033_" x="34.6" y="37.4" style="fill:#000000;" width="51" height="51"/>
<rect id="XMLID_3032_" x="39" y="78.8" style="fill:#FFFFFF;" width="19.1" height="3.2"/>
<g id="XMLID_3009_">
<path id="XMLID_3030_" style="fill:#FFFFFF;" d="M38.8,50.8l1.5-1.4c0.4,0.5,0.8,0.8,1.3,0.8c0.6,0,0.9-0.4,0.9-1.2l0-5.3l2.3,0
l0,5.3c0,1-0.3,1.8-0.8,2.3c-0.5,0.5-1.3,0.8-2.3,0.8C40.2,52.2,39.4,51.6,38.8,50.8z"/>
<path id="XMLID_3028_" style="fill:#FFFFFF;" d="M45.3,43.8l6.7,0v1.9l-4.4,0V47l4,0l0,1.8l-4,0l0,1.3l4.5,0l0,2l-6.7,0
L45.3,43.8z"/>
<path id="XMLID_3026_" style="fill:#FFFFFF;" d="M55,45.8l-2.5,0l0-2l7.3,0l0,2l-2.5,0l0,6.3l-2.3,0L55,45.8z"/>
<path id="XMLID_3022_" style="fill:#FFFFFF;" d="M39,54l4.3,0c1,0,1.8,0.3,2.3,0.7c0.3,0.3,0.5,0.8,0.5,1.4v0
c0,1-0.5,1.5-1.3,1.9c1,0.3,1.6,0.9,1.6,2v0c0,1.4-1.2,2.3-3.1,2.3l-4.3,0L39,54z M43.8,56.6c0-0.5-0.4-0.7-1-0.7l-1.5,0l0,1.5
l1.4,0C43.4,57.3,43.8,57.1,43.8,56.6L43.8,56.6z M43,59l-1.8,0l0,1.5H43c0.7,0,1.1-0.3,1.1-0.8v0C44.1,59.2,43.7,59,43,59z"/>
<path id="XMLID_3019_" style="fill:#FFFFFF;" d="M46.8,54l3.9,0c1.3,0,2.1,0.3,2.7,0.9c0.5,0.5,0.7,1.1,0.7,1.9v0
c0,1.3-0.7,2.1-1.7,2.6l2,2.9l-2.6,0l-1.7-2.5h-1l0,2.5l-2.3,0L46.8,54z M50.6,58c0.8,0,1.2-0.4,1.2-1v0c0-0.7-0.5-1-1.2-1
l-1.5,0v2H50.6z"/>
<path id="XMLID_3016_" style="fill:#FFFFFF;" d="M56.8,54l2.2,0l3.5,8.4l-2.5,0l-0.6-1.5l-3.2,0l-0.6,1.5l-2.4,0L56.8,54z
M58.8,59l-0.9-2.3L57,59L58.8,59z"/>
<path id="XMLID_3014_" style="fill:#FFFFFF;" d="M62.8,54l2.3,0l0,8.3l-2.3,0L62.8,54z"/>
<path id="XMLID_3012_" style="fill:#FFFFFF;" d="M65.7,54l2.1,0l3.4,4.4l0-4.4l2.3,0l0,8.3l-2,0L68,57.8l0,4.6l-2.3,0L65.7,54z"
/>
<path id="XMLID_3010_" style="fill:#FFFFFF;" d="M73.7,61.1l1.3-1.5c0.8,0.7,1.7,1,2.7,1c0.6,0,1-0.2,1-0.6v0
c0-0.4-0.3-0.5-1.4-0.8c-1.8-0.4-3.1-0.9-3.1-2.6v0c0-1.5,1.2-2.7,3.2-2.7c1.4,0,2.5,0.4,3.4,1.1l-1.2,1.6
c-0.8-0.5-1.6-0.8-2.3-0.8c-0.6,0-0.8,0.2-0.8,0.5v0c0,0.4,0.3,0.5,1.4,0.8c1.9,0.4,3.1,1,3.1,2.6v0c0,1.7-1.3,2.7-3.4,2.7
C76.1,62.5,74.7,62,73.7,61.1z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="22.9451" y1="75.7869" x2="74.7868" y2="20.6415">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.6505" style="stop-color:#EB8523"/>
<stop offset="0.9516" style="stop-color:#FEBD11"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="49.8,15.2 36,36.7 58.4,70 70,23.1 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="17.7187" y1="73.2922" x2="69.5556" y2="18.1519">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.7043" style="stop-color:#EB8523"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="51.1,15.7 49,0 18.8,33.6 27.6,42.3 20.8,70 58.4,70 "/>
</g>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="1.8281" y1="53.4275" x2="48.8245" y2="9.2255">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.6613" style="stop-color:#C41E57"/>
</linearGradient>
<polygon style="fill:url(#SVGID_3_);" points="49,0 11.6,0 0,47.1 55.6,47.1 "/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="49.8935" y1="-11.5569" x2="48.8588" y2="24.0352">
<stop offset="0.5" style="stop-color:#C41E57"/>
<stop offset="0.6668" style="stop-color:#D13F48"/>
<stop offset="0.7952" style="stop-color:#D94F39"/>
<stop offset="0.8656" style="stop-color:#DD5433"/>
</linearGradient>
<polygon style="fill:url(#SVGID_4_);" points="55.3,47.1 51.1,15.7 49,0 41.7,23 "/>
</g>
<g>
<rect x="13.4" y="13.5" transform="matrix(-1 2.577289e-003 -2.577289e-003 -1 70.0288 70.081)" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.6" y="48.6" transform="matrix(1 -2.577289e-003 2.577289e-003 1 -0.1287 6.634109e-002)" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<path style="fill:#FFFFFF;" d="M17.4,19.1l8.2,0c2.3,0,4,0.6,5.2,1.8c1,1,1.5,2.4,1.5,4.1l0,0.1c0,1.5-0.3,2.6-1.1,3.5
c-0.7,0.9-1.6,1.6-2.8,2l4.4,6.4l-4.6,0l-3.7-5.5l-3.3,0l0,5.5l-3.9,0L17.4,19.1z M25.3,27.8c1,0,1.7-0.2,2.2-0.7
c0.5-0.5,0.8-1.1,0.8-1.8l0-0.1c0-0.9-0.3-1.5-0.8-1.9c-0.5-0.4-1.3-0.6-2.3-0.6l-3.9,0l0,5.1L25.3,27.8z"/>
<path style="fill:#FFFFFF;" d="M36,33.2l-1.9,0l0-3.3l2.5,0l0.6-3.8l-2.3,0l0-3.3l2.8,0l0.6-3.7l3.4,0l-0.6,3.7l3.7,0l0.6-3.7
l3.4,0l-0.6,3.7l1.9,0l0,3.3l-2.5,0L47,29.9l2.3,0l0,3.3l-2.8,0L45.8,37l-3.4,0l0.7-3.8l-3.7,0L38.7,37l-3.4,0L36,33.2z
M43.7,29.9l0.6-3.8l-3.7,0L40,29.9L43.7,29.9z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1.7738" y1="31.2729" x2="40.1662" y2="31.2729">
<stop offset="0" style="stop-color:#905CFB"/>
<stop offset="6.772543e-002" style="stop-color:#776CF9"/>
<stop offset="0.1729" style="stop-color:#5681F7"/>
<stop offset="0.2865" style="stop-color:#3B92F5"/>
<stop offset="0.4097" style="stop-color:#269FF4"/>
<stop offset="0.5474" style="stop-color:#17A9F3"/>
<stop offset="0.7111" style="stop-color:#0FAEF2"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_1_);" d="M39.7,47.9l-6.1-34c-0.4-2.4-1.2-4.8-2.7-7.1c-2-3.2-5.2-5.4-8.8-6.3
C7.9-2.9-2.6,11.3,3.6,23.9c0,0,0,0,0,0l14.8,31.7c0.4,1,1,2,1.7,2.9c1.2,1.6,2.8,2.8,4.7,3.4C34.4,64.9,42.1,56.4,39.7,47.9z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="5.3113" y1="9.6691" x2="69.2278" y2="43.8664">
<stop offset="0" style="stop-color:#905CFB"/>
<stop offset="6.772543e-002" style="stop-color:#776CF9"/>
<stop offset="0.1729" style="stop-color:#5681F7"/>
<stop offset="0.2865" style="stop-color:#3B92F5"/>
<stop offset="0.4097" style="stop-color:#269FF4"/>
<stop offset="0.5474" style="stop-color:#17A9F3"/>
<stop offset="0.7111" style="stop-color:#0FAEF2"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_2_);" d="M67.4,26.5c-1.4-2.2-3.4-3.9-5.7-4.9L25.5,1.7l0,0c-1-0.5-2.1-1-3.3-1.3
C6.7-3.2-4.4,13.8,5.5,27c1.5,2,3.6,3.6,6,4.5L48,47.9c0.8,0.5,1.6,0.8,2.5,1.1C64.5,53.4,75.1,38.6,67.4,26.5z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-19.2836" y1="70.8198" x2="55.9833" y2="33.1863">
<stop offset="0" style="stop-color:#3BEA62"/>
<stop offset="0.117" style="stop-color:#31DE80"/>
<stop offset="0.3025" style="stop-color:#24CEA8"/>
<stop offset="0.4844" style="stop-color:#1AC1C9"/>
<stop offset="0.6592" style="stop-color:#12B7DF"/>
<stop offset="0.8238" style="stop-color:#0EB2ED"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_3_);" d="M67.4,26.5c-1.8-2.8-4.6-4.8-7.9-5.6c-3.5-0.8-6.8-0.5-9.6,0.7L11.4,36.1
c0,0-0.2,0.1-0.6,0.4C0.9,40.4-4,53.3,4,64c1.8,2.4,4.3,4.2,7.1,5c5.3,1.6,10.1,1,14-1.1c0,0,0.1,0,0.1,0l37.6-20.1
c0,0,0,0,0.1-0.1C69.5,43.9,72.6,34.6,67.4,26.5z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="38.9439" y1="5.8503" x2="5.4232" y2="77.5093">
<stop offset="0" style="stop-color:#3BEA62"/>
<stop offset="9.397750e-002" style="stop-color:#2FDB87"/>
<stop offset="0.196" style="stop-color:#24CEA8"/>
<stop offset="0.3063" style="stop-color:#1BC3C3"/>
<stop offset="0.4259" style="stop-color:#14BAD8"/>
<stop offset="0.5596" style="stop-color:#10B5E7"/>
<stop offset="0.7185" style="stop-color:#0DB1EF"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_4_);" d="M50.3,12.8c1.2-2.7,1.1-6-0.9-9c-1.1-1.8-2.9-3-4.9-3.5c-4.5-1.1-8.3,1-10.1,4.2L3.5,42
c0,0,0,0,0,0.1C-0.9,47.9-1.6,56.5,4,64c1.8,2.4,4.3,4.2,7.1,5c10.5,3.3,19.3-2.5,22.1-10.8L50.3,12.8z"/>
</g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.5" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<polygon style="fill:#FFFFFF;" points="22.9,22.7 17.5,22.7 17.5,19.1 32.3,19.1 32.3,22.7 26.8,22.7 26.8,37 22.9,37 "/>
<path style="fill:#FFFFFF;" d="M32.5,28.1L32.5,28.1c0-5.1,3.8-9.3,9.3-9.3c3.4,0,5.4,1.1,7.1,2.8l-2.5,2.9c-1.4-1.3-2.8-2-4.6-2
c-3,0-5.2,2.5-5.2,5.6V28c0,3.1,2.1,5.6,5.2,5.6c2,0,3.3-0.8,4.7-2.1l2.5,2.5c-1.8,2-3.9,3.2-7.3,3.2
C36.4,37.3,32.5,33.2,32.5,28.1"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,4 +1,4 @@
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
[![Translated](https://translate.servarr.com/widgets/servarr/-/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Backers on Open Collective](https://opencollective.com/Sonarr/backers/badge.svg)](#backers)
@@ -33,7 +33,7 @@ Note: GitHub Issues are for Bugs and Feature Requests Only
- Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.
- Automatically detects new episodes
- Can scan your existing library and download any missing episodes
- Can watch for better quality of the episodes you already have and do an automatic upgrade. _eg. from DVD to Blu-Ray_
- Can watch for better quality of the episodes you already have and do an automatic upgrade. *eg. from DVD to Blu-Ray*
- Automatic failed download handling will try another release if one fails
- Manual search so you can pick any release or to see why a release was not downloaded automatically
- Fully configurable episode renaming
@@ -52,7 +52,7 @@ This project exists thanks to all the people who contribute. [Contribute](CONTRI
### Supporters
This project would not be possible without the support of our users and software providers.
This project would not be possible without the support of our users and software providers.
[**Become a sponsor or backer**](https://opencollective.com/sonarr) to help us out!
#### Mega Sponsors
@@ -69,17 +69,13 @@ This project would not be possible without the support of our users and software
#### JetBrains
Thank you to [<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.png" alt="JetBrains" width="96">](http://www.jetbrains.com/) for providing us with free licenses to their great tools
Thank you to [<img src="/Logo/Jetbrains/jetbrains.svg" alt="JetBrains" width="32"> JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/TeamCity.png" alt="TeamCity" width="64">](http://www.jetbrains.com/teamcity/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/ReSharper.png" alt="ReSharper" width="64">](http://www.jetbrains.com/resharper/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/dotTrace.png" alt="dotTrace" width="64">](http://www.jetbrains.com/dottrace/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/Rider.png" alt="Rider" width="64">](http://www.jetbrains.com/rider/)
* [<img src="/Logo/Jetbrains/teamcity.svg" alt="TeamCity" width="32"> TeamCity](http://www.jetbrains.com/teamcity/)
* [<img src="/Logo/Jetbrains/resharper.svg" alt="ReSharper" width="32"> ReSharper](http://www.jetbrains.com/resharper/)
* [<img src="/Logo/Jetbrains/dottrace.svg" alt="dotTrace" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
### Licenses
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2024
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2023

View File

@@ -210,6 +210,7 @@ module.exports = {
'no-undef-init': 'off',
'no-undefined': 'off',
'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }],
'no-use-before-define': 'error',
// Node.js and CommonJS

View File

@@ -195,14 +195,9 @@ function HistoryRow(props: HistoryRowProps) {
}
if (name === 'downloadClient') {
const downloadClientName =
'downloadClientName' in data ? data.downloadClientName : null;
const downloadClient =
'downloadClient' in data ? data.downloadClient : null;
return (
<TableRowCell key={name} className={styles.downloadClient}>
{downloadClientName ?? downloadClient ?? ''}
{'downloadClient' in data ? data.downloadClient : ''}
</TableRowCell>
);
}

View File

@@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
@@ -130,8 +129,7 @@ class AddNewSeries extends Component {
<div className={styles.helpText}>
{translate('AddNewSeriesError')}
</div>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
<div>{getErrorMessage(error)}</div>
</div> : null
}

View File

@@ -1,15 +1,12 @@
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
import HistoryAppState from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import OAuthAppState from './OAuthAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
@@ -67,17 +64,14 @@ interface AppState {
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;
captcha: CaptchaAppState;
commands: CommandAppState;
episodeFiles: EpisodeFilesAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
oAuth: OAuthAppState;
parse: ParseAppState;
paths: PathsAppState;
providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;

View File

@@ -1,11 +0,0 @@
interface CaptchaAppState {
refreshing: false;
token: string;
siteKey: unknown;
secretToken: unknown;
ray: unknown;
stoken: unknown;
responseUrl: unknown;
}
export default CaptchaAppState;

View File

@@ -1,20 +1,11 @@
import AppSectionState from 'App/State/AppSectionState';
import RecentFolder from 'InteractiveImport/Folder/RecentFolder';
import ImportMode from 'InteractiveImport/ImportMode';
import InteractiveImport from 'InteractiveImport/InteractiveImport';
interface FavoriteFolder {
folder: string;
}
interface RecentFolder {
folder: string;
lastUsed: string;
}
interface InteractiveImportAppState extends AppSectionState<InteractiveImport> {
originalItems: InteractiveImport[];
importMode: ImportMode;
favoriteFolders: FavoriteFolder[];
recentFolders: RecentFolder[];
}

View File

@@ -1,9 +0,0 @@
import { Error } from './AppSectionState';
interface OAuthAppState {
authorizing: boolean;
result: Record<string, unknown> | null;
error: Error;
}
export default OAuthAppState;

View File

@@ -1,22 +0,0 @@
import AppSectionState from 'App/State/AppSectionState';
import Field, { FieldSelectOption } from 'typings/Field';
export interface ProviderOptions {
fields?: Field[];
}
interface ProviderOptionsDevice {
id: string;
name: string;
}
interface ProviderOptionsAppState {
devices: AppSectionState<ProviderOptionsDevice>;
servers: AppSectionState<FieldSelectOption<unknown>>;
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
getTags: AppSectionState<FieldSelectOption<unknown>>;
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
}
export default ProviderOptionsAppState;

View File

@@ -16,9 +16,6 @@ import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile';
import General from 'typings/Settings/General';
import NamingConfig from 'typings/Settings/NamingConfig';
import NamingExample from 'typings/Settings/NamingExample';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import UiSettings from 'typings/Settings/UiSettings';
export interface DownloadClientAppState
@@ -32,13 +29,6 @@ export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
AppSectionSaveState {}
export interface NamingExamplesAppState
extends AppSectionItemState<NamingExample> {}
export interface ImportListAppState
extends AppSectionState<ImportList>,
AppSectionDeleteState,
@@ -59,12 +49,6 @@ export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionItemSchemaState<QualityProfile> {}
export interface ReleaseProfilesAppState
extends AppSectionState<ReleaseProfile>,
AppSectionSaveState {
pendingChanges: Partial<ReleaseProfile>;
}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
@@ -97,11 +81,8 @@ interface SettingsAppState {
indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState;
releaseProfiles: ReleaseProfilesAppState;
ui: UiSettingsAppState;
}

View File

@@ -1,7 +1,7 @@
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 PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@@ -151,7 +151,7 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) {
</Alert>
) : null}
<PathInputInternal
<PathInput
className={styles.pathInput}
placeholder={translate('FileBrowserPlaceholderText')}
hasFileBrowser={false}

View File

@@ -13,7 +13,6 @@ import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue';
import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
@@ -81,9 +80,6 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValue;
case filterBuilderValueTypes.QUEUE_STATUS:
return QueueStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
return SeasonsMonitoredStatusFilterBuilderRowValue;

View File

@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TagInput from 'Components/Form/Tag/TagInput';
import TagInput from 'Components/Form/TagInput';
import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import convertToBytes from 'Utilities/Number/convertToBytes';

View File

@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import TagInputTag from 'Components/Form/Tag/TagInputTag';
import TagInputTag from 'Components/Form/TagInputTag';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './FilterBuilderRowValueTag.css';

View File

@@ -1,67 +0,0 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
const statusTagList = [
{
id: 'queued',
get name() {
return translate('Queued');
},
},
{
id: 'paused',
get name() {
return translate('Paused');
},
},
{
id: 'downloading',
get name() {
return translate('Downloading');
},
},
{
id: 'completed',
get name() {
return translate('Completed');
},
},
{
id: 'failed',
get name() {
return translate('Failed');
},
},
{
id: 'warning',
get name() {
return translate('Warning');
},
},
{
id: 'delay',
get name() {
return translate('Delay');
},
},
{
id: 'downloadClientUnavailable',
get name() {
return translate('DownloadClientUnavailable');
},
},
{
id: 'fallback',
get name() {
return translate('Fallback');
},
},
];
function QueueStatusFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
return <FilterBuilderRowValue {...props} tagList={statusTagList} />;
}
export default QueueStatusFilterBuilderRowValue;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const statusTagList = [
const seriesStatusList = [
{
id: 'continuing',
get name() {
@@ -32,7 +32,7 @@ const statusTagList = [
function SeriesStatusFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={statusTagList}
tagList={seriesStatusList}
{...props}
/>
);

View File

@@ -0,0 +1,98 @@
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 (
<AutoSuggestInput
{...otherProps}
name={name}
value={value}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onInputBlur={this.onInputBlur}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
);
}
}
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;

View File

@@ -1,81 +0,0 @@
import jdu from 'jdu';
import React, { SyntheticEvent, useCallback, useState } from 'react';
import {
ChangeEvent,
SuggestionsFetchRequestedParams,
} from 'react-autosuggest';
import { InputChanged } from 'typings/inputs';
import AutoSuggestInput from './AutoSuggestInput';
interface AutoCompleteInputProps {
name: string;
value?: string;
values: string[];
onChange: (change: InputChanged<string>) => unknown;
}
function AutoCompleteInput({
name,
value = '',
values,
onChange,
...otherProps
}: AutoCompleteInputProps) {
const [suggestions, setSuggestions] = useState<string[]>([]);
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 (
<AutoSuggestInput
{...otherProps}
name={name}
value={value}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
onInputChange={handleInputChange}
onInputBlur={handleInputBlur}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
/>
);
}
export default AutoCompleteInput;

View File

@@ -0,0 +1,257 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import styles from './AutoSuggestInput.css';
class AutoSuggestInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
}
componentDidUpdate(prevProps) {
if (
this._scheduleUpdate &&
prevProps.suggestions !== this.props.suggestions
) {
this._scheduleUpdate();
}
}
//
// Control
renderInputComponent = (inputProps) => {
const { renderInputComponent } = this.props;
return (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input
{...inputProps}
/>
</div>
);
}}
</Reference>
);
};
renderSuggestionsContainer = ({ containerProps, children }) => {
return (
<Portal>
<Popper
placement='bottom-start'
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
flip: {
padding: this.props.minHeight
}
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={children ? styles.suggestionsContainerOpen : undefined}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
};
//
// 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 (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={this.renderInputComponent}
renderSuggestionsContainer={this.renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}
}
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;

View File

@@ -1,259 +0,0 @@
import classNames from 'classnames';
import React, {
FocusEvent,
FormEvent,
KeyboardEvent,
KeyboardEventHandler,
MutableRefObject,
ReactNode,
Ref,
SyntheticEvent,
useCallback,
useEffect,
useRef,
} from 'react';
import Autosuggest, {
AutosuggestPropsBase,
BlurEvent,
ChangeEvent,
RenderInputComponentProps,
RenderSuggestionsContainerParams,
} from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { InputChanged } from 'typings/inputs';
import styles from './AutoSuggestInput.css';
interface AutoSuggestInputProps<T>
extends Omit<AutosuggestPropsBase<T>, 'renderInputComponent' | 'inputProps'> {
forwardedRef?: MutableRefObject<Autosuggest<T> | 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<HTMLDivElement>
) => ReactNode;
onInputChange: (
event: FormEvent<HTMLElement>,
params: ChangeEvent
) => unknown;
onInputKeyDown?: KeyboardEventHandler<HTMLElement>;
onInputFocus?: (event: SyntheticEvent) => unknown;
onInputBlur: (
event: FocusEvent<HTMLElement>,
params?: BlurEvent<T>
) => unknown;
onChange?: (change: InputChanged<T>) => unknown;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
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 (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input {...inputProps} />
</div>
);
}}
</Reference>
);
},
[renderInputComponent]
);
const renderSuggestionsContainer = useCallback(
({ containerProps, children }: RenderSuggestionsContainerParams) => {
return (
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
flip: {
padding: minHeight,
},
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={
children ? styles.suggestionsContainerOpen : undefined
}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight,
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
},
[minHeight, handleComputeMaxHeight]
);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
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 (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}
export default AutoSuggestInput;

View File

@@ -0,0 +1,84 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import FormInputButton from './FormInputButton';
import TextInput from './TextInput';
import styles from './CaptchaInput.css';
function CaptchaInput(props) {
const {
className,
name,
value,
hasError,
hasWarning,
refreshing,
siteKey,
secretToken,
onChange,
onRefreshPress,
onCaptchaChange
} = props;
return (
<div>
<div className={styles.captchaInputWrapper}>
<TextInput
className={classNames(
className,
styles.hasButton,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
name={name}
value={value}
onChange={onChange}
/>
<FormInputButton
onPress={onRefreshPress}
>
<Icon
name={icons.REFRESH}
isSpinning={refreshing}
/>
</FormInputButton>
</div>
{
!!siteKey && !!secretToken &&
<div className={styles.recaptchaWrapper}>
<ReCAPTCHA
sitekey={siteKey}
stoken={secretToken}
onChange={onCaptchaChange}
/>
</div>
}
</div>
);
}
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;

View File

@@ -1,118 +0,0 @@
import classNames from 'classnames';
import React, { useCallback, useEffect } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import {
getCaptchaCookie,
refreshCaptcha,
resetCaptcha,
} from 'Store/Actions/captchaActions';
import { InputChanged } from 'typings/inputs';
import FormInputButton from './FormInputButton';
import TextInput from './TextInput';
import styles from './CaptchaInput.css';
interface CaptchaInputProps {
className?: string;
name: string;
value?: string;
provider: string;
providerData: object;
hasError?: boolean;
hasWarning?: boolean;
refreshing: boolean;
siteKey?: string;
secretToken?: string;
onChange: (change: InputChanged<string>) => 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 (
<div>
<div className={styles.captchaInputWrapper}>
<TextInput
className={classNames(
className,
styles.hasButton,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
name={name}
value={value}
onChange={onChange}
/>
<FormInputButton onPress={handleRefreshPress}>
<Icon name={icons.REFRESH} isSpinning={refreshing} />
</FormInputButton>
</div>
{siteKey && secretToken ? (
<div className={styles.recaptchaWrapper}>
<ReCAPTCHA
sitekey={siteKey}
stoken={secretToken}
onChange={handleCaptchaChange}
/>
</div>
) : null}
</div>
);
}
export default CaptchaInput;

View File

@@ -0,0 +1,98 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions';
import CaptchaInput from './CaptchaInput';
function createMapStateToProps() {
return createSelector(
(state) => state.captcha,
(captcha) => {
return captcha;
}
);
}
const mapDispatchToProps = {
refreshCaptcha,
getCaptchaCookie,
resetCaptcha
};
class CaptchaInputConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
const {
name,
token,
onChange
} = this.props;
if (token && token !== prevProps.token) {
onChange({ name, value: token });
}
}
componentWillUnmount = () => {
this.props.resetCaptcha();
};
//
// Listeners
onRefreshPress = () => {
const {
provider,
providerData
} = this.props;
this.props.refreshCaptcha({ provider, providerData });
};
onCaptchaChange = (captchaResponse) => {
// If the captcha has expired `captchaResponse` will be null.
// In the event it's null don't try to get the captchaCookie.
// TODO: Should we clear the cookie? or reset the captcha?
if (!captchaResponse) {
return;
}
const {
provider,
providerData
} = this.props;
this.props.getCaptchaCookie({ provider, providerData, captchaResponse });
};
//
// Render
render() {
return (
<CaptchaInput
{...this.props}
onRefreshPress={this.onRefreshPress}
onCaptchaChange={this.onCaptchaChange}
/>
);
}
}
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);

View File

@@ -0,0 +1,191 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import FormInputHelpText from './FormInputHelpText';
import styles from './CheckInput.css';
class CheckInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._checkbox = null;
}
componentDidMount() {
this.setIndeterminate();
}
componentDidUpdate() {
this.setIndeterminate();
}
//
// Control
setIndeterminate() {
if (!this._checkbox) {
return;
}
const {
value,
uncheckedValue,
checkedValue
} = this.props;
this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
}
toggleChecked = (checked, shiftKey) => {
const {
name,
value,
checkedValue,
uncheckedValue
} = this.props;
const newValue = checked ? checkedValue : uncheckedValue;
if (value !== newValue) {
this.props.onChange({
name,
value: newValue,
shiftKey
});
}
};
//
// Listeners
setRef = (ref) => {
this._checkbox = ref;
};
onClick = (event) => {
if (this.props.isDisabled) {
return;
}
const shiftKey = event.nativeEvent.shiftKey;
const checked = !this._checkbox.checked;
event.preventDefault();
this.toggleChecked(checked, shiftKey);
};
onChange = (event) => {
const checked = event.target.checked;
const shiftKey = event.nativeEvent.shiftKey;
this.toggleChecked(checked, shiftKey);
};
//
// Render
render() {
const {
className,
containerClassName,
name,
value,
checkedValue,
uncheckedValue,
helpText,
helpTextWarning,
isDisabled,
kind
} = this.props;
const isChecked = value === checkedValue;
const isUnchecked = value === uncheckedValue;
const isIndeterminate = !isChecked && !isUnchecked;
const isCheckClass = `${kind}IsChecked`;
return (
<div className={containerClassName}>
<label
className={styles.label}
onClick={this.onClick}
>
<input
ref={this.setRef}
className={styles.checkbox}
type="checkbox"
name={name}
checked={isChecked}
disabled={isDisabled}
onChange={this.onChange}
/>
<div
className={classNames(
className,
isChecked ? styles[isCheckClass] : styles.isNotChecked,
isIndeterminate && styles.isIndeterminate,
isDisabled && styles.isDisabled
)}
>
{
isChecked &&
<Icon name={icons.CHECK} />
}
{
isIndeterminate &&
<Icon name={icons.CHECK_INDETERMINATE} />
}
</div>
{
helpText &&
<FormInputHelpText
className={styles.helpText}
text={helpText}
/>
}
{
!helpText && helpTextWarning &&
<FormInputHelpText
className={styles.helpText}
text={helpTextWarning}
isWarning={true}
/>
}
</label>
</div>
);
}
}
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;

View File

@@ -1,141 +0,0 @@
import classNames from 'classnames';
import React, { SyntheticEvent, useCallback, useEffect, useRef } from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { CheckInputChanged } from 'typings/inputs';
import FormInputHelpText from './FormInputHelpText';
import styles from './CheckInput.css';
interface ChangeEvent<T = Element> extends SyntheticEvent<T, MouseEvent> {
target: EventTarget & T;
}
interface CheckInputProps {
className?: string;
containerClassName?: string;
name: string;
checkedValue?: boolean;
uncheckedValue?: boolean;
value?: string | boolean;
helpText?: string;
helpTextWarning?: string;
isDisabled?: boolean;
kind?: Extract<Kind, keyof typeof styles>;
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<HTMLInputElement>(null);
const isChecked = value === checkedValue;
const isUnchecked = value === uncheckedValue;
const isIndeterminate = !isChecked && !isUnchecked;
const isCheckClass: keyof typeof styles = `${kind}IsChecked`;
const toggleChecked = useCallback(
(checked: boolean, shiftKey: boolean) => {
const newValue = checked ? checkedValue : uncheckedValue;
if (value !== newValue) {
onChange({
name,
value: newValue,
shiftKey,
});
}
},
[name, value, checkedValue, uncheckedValue, onChange]
);
const handleClick = useCallback(
(event: SyntheticEvent<HTMLElement, MouseEvent>) => {
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<HTMLInputElement>) => {
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 (
<div className={containerClassName}>
<label className={styles.label} onClick={handleClick}>
<input
ref={inputRef}
className={styles.checkbox}
type="checkbox"
name={name}
checked={isChecked}
disabled={isDisabled}
onChange={handleChange}
/>
<div
className={classNames(
className,
isChecked ? styles[isCheckClass] : styles.isNotChecked,
isIndeterminate && styles.isIndeterminate,
isDisabled && styles.isDisabled
)}
>
{isChecked ? <Icon name={icons.CHECK} /> : null}
{isIndeterminate ? <Icon name={icons.CHECK_INDETERMINATE} /> : null}
</div>
{helpText ? (
<FormInputHelpText className={styles.helpText} text={helpText} />
) : null}
{!helpText && helpTextWarning ? (
<FormInputHelpText
className={styles.helpText}
text={helpTextWarning}
isWarning={true}
/>
) : null}
</label>
</div>
);
}
export default CheckInput;

View File

@@ -3,6 +3,6 @@
}
.input {
composes: input from '~Components/Form/Tag/TagInput.css';
composes: input from '~./TagInput.css';
composes: hasButton from '~Components/Form/Input.css';
}

View File

@@ -0,0 +1,106 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import FormInputButton from './FormInputButton';
import TagInput from './TagInput';
import styles from './DeviceInput.css';
class DeviceInput extends Component {
onTagAdd = (device) => {
const {
name,
value,
onChange
} = this.props;
// New tags won't have an ID, only a name.
const deviceId = device.id || device.name;
onChange({
name,
value: [...value, deviceId]
});
};
onTagDelete = ({ index }) => {
const {
name,
value,
onChange
} = this.props;
const newValue = value.slice();
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
};
//
// Render
render() {
const {
className,
name,
items,
selectedDevices,
hasError,
hasWarning,
isFetching,
onRefreshPress
} = this.props;
return (
<div className={className}>
<TagInput
inputContainerClassName={styles.input}
name={name}
tags={selectedDevices}
tagList={items}
allowNew={true}
minQueryLength={0}
hasError={hasError}
hasWarning={hasWarning}
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
/>
<FormInputButton
onPress={onRefreshPress}
>
<Icon
name={icons.REFRESH}
isSpinning={isFetching}
/>
</FormInputButton>
</div>
);
}
}
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;

View File

@@ -0,0 +1,104 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
import DeviceInput from './DeviceInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(state) => state.providerOptions.devices || defaultState,
(value, devices) => {
return {
...devices,
selectedDevices: value.map((valueDevice) => {
// Disable equality ESLint rule so we don't need to worry about
// a type mismatch between the value items and the device ID.
// eslint-disable-next-line eqeqeq
const device = devices.items.find((d) => d.id == valueDevice);
if (device) {
return {
id: device.id,
name: `${device.name} (${device.id})`
};
}
return {
id: valueDevice,
name: `Unknown (${valueDevice})`
};
})
};
}
);
}
const mapDispatchToProps = {
dispatchFetchOptions: fetchOptions,
dispatchClearOptions: clearOptions
};
class DeviceInputConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
this._populate();
};
componentWillUnmount = () => {
this.props.dispatchClearOptions({ section: 'devices' });
};
//
// Control
_populate() {
const {
provider,
providerData,
dispatchFetchOptions
} = this.props;
dispatchFetchOptions({
section: 'devices',
action: 'getDevices',
provider,
providerData
});
}
//
// Listeners
onRefreshPress = () => {
this._populate();
};
//
// Render
render() {
return (
<DeviceInput
{...this.props}
onRefreshPress={this.onRefreshPress}
/>
);
}
}
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);

View File

@@ -0,0 +1,102 @@
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 (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
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);

View File

@@ -73,12 +73,6 @@
padding: 10px 0;
}
.optionsInnerModalBody {
composes: innerModalBody from '~Components/Modal/ModalBody.css';
padding: 0;
}
.optionsModalScroller {
composes: scroller from '~Components/Scroller/Scroller.css';

View File

@@ -14,7 +14,6 @@ interface CssExports {
'mobileCloseButtonContainer': string;
'options': string;
'optionsContainer': string;
'optionsInnerModalBody': string;
'optionsModal': string;
'optionsModalBody': string;
'optionsModalScroller': string;

View File

@@ -0,0 +1,614 @@
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 (
<div>
<Manager>
<Reference>
{({ ref }) => (
<div
ref={ref}
id={this._buttonId}
>
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
{
isEditable ?
<div
className={styles.editableContainer}
>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={this.onFocus}
onBlur={this.onBlur}
onChange={onChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer)
}
onPress={this.onPress}
>
{
isFetching ?
<LoadingIndicator
className={styles.loading}
size={20}
/> :
null
}
{
isFetching ?
null :
<Icon
name={icons.CARET_DOWN}
/>
}
</Link>
</div> :
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
value={selectedValue}
values={values}
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
{
isFetching ?
<LoadingIndicator
className={styles.loading}
size={20}
/> :
null
}
{
isFetching ?
null :
<Icon
name={icons.CARET_DOWN}
/>
}
</div>
</Link>
}
</Measure>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
}
}}
>
{({ ref, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={ref}
id={this._optionsId}
className={styles.optionsContainer}
style={{
...style,
minWidth: width
}}
>
{
isOpen && !isMobile ?
<Scroller
className={styles.options}
style={{
maxHeight: style.maxHeight
}}
>
{
values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected = hasParent && Array.isArray(value) && value.includes(v.parentKey);
return (
<OptionComponent
key={v.key}
id={v.key}
depth={depth}
isSelected={isSelectedItem(index, this.props)}
isDisabled={parentSelected}
isMultiSelect={isMultiSelect}
{...valueOptions}
{...v}
isMobile={false}
onSelect={this.onSelect}
>
{v.value}
</OptionComponent>
);
})
}
</Scroller> :
null
}
</div>
);
}
}
</Popper>
</Portal>
</Manager>
{
isMobile ?
<Modal
className={styles.optionsModal}
size={sizes.EXTRA_SMALL}
isOpen={isOpen}
onModalClose={this.onOptionsModalClose}
>
<ModalBody
className={styles.optionsModalBody}
innerClassName={styles.optionsInnerModalBody}
scrollDirection={scrollDirections.NONE}
>
<Scroller className={styles.optionsModalScroller}>
<div className={styles.mobileCloseButtonContainer}>
<Link
className={styles.mobileCloseButton}
onPress={this.onOptionsModalClose}
>
<Icon
name={icons.CLOSE}
size={18}
/>
</Link>
</div>
{
values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected = hasParent && value.includes(v.parentKey);
return (
<OptionComponent
key={v.key}
id={v.key}
depth={depth}
isSelected={isSelectedItem(index, this.props)}
isMultiSelect={isMultiSelect}
isDisabled={parentSelected}
{...valueOptions}
{...v}
isMobile={true}
onSelect={this.onSelect}
>
{v.value}
</OptionComponent>
);
})
}
</Scroller>
</ModalBody>
</Modal> :
null
}
</div>
);
}
}
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;

View File

@@ -0,0 +1,162 @@
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 (
<EnhancedSelectInput
{...this.props}
onOpen={this.onOpen}
/>
);
}
}
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);

View File

@@ -16,13 +16,13 @@
}
.optionCheck {
composes: container from '~Components/Form/CheckInput.css';
composes: container from '~./CheckInput.css';
flex: 0 0 0;
}
.optionCheckInput {
composes: input from '~Components/Form/CheckInput.css';
composes: input from '~./CheckInput.css';
margin-top: 0;
}

View File

@@ -0,0 +1,113 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import CheckInput from './CheckInput';
import styles from './EnhancedSelectInputOption.css';
class EnhancedSelectInputOption extends Component {
//
// Listeners
onPress = (e) => {
e.preventDefault();
const {
id,
onSelect
} = this.props;
onSelect(id);
};
onCheckPress = () => {
// CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation.
};
//
// Render
render() {
const {
className,
id,
depth,
isSelected,
isDisabled,
isHidden,
isMultiSelect,
isMobile,
children
} = this.props;
return (
<Link
className={classNames(
className,
isSelected && !isMultiSelect && styles.isSelected,
isDisabled && !isMultiSelect && styles.isDisabled,
isHidden && styles.isHidden,
isMobile && styles.isMobile
)}
component="div"
isDisabled={isDisabled}
onPress={this.onPress}
>
{
depth !== 0 &&
<div style={{ width: `${depth * 20}px` }} />
}
{
isMultiSelect &&
<CheckInput
className={styles.optionCheckInput}
containerClassName={styles.optionCheck}
name={`select-${id}`}
value={isSelected}
isDisabled={isDisabled}
onChange={this.onCheckPress}
/>
}
{children}
{
isMobile &&
<div className={styles.iconContainer}>
<Icon
name={isSelected ? icons.CHECK_CIRCLE : icons.CIRCLE_OUTLINE}
/>
</div>
}
</Link>
);
}
}
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;

View File

@@ -0,0 +1,35 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import styles from './EnhancedSelectInputSelectedValue.css';
function EnhancedSelectInputSelectedValue(props) {
const {
className,
children,
isDisabled
} = props;
return (
<div className={classNames(
className,
isDisabled && styles.isDisabled
)}
>
{children}
</div>
);
}
EnhancedSelectInputSelectedValue.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.node,
isDisabled: PropTypes.bool.isRequired
};
EnhancedSelectInputSelectedValue.defaultProps = {
className: styles.selectedValue,
isDisabled: false
};
export default EnhancedSelectInputSelectedValue;

View File

@@ -0,0 +1,66 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import { kinds } from 'Helpers/Props';
import styles from './Form.css';
function Form(props) {
const {
children,
validationErrors,
validationWarnings,
// eslint-disable-next-line no-unused-vars
...otherProps
} = props;
return (
<div>
{
validationErrors.length || validationWarnings.length ?
<div className={styles.validationFailures}>
{
validationErrors.map((error, index) => {
return (
<Alert
key={index}
kind={kinds.DANGER}
>
{error.errorMessage}
</Alert>
);
})
}
{
validationWarnings.map((warning, index) => {
return (
<Alert
key={index}
kind={kinds.WARNING}
>
{warning.errorMessage}
</Alert>
);
})
}
</div> :
null
}
{children}
</div>
);
}
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;

View File

@@ -1,45 +0,0 @@
import React, { ReactNode } from 'react';
import Alert from 'Components/Alert';
import { kinds } from 'Helpers/Props';
import { ValidationError, ValidationWarning } from 'typings/pending';
import styles from './Form.css';
export interface FormProps {
children: ReactNode;
validationErrors?: ValidationError[];
validationWarnings?: ValidationWarning[];
}
function Form({
children,
validationErrors = [],
validationWarnings = [],
}: FormProps) {
return (
<div>
{validationErrors.length || validationWarnings.length ? (
<div className={styles.validationFailures}>
{validationErrors.map((error, index) => {
return (
<Alert key={index} kind={kinds.DANGER}>
{error.errorMessage}
</Alert>
);
})}
{validationWarnings.map((warning, index) => {
return (
<Alert key={index} kind={kinds.WARNING}>
{warning.errorMessage}
</Alert>
);
})}
</div>
) : null}
{children}
</div>
);
}
export default Form;

View File

@@ -0,0 +1,56 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { map } from 'Helpers/elementChildren';
import { sizes } from 'Helpers/Props';
import styles from './FormGroup.css';
function FormGroup(props) {
const {
className,
children,
size,
advancedSettings,
isAdvanced,
...otherProps
} = props;
if (!advancedSettings && isAdvanced) {
return null;
}
const childProps = isAdvanced ? { isAdvanced } : {};
return (
<div
className={classNames(
className,
styles[size]
)}
{...otherProps}
>
{
map(children, (child) => {
return React.cloneElement(child, childProps);
})
}
</div>
);
}
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;

View File

@@ -1,43 +0,0 @@
import classNames from 'classnames';
import React, { Children, ComponentPropsWithoutRef, ReactNode } from 'react';
import { Size } from 'Helpers/Props/sizes';
import styles from './FormGroup.css';
interface FormGroupProps extends ComponentPropsWithoutRef<'div'> {
className?: string;
children: ReactNode;
size?: Extract<Size, keyof typeof styles>;
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 (
<div className={classNames(className, styles[size])} {...otherProps}>
{Children.map(children, (child) => {
if (!React.isValidElement(child)) {
return child;
}
return React.cloneElement(child, childProps);
})}
</div>
);
}
export default FormGroup;

View File

@@ -0,0 +1,54 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css';
function FormInputButton(props) {
const {
className,
canSpin,
isLastButton,
...otherProps
} = props;
if (canSpin) {
return (
<SpinnerButton
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
FormInputButton.propTypes = {
className: PropTypes.string.isRequired,
isLastButton: PropTypes.bool.isRequired,
canSpin: PropTypes.bool.isRequired
};
FormInputButton.defaultProps = {
className: styles.button,
isLastButton: true,
canSpin: false
};
export default FormInputButton;

View File

@@ -1,38 +0,0 @@
import classNames from 'classnames';
import React from 'react';
import Button, { ButtonProps } from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css';
export interface FormInputButtonProps extends ButtonProps {
canSpin?: boolean;
isLastButton?: boolean;
}
function FormInputButton({
className = styles.button,
canSpin = false,
isLastButton = true,
...otherProps
}: FormInputButtonProps) {
if (canSpin) {
return (
<SpinnerButton
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
export default FormInputButton;

View File

@@ -0,0 +1,307 @@
import PropTypes from 'prop-types';
import React from 'react';
import Link from 'Components/Link/Link';
import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AutoCompleteInput from './AutoCompleteInput';
import CaptchaInputConnector from './CaptchaInputConnector';
import CheckInput from './CheckInput';
import DeviceInputConnector from './DeviceInputConnector';
import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector';
import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
import FormInputHelpText from './FormInputHelpText';
import IndexerFlagsSelectInput from './IndexerFlagsSelectInput';
import IndexerSelectInputConnector from './IndexerSelectInputConnector';
import KeyValueListInput from './KeyValueListInput';
import MonitorEpisodesSelectInput from './MonitorEpisodesSelectInput';
import MonitorNewItemsSelectInput from './MonitorNewItemsSelectInput';
import NumberInput from './NumberInput';
import OAuthInputConnector from './OAuthInputConnector';
import PasswordInput from './PasswordInput';
import PathInputConnector from './PathInputConnector';
import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
import SeriesTagInput from './SeriesTagInput';
import SeriesTypeSelectInput from './SeriesTypeSelectInput';
import TagInputConnector from './TagInputConnector';
import TagSelectInputConnector from './TagSelectInputConnector';
import TextArea from './TextArea';
import TextInput from './TextInput';
import TextTagInputConnector from './TextTagInputConnector';
import UMaskInput from './UMaskInput';
import styles from './FormInputGroup.css';
function getComponent(type) {
switch (type) {
case inputTypes.AUTO_COMPLETE:
return AutoCompleteInput;
case inputTypes.CAPTCHA:
return CaptchaInputConnector;
case inputTypes.CHECK:
return CheckInput;
case inputTypes.DEVICE:
return DeviceInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.MONITOR_EPISODES_SELECT:
return MonitorEpisodesSelectInput;
case inputTypes.MONITOR_NEW_ITEMS_SELECT:
return MonitorNewItemsSelectInput;
case inputTypes.NUMBER:
return NumberInput;
case inputTypes.OAUTH:
return OAuthInputConnector;
case inputTypes.PASSWORD:
return PasswordInput;
case inputTypes.PATH:
return PathInputConnector;
case inputTypes.QUALITY_PROFILE_SELECT:
return QualityProfileSelectInputConnector;
case inputTypes.INDEXER_SELECT:
return IndexerSelectInputConnector;
case inputTypes.INDEXER_FLAGS_SELECT:
return IndexerFlagsSelectInput;
case inputTypes.DOWNLOAD_CLIENT_SELECT:
return DownloadClientSelectInputConnector;
case inputTypes.ROOT_FOLDER_SELECT:
return RootFolderSelectInputConnector;
case inputTypes.SELECT:
return EnhancedSelectInput;
case inputTypes.DYNAMIC_SELECT:
return EnhancedSelectInputConnector;
case inputTypes.SERIES_TAG:
return SeriesTagInput;
case inputTypes.SERIES_TYPE_SELECT:
return SeriesTypeSelectInput;
case inputTypes.TAG:
return TagInputConnector;
case inputTypes.TEXT_AREA:
return TextArea;
case inputTypes.TEXT_TAG:
return TextTagInputConnector;
case inputTypes.TAG_SELECT:
return TagSelectInputConnector;
case inputTypes.UMASK:
return UMaskInput;
default:
return TextInput;
}
}
function FormInputGroup(props) {
const {
className,
containerClassName,
inputClassName,
type,
unit,
buttons,
helpText,
helpTexts,
helpTextWarning,
helpLink,
pending,
errors,
warnings,
...otherProps
} = props;
const InputComponent = getComponent(type);
const checkInput = type === inputTypes.CHECK;
const hasError = !!errors.length;
const hasWarning = !hasError && !!warnings.length;
const buttonsArray = React.Children.toArray(buttons);
const lastButtonIndex = buttonsArray.length - 1;
const hasButton = !!buttonsArray.length;
return (
<div className={containerClassName}>
<div className={className}>
<div className={styles.inputContainer}>
<InputComponent
className={inputClassName}
helpText={helpText}
helpTextWarning={helpTextWarning}
hasError={hasError}
hasWarning={hasWarning}
hasButton={hasButton}
{...otherProps}
/>
{
unit &&
<div
className={
type === inputTypes.NUMBER ?
styles.inputUnitNumber :
styles.inputUnit
}
>
{unit}
</div>
}
</div>
{
buttonsArray.map((button, index) => {
return React.cloneElement(
button,
{
isLastButton: index === lastButtonIndex
}
);
})
}
{/* <div className={styles.pendingChangesContainer}>
{
pending &&
<Icon
name={icons.UNSAVED_SETTING}
className={styles.pendingChangesIcon}
title="Change has not been saved yet"
/>
}
</div> */}
</div>
{
!checkInput && helpText &&
<FormInputHelpText
text={helpText}
/>
}
{
!checkInput && helpTexts &&
<div>
{
helpTexts.map((text, index) => {
return (
<FormInputHelpText
key={index}
text={text}
isCheckInput={checkInput}
/>
);
})
}
</div>
}
{
(!checkInput || helpText) && helpTextWarning &&
<FormInputHelpText
text={helpTextWarning}
isWarning={true}
/>
}
{
helpLink &&
<Link
to={helpLink}
>
{translate('MoreInfo')}
</Link>
}
{
errors.map((error, index) => {
return (
<FormInputHelpText
key={index}
text={error.message}
link={error.link}
tooltip={error.detailedMessage}
isError={true}
isCheckInput={checkInput}
/>
);
})
}
{
warnings.map((warning, index) => {
return (
<FormInputHelpText
key={index}
text={warning.message}
link={warning.link}
tooltip={warning.detailedMessage}
isWarning={true}
isCheckInput={checkInput}
/>
);
})
}
</div>
);
}
FormInputGroup.propTypes = {
className: PropTypes.string.isRequired,
containerClassName: PropTypes.string.isRequired,
inputClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
isDisabled: PropTypes.bool,
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
min: PropTypes.number,
max: PropTypes.number,
unit: PropTypes.string,
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
helpText: PropTypes.string,
helpTexts: PropTypes.arrayOf(PropTypes.string),
helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
autoFocus: PropTypes.bool,
includeNoChange: PropTypes.bool,
includeNoChangeDisabled: PropTypes.bool,
selectedValueOptions: PropTypes.object,
indexerFlags: PropTypes.number,
pending: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func.isRequired
};
FormInputGroup.defaultProps = {
className: styles.inputGroup,
containerClassName: styles.inputGroupContainer,
type: inputTypes.TEXT,
buttons: [],
helpTexts: [],
errors: [],
warnings: []
};
export default FormInputGroup;

View File

@@ -1,292 +0,0 @@
import React, { ReactNode } from 'react';
import Link from 'Components/Link/Link';
import { inputTypes } from 'Helpers/Props';
import { InputType } from 'Helpers/Props/inputTypes';
import { Kind } from 'Helpers/Props/kinds';
import { ValidationError, ValidationWarning } from 'typings/pending';
import translate from 'Utilities/String/translate';
import AutoCompleteInput from './AutoCompleteInput';
import CaptchaInput from './CaptchaInput';
import CheckInput from './CheckInput';
import { FormInputButtonProps } from './FormInputButton';
import FormInputHelpText from './FormInputHelpText';
import NumberInput from './NumberInput';
import OAuthInput from './OAuthInput';
import PasswordInput from './PasswordInput';
import PathInput from './PathInput';
import DownloadClientSelectInput from './Select/DownloadClientSelectInput';
import EnhancedSelectInput from './Select/EnhancedSelectInput';
import IndexerFlagsSelectInput from './Select/IndexerFlagsSelectInput';
import IndexerSelectInput from './Select/IndexerSelectInput';
import MonitorEpisodesSelectInput from './Select/MonitorEpisodesSelectInput';
import MonitorNewItemsSelectInput from './Select/MonitorNewItemsSelectInput';
import ProviderDataSelectInput from './Select/ProviderOptionSelectInput';
import QualityProfileSelectInput from './Select/QualityProfileSelectInput';
import RootFolderSelectInput from './Select/RootFolderSelectInput';
import SeriesTypeSelectInput from './Select/SeriesTypeSelectInput';
import UMaskInput from './Select/UMaskInput';
import DeviceInput from './Tag/DeviceInput';
import SeriesTagInput from './Tag/SeriesTagInput';
import TagSelectInput from './Tag/TagSelectInput';
import TextTagInput from './Tag/TextTagInput';
import TextArea from './TextArea';
import TextInput from './TextInput';
import styles from './FormInputGroup.css';
function getComponent(type: InputType) {
switch (type) {
case inputTypes.AUTO_COMPLETE:
return AutoCompleteInput;
case inputTypes.CAPTCHA:
return CaptchaInput;
case inputTypes.CHECK:
return CheckInput;
case inputTypes.DEVICE:
return DeviceInput;
case inputTypes.MONITOR_EPISODES_SELECT:
return MonitorEpisodesSelectInput;
case inputTypes.MONITOR_NEW_ITEMS_SELECT:
return MonitorNewItemsSelectInput;
case inputTypes.NUMBER:
return NumberInput;
case inputTypes.OAUTH:
return OAuthInput;
case inputTypes.PASSWORD:
return PasswordInput;
case inputTypes.PATH:
return PathInput;
case inputTypes.QUALITY_PROFILE_SELECT:
return QualityProfileSelectInput;
case inputTypes.INDEXER_SELECT:
return IndexerSelectInput;
case inputTypes.INDEXER_FLAGS_SELECT:
return IndexerFlagsSelectInput;
case inputTypes.DOWNLOAD_CLIENT_SELECT:
return DownloadClientSelectInput;
case inputTypes.ROOT_FOLDER_SELECT:
return RootFolderSelectInput;
case inputTypes.SELECT:
return EnhancedSelectInput;
case inputTypes.DYNAMIC_SELECT:
return ProviderDataSelectInput;
case inputTypes.TAG:
case inputTypes.SERIES_TAG:
return SeriesTagInput;
case inputTypes.SERIES_TYPE_SELECT:
return SeriesTypeSelectInput;
case inputTypes.TEXT_AREA:
return TextArea;
case inputTypes.TEXT_TAG:
return TextTagInput;
case inputTypes.TAG_SELECT:
return TagSelectInput;
case inputTypes.UMASK:
return UMaskInput;
default:
return TextInput;
}
}
// TODO: Remove once all parent components are updated to TSX and we can refactor to a consistent type
interface ValidationMessage {
message: string;
}
interface FormInputGroupProps<T> {
className?: string;
containerClassName?: string;
inputClassName?: string;
name: string;
value?: unknown;
values?: unknown[];
isDisabled?: boolean;
type?: InputType;
kind?: Kind;
min?: number;
max?: number;
unit?: string;
buttons?: ReactNode | ReactNode[];
helpText?: string;
helpTexts?: string[];
helpTextWarning?: string;
helpLink?: string;
placeholder?: string;
autoFocus?: boolean;
includeNoChange?: boolean;
includeNoChangeDisabled?: boolean;
selectedValueOptions?: object;
indexerFlags?: number;
pending?: boolean;
canEdit?: boolean;
includeAny?: boolean;
delimiters?: string[];
errors?: (ValidationMessage | ValidationError)[];
warnings?: (ValidationMessage | ValidationWarning)[];
onChange: (args: T) => void;
}
function FormInputGroup<T>(props: FormInputGroupProps<T>) {
const {
className = styles.inputGroup,
containerClassName = styles.inputGroupContainer,
inputClassName,
type = 'text',
unit,
buttons = [],
helpText,
helpTexts = [],
helpTextWarning,
helpLink,
pending,
errors = [],
warnings = [],
...otherProps
} = props;
const InputComponent = getComponent(type);
const checkInput = type === inputTypes.CHECK;
const hasError = !!errors.length;
const hasWarning = !hasError && !!warnings.length;
const buttonsArray = React.Children.toArray(buttons);
const lastButtonIndex = buttonsArray.length - 1;
const hasButton = !!buttonsArray.length;
return (
<div className={containerClassName}>
<div className={className}>
<div className={styles.inputContainer}>
{/* @ts-expect-error - need to pass through all the expected options */}
<InputComponent
className={inputClassName}
helpText={helpText}
helpTextWarning={helpTextWarning}
hasError={hasError}
hasWarning={hasWarning}
hasButton={hasButton}
{...otherProps}
/>
{unit && (
<div
className={
type === inputTypes.NUMBER
? styles.inputUnitNumber
: styles.inputUnit
}
>
{unit}
</div>
)}
</div>
{buttonsArray.map((button, index) => {
if (!React.isValidElement<FormInputButtonProps>(button)) {
return button;
}
return React.cloneElement(button, {
isLastButton: index === lastButtonIndex,
});
})}
{/* <div className={styles.pendingChangesContainer}>
{
pending &&
<Icon
name={icons.UNSAVED_SETTING}
className={styles.pendingChangesIcon}
title="Change has not been saved yet"
/>
}
</div> */}
</div>
{!checkInput && helpText ? <FormInputHelpText text={helpText} /> : null}
{!checkInput && helpTexts ? (
<div>
{helpTexts.map((text, index) => {
return (
<FormInputHelpText
key={index}
text={text}
isCheckInput={checkInput}
/>
);
})}
</div>
) : null}
{(!checkInput || helpText) && helpTextWarning ? (
<FormInputHelpText text={helpTextWarning} isWarning={true} />
) : null}
{helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null}
{errors.map((error, index) => {
return 'message' in error ? (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={error.errorMessage}
link={error.infoLink}
tooltip={error.detailedDescription}
isError={true}
isCheckInput={checkInput}
/>
);
})}
{warnings.map((warning, index) => {
return 'message' in warning ? (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={warning.errorMessage}
link={warning.infoLink}
tooltip={warning.detailedDescription}
isWarning={true}
isCheckInput={checkInput}
/>
);
})}
</div>
);
}
export default FormInputGroup;

View File

@@ -0,0 +1,74 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import styles from './FormInputHelpText.css';
function FormInputHelpText(props) {
const {
className,
text,
link,
tooltip,
isError,
isWarning,
isCheckInput
} = props;
return (
<div className={classNames(
className,
isError && styles.isError,
isWarning && styles.isWarning,
isCheckInput && styles.isCheckInput
)}
>
{text}
{
link ?
<Link
className={styles.link}
to={link}
title={tooltip}
>
<Icon
name={icons.EXTERNAL_LINK}
/>
</Link> :
null
}
{
!link && tooltip ?
<Icon
containerClassName={styles.details}
name={icons.INFO}
title={tooltip}
/> :
null
}
</div>
);
}
FormInputHelpText.propTypes = {
className: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
link: PropTypes.string,
tooltip: PropTypes.string,
isError: PropTypes.bool,
isWarning: PropTypes.bool,
isCheckInput: PropTypes.bool
};
FormInputHelpText.defaultProps = {
className: styles.helpText,
isError: false,
isWarning: false,
isCheckInput: false
};
export default FormInputHelpText;

View File

@@ -1,55 +0,0 @@
import classNames from 'classnames';
import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import styles from './FormInputHelpText.css';
interface FormInputHelpTextProps {
className?: string;
text: string;
link?: string;
tooltip?: string;
isError?: boolean;
isWarning?: boolean;
isCheckInput?: boolean;
}
function FormInputHelpText({
className = styles.helpText,
text,
link,
tooltip,
isError = false,
isWarning = false,
isCheckInput = false,
}: FormInputHelpTextProps) {
return (
<div
className={classNames(
className,
isError && styles.isError,
isWarning && styles.isWarning,
isCheckInput && styles.isCheckInput
)}
>
{text}
{link ? (
<Link className={styles.link} to={link} title={tooltip}>
<Icon name={icons.EXTERNAL_LINK} />
</Link>
) : null}
{!link && tooltip ? (
<Icon
containerClassName={styles.details}
name={icons.INFO}
title={tooltip}
/>
) : null}
</div>
);
}
export default FormInputHelpText;

View File

@@ -0,0 +1,52 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { sizes } from 'Helpers/Props';
import styles from './FormLabel.css';
function FormLabel(props) {
const {
children,
className,
errorClassName,
size,
name,
hasError,
isAdvanced,
...otherProps
} = props;
return (
<label
{...otherProps}
className={classNames(
className,
styles[size],
hasError && errorClassName,
isAdvanced && styles.isAdvanced
)}
htmlFor={name}
>
{children}
</label>
);
}
FormLabel.propTypes = {
children: PropTypes.oneOfType([PropTypes.node, PropTypes.string]).isRequired,
className: PropTypes.string,
errorClassName: PropTypes.string,
size: PropTypes.oneOf(sizes.all),
name: PropTypes.string,
hasError: PropTypes.bool,
isAdvanced: PropTypes.bool
};
FormLabel.defaultProps = {
className: styles.label,
errorClassName: styles.hasError,
isAdvanced: false,
size: sizes.LARGE
};
export default FormLabel;

View File

@@ -1,42 +0,0 @@
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import { Size } from 'Helpers/Props/sizes';
import styles from './FormLabel.css';
interface FormLabelProps {
children: ReactNode;
className?: string;
errorClassName?: string;
size?: Extract<Size, keyof typeof styles>;
name?: string;
hasError?: boolean;
isAdvanced?: boolean;
}
function FormLabel(props: FormLabelProps) {
const {
children,
className = styles.label,
errorClassName = styles.hasError,
size = 'large',
name,
hasError,
isAdvanced = false,
} = props;
return (
<label
className={classNames(
className,
styles[size],
hasError && errorClassName,
isAdvanced && styles.isAdvanced
)}
htmlFor={name}
>
{children}
</label>
);
}
export default FormLabel;

View File

@@ -0,0 +1,66 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './HintedSelectInputOption.css';
function HintedSelectInputOption(props) {
const {
id,
value,
hint,
depth,
isSelected,
isDisabled,
isMultiSelect,
isMobile,
...otherProps
} = props;
return (
<EnhancedSelectInputOption
id={id}
depth={depth}
isSelected={isSelected}
isDisabled={isDisabled}
isHidden={isDisabled}
isMultiSelect={isMultiSelect}
isMobile={isMobile}
{...otherProps}
>
<div className={classNames(
styles.optionText,
isMobile && styles.isMobile
)}
>
<div>{value}</div>
{
hint != null &&
<div className={styles.hintText}>
{hint}
</div>
}
</div>
</EnhancedSelectInputOption>
);
}
HintedSelectInputOption.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
value: PropTypes.string.isRequired,
hint: PropTypes.node,
depth: PropTypes.number,
isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired,
isMultiSelect: PropTypes.bool.isRequired,
isMobile: PropTypes.bool.isRequired
};
HintedSelectInputOption.defaultProps = {
isDisabled: false,
isHidden: false,
isMultiSelect: false
};
export default HintedSelectInputOption;

View File

@@ -0,0 +1,68 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import styles from './HintedSelectInputSelectedValue.css';
function HintedSelectInputSelectedValue(props) {
const {
value,
values,
hint,
isMultiSelect,
includeHint,
...otherProps
} = props;
const valuesMap = isMultiSelect && _.keyBy(values, 'key');
return (
<EnhancedSelectInputSelectedValue
className={styles.selectedValue}
{...otherProps}
>
<div className={styles.valueText}>
{
isMultiSelect ?
value.map((key, index) => {
const v = valuesMap[key];
return (
<Label key={key}>
{v ? v.value : key}
</Label>
);
}) :
null
}
{
isMultiSelect ? null : value
}
</div>
{
hint != null && includeHint ?
<div className={styles.hintText}>
{hint}
</div> :
null
}
</EnhancedSelectInputSelectedValue>
);
}
HintedSelectInputSelectedValue.propTypes = {
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number]))]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
hint: PropTypes.string,
isMultiSelect: PropTypes.bool.isRequired,
includeHint: PropTypes.bool.isRequired
};
HintedSelectInputSelectedValue.defaultProps = {
isMultiSelect: false,
includeHint: true
};
export default HintedSelectInputSelectedValue;

View File

@@ -2,7 +2,6 @@ import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import { EnhancedSelectInputChanged } from 'typings/inputs';
import EnhancedSelectInput from './EnhancedSelectInput';
const selectIndexerFlagsValues = (selectedFlags: number) =>
@@ -33,36 +32,29 @@ const selectIndexerFlagsValues = (selectedFlags: number) =>
interface IndexerFlagsSelectInputProps {
name: string;
indexerFlags: number;
onChange(payload: EnhancedSelectInputChanged<number>): void;
onChange(payload: object): void;
}
function IndexerFlagsSelectInput({
name,
indexerFlags,
onChange,
...otherProps
}: IndexerFlagsSelectInputProps) {
function IndexerFlagsSelectInput(props: IndexerFlagsSelectInputProps) {
const { indexerFlags, onChange } = props;
const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags));
const handleChange = useCallback(
(change: EnhancedSelectInputChanged<number[]>) => {
const indexerFlags = change.value.reduce(
(acc, flagId) => acc + flagId,
0
);
const onChangeWrapper = useCallback(
({ name, value }: { name: string; value: number[] }) => {
const indexerFlags = value.reduce((acc, flagId) => acc + flagId, 0);
onChange({ name, value: indexerFlags });
},
[name, onChange]
[onChange]
);
return (
<EnhancedSelectInput
{...otherProps}
name={name}
{...props}
value={value}
values={values}
onChange={handleChange}
onChange={onChangeWrapper}
/>
);
}

View File

@@ -0,0 +1,97 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchIndexers } 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.indexers,
(state, { includeAny }) => includeAny,
(indexers, includeAny) => {
const {
isFetching,
isPopulated,
error,
items
} = indexers;
const values = _.map(items.sort(sortByProp('name')), (indexer) => {
return {
key: indexer.id,
value: indexer.name
};
});
if (includeAny) {
values.unshift({
key: 0,
value: `(${translate('Any')})`
});
}
return {
isFetching,
isPopulated,
error,
values
};
}
);
}
const mapDispatchToProps = {
dispatchFetchIndexers: fetchIndexers
};
class IndexerSelectInputConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.isPopulated) {
this.props.dispatchFetchIndexers();
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.props.onChange({ name, value: parseInt(value) });
};
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
IndexerSelectInputConnector.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,
dispatchFetchIndexers: PropTypes.func.isRequired
};
IndexerSelectInputConnector.defaultProps = {
includeAny: false
};
export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSelectInputConnector);

View File

@@ -0,0 +1,21 @@
.inputContainer {
composes: input from '~Components/Form/Input.css';
position: relative;
min-height: 35px;
height: auto;
&.isFocused {
outline: 0;
border-color: var(--inputFocusBorderColor);
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor);
}
}
.hasError {
composes: hasError from '~Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from '~Components/Form/Input.css';
}

View File

@@ -0,0 +1,10 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'hasError': string;
'hasWarning': string;
'inputContainer': string;
'isFocused': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,156 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
class KeyValueListInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isFocused: false
};
}
//
// Listeners
onItemChange = (index, itemValue) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
if (index == null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({
name,
value: newValue
});
};
onRemoveItem = (index) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
};
onFocus = () => {
this.setState({
isFocused: true
});
};
onBlur = () => {
this.setState({
isFocused: false
});
const {
name,
value,
onChange
} = this.props;
const newValue = value.reduce((acc, v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({
name,
value: newValue
});
}
};
//
// Render
render() {
const {
className,
value,
keyPlaceholder,
valuePlaceholder,
hasError,
hasWarning
} = this.props;
const { isFocused } = this.state;
return (
<div className={classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
>
{
[...value, { key: '', value: '' }].map((v, index) => {
return (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={this.onItemChange}
onRemove={this.onRemoveItem}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
);
})
}
</div>
);
}
}
KeyValueListInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
keyPlaceholder: PropTypes.string,
valuePlaceholder: PropTypes.string,
onChange: PropTypes.func.isRequired
};
KeyValueListInput.defaultProps = {
className: styles.inputContainer,
value: []
};
export default KeyValueListInput;

View File

@@ -0,0 +1,28 @@
.itemContainer {
display: flex;
margin-bottom: 3px;
border-bottom: 1px solid var(--inputBorderColor);
&:last-child {
margin-bottom: 0;
}
}
.keyInputWrapper {
flex: 6 0 0;
}
.valueInputWrapper {
flex: 1 0 0;
min-width: 40px;
}
.buttonWrapper {
flex: 0 0 22px;
}
.keyInput,
.valueInput {
width: 100%;
border: none;
}

View File

@@ -0,0 +1,12 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'buttonWrapper': string;
'itemContainer': string;
'keyInput': string;
'keyInputWrapper': string;
'valueInput': string;
'valueInputWrapper': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,124 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
class KeyValueListInputItem extends Component {
//
// Listeners
onKeyChange = ({ value: keyValue }) => {
const {
index,
value,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
};
onValueChange = ({ value }) => {
// TODO: Validate here or validate at a lower level component
const {
index,
keyValue,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
};
onRemovePress = () => {
const {
index,
onRemove
} = this.props;
onRemove(index);
};
onFocus = () => {
this.props.onFocus();
};
onBlur = () => {
this.props.onBlur();
};
//
// Render
render() {
const {
keyValue,
value,
keyPlaceholder,
valuePlaceholder,
isNew
} = this.props;
return (
<div className={styles.itemContainer}>
<div className={styles.keyInputWrapper}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={this.onKeyChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
</div>
<div className={styles.valueInputWrapper}>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={this.onValueChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
</div>
<div className={styles.buttonWrapper}>
{
isNew ?
null :
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={this.onRemovePress}
/>
}
</div>
</div>
);
}
}
KeyValueListInputItem.propTypes = {
index: PropTypes.number,
keyValue: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
keyPlaceholder: PropTypes.string.isRequired,
valuePlaceholder: PropTypes.string.isRequired,
isNew: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onFocus: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired
};
KeyValueListInputItem.defaultProps = {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value'
};
export default KeyValueListInputItem;

View File

@@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
(state, { values }) => values,
( languages ) => {
const minId = languages.reduce((min, v) => (v.key < 1 ? v.key : min), languages[0].key);
const values = languages.map(({ key, value }) => {
return {
key,
value,
dividerAfter: minId < 1 ? key === minId : false
};
});
return {
values
};
}
);
}
class LanguageSelectInputConnector extends Component {
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.props.onChange}
/>
);
}
}
LanguageSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
onChange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps)(LanguageSelectInputConnector);

View File

@@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import React from 'react';
import monitorOptions from 'Utilities/Series/monitorOptions';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function MonitorEpisodesSelectInput(props) {
const {
includeNoChange,
includeMixed,
...otherProps
} = props;
const values = [...monitorOptions];
if (includeNoChange) {
values.unshift({
key: 'noChange',
get value() {
return translate('NoChange');
},
isDisabled: true
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
get value() {
return `(${translate('Mixed')})`;
},
isDisabled: true
});
}
return (
<EnhancedSelectInput
values={values}
{...otherProps}
/>
);
}
MonitorEpisodesSelectInput.propTypes = {
includeNoChange: PropTypes.bool.isRequired,
includeMixed: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired
};
MonitorEpisodesSelectInput.defaultProps = {
includeNoChange: false,
includeMixed: false
};
export default MonitorEpisodesSelectInput;

View File

@@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React from 'react';
import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions';
import EnhancedSelectInput from './EnhancedSelectInput';
function MonitorNewItemsSelectInput(props) {
const {
includeNoChange,
includeMixed,
...otherProps
} = props;
const values = [...monitorNewItemsOptions];
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: 'No Change',
isDisabled: true
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
isDisabled: true
});
}
return (
<EnhancedSelectInput
values={values}
{...otherProps}
/>
);
}
MonitorNewItemsSelectInput.propTypes = {
includeNoChange: PropTypes.bool.isRequired,
includeMixed: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired
};
MonitorNewItemsSelectInput.defaultProps = {
includeNoChange: false,
includeMixed: false
};
export default MonitorNewItemsSelectInput;

View File

@@ -0,0 +1,126 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextInput from './TextInput';
function parseValue(props, value) {
const {
isFloat,
min,
max
} = props;
if (value == null || value === '') {
return null;
}
let newValue = isFloat ? parseFloat(value) : parseInt(value);
if (min != null && newValue != null && newValue < min) {
newValue = min;
} else if (max != null && newValue != null && newValue > max) {
newValue = max;
}
return newValue;
}
class NumberInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
value: props.value == null ? '' : props.value.toString(),
isFocused: false
};
}
componentDidUpdate(prevProps, prevState) {
const { value } = this.props;
if (!isNaN(value) && value !== prevProps.value && !this.state.isFocused) {
this.setState({
value: value == null ? '' : value.toString()
});
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.setState({ value });
this.props.onChange({
name,
value: parseValue(this.props, value)
});
};
onFocus = () => {
this.setState({ isFocused: true });
};
onBlur = () => {
const {
name,
onChange
} = this.props;
const { value } = this.state;
const parsedValue = parseValue(this.props, value);
const stringValue = parsedValue == null ? '' : parsedValue.toString();
if (stringValue === value) {
this.setState({ isFocused: false });
} else {
this.setState({
value: stringValue,
isFocused: false
});
}
onChange({
name,
value: parsedValue
});
};
//
// Render
render() {
const value = this.state.value;
return (
<TextInput
{...this.props}
type="number"
value={value == null ? '' : value}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
/>
);
}
}
NumberInput.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.number,
min: PropTypes.number,
max: PropTypes.number,
isFloat: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired
};
NumberInput.defaultProps = {
value: null,
isFloat: false
};
export default NumberInput;

View File

@@ -1,108 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { InputChanged } from 'typings/inputs';
import TextInput, { TextInputProps } from './TextInput';
function parseValue(
value: string | null | undefined,
isFloat: boolean,
min: number | undefined,
max: number | undefined
) {
if (value == null || value === '') {
return null;
}
let newValue = isFloat ? parseFloat(value) : parseInt(value);
if (min != null && newValue != null && newValue < min) {
newValue = min;
} else if (max != null && newValue != null && newValue > max) {
newValue = max;
}
return newValue;
}
interface NumberInputProps
extends Omit<TextInputProps<number | null>, 'value'> {
value?: number | null;
min?: number;
max?: number;
isFloat?: boolean;
}
function NumberInput({
name,
value: inputValue = null,
isFloat = false,
min,
max,
onChange,
...otherProps
}: NumberInputProps) {
const [value, setValue] = useState(
inputValue == null ? '' : inputValue.toString()
);
const isFocused = useRef(false);
const previousValue = usePrevious(inputValue);
const handleChange = useCallback(
({ name, value: newValue }: InputChanged<string>) => {
setValue(newValue);
onChange({
name,
value: parseValue(newValue, isFloat, min, max),
});
},
[isFloat, min, max, onChange, setValue]
);
const handleFocus = useCallback(() => {
isFocused.current = true;
}, []);
const handleBlur = useCallback(() => {
const parsedValue = parseValue(value, isFloat, min, max);
const stringValue = parsedValue == null ? '' : parsedValue.toString();
if (stringValue !== value) {
setValue(stringValue);
}
onChange({
name,
value: parsedValue,
});
isFocused.current = false;
}, [name, value, isFloat, min, max, onChange]);
useEffect(() => {
if (
// @ts-expect-error inputValue may be null
!isNaN(inputValue) &&
inputValue !== previousValue &&
!isFocused.current
) {
setValue(inputValue == null ? '' : inputValue.toString());
}
}, [inputValue, previousValue, setValue]);
return (
<TextInput
{...otherProps}
name={name}
type="number"
value={value == null ? '' : value}
min={min}
max={max}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
/>
);
}
export default NumberInput;

View File

@@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import React from 'react';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import { kinds } from 'Helpers/Props';
function OAuthInput(props) {
const {
label,
authorizing,
error,
onPress
} = props;
return (
<div>
<SpinnerErrorButton
kind={kinds.PRIMARY}
isSpinning={authorizing}
error={error}
onPress={onPress}
>
{label}
</SpinnerErrorButton>
</div>
);
}
OAuthInput.propTypes = {
label: PropTypes.string.isRequired,
authorizing: PropTypes.bool.isRequired,
error: PropTypes.object,
onPress: PropTypes.func.isRequired
};
OAuthInput.defaultProps = {
label: 'Start OAuth'
};
export default OAuthInput;

View File

@@ -1,72 +0,0 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import { kinds } from 'Helpers/Props';
import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions';
import { InputOnChange } from 'typings/inputs';
interface OAuthInputProps {
label?: string;
name: string;
provider: string;
providerData: object;
section: string;
onChange: InputOnChange<unknown>;
}
function OAuthInput({
label = 'Start OAuth',
name,
provider,
providerData,
section,
onChange,
}: OAuthInputProps) {
const dispatch = useDispatch();
const { authorizing, error, result } = useSelector(
(state: AppState) => state.oAuth
);
const handlePress = useCallback(() => {
dispatch(
startOAuth({
name,
provider,
providerData,
section,
})
);
}, [name, provider, providerData, section, dispatch]);
useEffect(() => {
if (!result) {
return;
}
Object.keys(result).forEach((key) => {
onChange({ name: key, value: result[key] });
});
}, [result, onChange]);
useEffect(() => {
return () => {
dispatch(resetOAuth());
};
}, [dispatch]);
return (
<div>
<SpinnerErrorButton
kind={kinds.PRIMARY}
isSpinning={authorizing}
error={error}
onPress={handlePress}
>
{label}
</SpinnerErrorButton>
</div>
);
}
export default OAuthInput;

View File

@@ -0,0 +1,89 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions';
import OAuthInput from './OAuthInput';
function createMapStateToProps() {
return createSelector(
(state) => state.oAuth,
(oAuth) => {
return oAuth;
}
);
}
const mapDispatchToProps = {
startOAuth,
resetOAuth
};
class OAuthInputConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
const {
result,
onChange
} = this.props;
if (!result || result === prevProps.result) {
return;
}
Object.keys(result).forEach((key) => {
onChange({ name: key, value: result[key] });
});
}
componentWillUnmount = () => {
this.props.resetOAuth();
};
//
// Listeners
onPress = () => {
const {
name,
provider,
providerData,
section
} = this.props;
this.props.startOAuth({
name,
provider,
providerData,
section
});
};
//
// Render
render() {
return (
<OAuthInput
{...this.props}
onPress={this.onPress}
/>
);
}
}
OAuthInputConnector.propTypes = {
name: PropTypes.string.isRequired,
result: PropTypes.object,
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
section: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
startOAuth: PropTypes.func.isRequired,
resetOAuth: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector);

View File

@@ -0,0 +1,24 @@
import React from 'react';
import TextInput from './TextInput';
// Prevent a user from copying (or cutting) the password from the input
function onCopy(e) {
e.preventDefault();
e.nativeEvent.stopImmediatePropagation();
}
function PasswordInput(props) {
return (
<TextInput
{...props}
type="password"
onCopy={onCopy}
/>
);
}
PasswordInput.propTypes = {
...TextInput.props
};
export default PasswordInput;

View File

@@ -1,14 +0,0 @@
import React, { SyntheticEvent } from 'react';
import TextInput, { TextInputProps } from './TextInput';
// Prevent a user from copying (or cutting) the password from the input
function onCopy(e: SyntheticEvent) {
e.preventDefault();
e.nativeEvent.stopImmediatePropagation();
}
function PasswordInput(props: TextInputProps<string>) {
return <TextInput {...props} type="password" onCopy={onCopy} />;
}
export default PasswordInput;

View File

@@ -0,0 +1,195 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import AutoSuggestInput from './AutoSuggestInput';
import FormInputButton from './FormInputButton';
import styles from './PathInput.css';
class PathInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._node = document.getElementById('portal-root');
this.state = {
value: props.value,
isFileBrowserModalOpen: false
};
}
componentDidUpdate(prevProps) {
const { value } = this.props;
if (prevProps.value !== value) {
this.setState({ value });
}
}
//
// Control
getSuggestionValue({ path }) {
return path;
}
renderSuggestion({ path }, { query }) {
const lastSeparatorIndex = query.lastIndexOf('\\') || query.lastIndexOf('/');
if (lastSeparatorIndex === -1) {
return (
<span>{path}</span>
);
}
return (
<span>
<span className={styles.pathMatch}>
{path.substr(0, lastSeparatorIndex)}
</span>
{path.substr(lastSeparatorIndex)}
</span>
);
}
//
// Listeners
onInputChange = ({ value }) => {
this.setState({ value });
};
onInputKeyDown = (event) => {
if (event.key === 'Tab') {
event.preventDefault();
const path = this.props.paths[0];
if (path) {
this.props.onChange({
name: this.props.name,
value: path.path
});
if (path.type !== 'file') {
this.props.onFetchPaths(path.path);
}
}
}
};
onInputBlur = () => {
this.props.onChange({
name: this.props.name,
value: this.state.value
});
this.props.onClearPaths();
};
onSuggestionsFetchRequested = ({ value }) => {
this.props.onFetchPaths(value);
};
onSuggestionsClearRequested = () => {
// Required because props aren't always rendered, but no-op
// because we don't want to reset the paths after a path is selected.
};
onSuggestionSelected = (event, { suggestionValue }) => {
this.props.onFetchPaths(suggestionValue);
};
onFileBrowserOpenPress = () => {
this.setState({ isFileBrowserModalOpen: true });
};
onFileBrowserModalClose = () => {
this.setState({ isFileBrowserModalOpen: false });
};
//
// Render
render() {
const {
className,
name,
paths,
includeFiles,
hasFileBrowser,
onChange,
...otherProps
} = this.props;
const {
value,
isFileBrowserModalOpen
} = this.state;
return (
<div className={className}>
<AutoSuggestInput
{...otherProps}
className={hasFileBrowser ? styles.hasFileBrowser : undefined}
name={name}
value={value}
suggestions={paths}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onInputKeyDown={this.onInputKeyDown}
onInputBlur={this.onInputBlur}
onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onChange={this.onInputChange}
/>
{
hasFileBrowser &&
<div>
<FormInputButton
className={styles.fileBrowserButton}
onPress={this.onFileBrowserOpenPress}
>
<Icon name={icons.FOLDER_OPEN} />
</FormInputButton>
<FileBrowserModal
isOpen={isFileBrowserModalOpen}
name={name}
value={value}
includeFiles={includeFiles}
onChange={onChange}
onModalClose={this.onFileBrowserModalClose}
/>
</div>
}
</div>
);
}
}
PathInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string,
paths: PropTypes.array.isRequired,
includeFiles: PropTypes.bool.isRequired,
hasFileBrowser: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onFetchPaths: PropTypes.func.isRequired,
onClearPaths: PropTypes.func.isRequired
};
PathInput.defaultProps = {
className: styles.inputWrapper,
value: '',
hasFileBrowser: true
};
export default PathInput;

View File

@@ -1,252 +0,0 @@
import React, {
KeyboardEvent,
SyntheticEvent,
useCallback,
useEffect,
useState,
} from 'react';
import {
ChangeEvent,
SuggestionsFetchRequestedParams,
} from 'react-autosuggest';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import { Path } from 'App/State/PathsAppState';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import Icon from 'Components/Icon';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
import { InputChanged } from 'typings/inputs';
import AutoSuggestInput from './AutoSuggestInput';
import FormInputButton from './FormInputButton';
import styles from './PathInput.css';
interface PathInputProps {
className?: string;
name: string;
value?: string;
placeholder?: string;
includeFiles: boolean;
hasFileBrowser?: boolean;
onChange: (change: InputChanged<string>) => void;
}
interface PathInputInternalProps extends PathInputProps {
paths: Path[];
onFetchPaths: (path: string) => void;
onClearPaths: () => void;
}
function handleSuggestionsClearRequested() {
// Required because props aren't always rendered, but no-op
// because we don't want to reset the paths after a path is selected.
}
function createPathsSelector() {
return createSelector(
(state: AppState) => state.paths,
(paths) => {
const { currentPath, directories, files } = paths;
const filteredPaths = [...directories, ...files].filter(({ path }) => {
return path.toLowerCase().startsWith(currentPath.toLowerCase());
});
return filteredPaths;
}
);
}
function PathInput(props: PathInputProps) {
const { includeFiles } = props;
const dispatch = useDispatch();
const paths = useSelector(createPathsSelector());
const handleFetchPaths = useCallback(
(path: string) => {
dispatch(fetchPaths({ path, includeFiles }));
},
[includeFiles, dispatch]
);
const handleClearPaths = useCallback(() => {
dispatch(clearPaths);
}, [dispatch]);
return (
<PathInputInternal
{...props}
paths={paths}
onFetchPaths={handleFetchPaths}
onClearPaths={handleClearPaths}
/>
);
}
export default PathInput;
export function PathInputInternal(props: PathInputInternalProps) {
const {
className = styles.inputWrapper,
name,
value: inputValue = '',
paths,
includeFiles,
hasFileBrowser = true,
onChange,
onFetchPaths,
onClearPaths,
...otherProps
} = props;
const [value, setValue] = useState(inputValue);
const [isFileBrowserModalOpen, setIsFileBrowserModalOpen] = useState(false);
const previousInputValue = usePrevious(inputValue);
const dispatch = useDispatch();
const handleFetchPaths = useCallback(
(path: string) => {
dispatch(fetchPaths({ path, includeFiles }));
},
[includeFiles, dispatch]
);
const handleInputChange = useCallback(
(_event: SyntheticEvent, { newValue }: ChangeEvent) => {
setValue(newValue);
},
[setValue]
);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (event.key === 'Tab') {
event.preventDefault();
const path = paths[0];
if (path) {
onChange({
name,
value: path.path,
});
if (path.type !== 'file') {
handleFetchPaths(path.path);
}
}
}
},
[name, paths, handleFetchPaths, onChange]
);
const handleInputBlur = useCallback(() => {
onChange({
name,
value,
});
onClearPaths();
}, [name, value, onClearPaths, onChange]);
const handleSuggestionSelected = useCallback(
(_event: SyntheticEvent, { suggestion }: { suggestion: Path }) => {
handleFetchPaths(suggestion.path);
},
[handleFetchPaths]
);
const handleSuggestionsFetchRequested = useCallback(
({ value: newValue }: SuggestionsFetchRequestedParams) => {
handleFetchPaths(newValue);
},
[handleFetchPaths]
);
const handleFileBrowserOpenPress = useCallback(() => {
setIsFileBrowserModalOpen(true);
}, [setIsFileBrowserModalOpen]);
const handleFileBrowserModalClose = useCallback(() => {
setIsFileBrowserModalOpen(false);
}, [setIsFileBrowserModalOpen]);
const handleChange = useCallback(
(change: InputChanged<Path>) => {
onChange({ name, value: change.value.path });
},
[name, onChange]
);
const getSuggestionValue = useCallback(({ path }: Path) => path, []);
const renderSuggestion = useCallback(
({ path }: Path, { query }: { query: string }) => {
const lastSeparatorIndex =
query.lastIndexOf('\\') || query.lastIndexOf('/');
if (lastSeparatorIndex === -1) {
return <span>{path}</span>;
}
return (
<span>
<span className={styles.pathMatch}>
{path.substring(0, lastSeparatorIndex)}
</span>
{path.substring(lastSeparatorIndex)}
</span>
);
},
[]
);
useEffect(() => {
if (inputValue !== previousInputValue) {
setValue(inputValue);
}
}, [inputValue, previousInputValue, setValue]);
return (
<div className={className}>
<AutoSuggestInput
{...otherProps}
className={hasFileBrowser ? styles.hasFileBrowser : undefined}
name={name}
value={value}
suggestions={paths}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
onInputKeyDown={handleInputKeyDown}
onInputChange={handleInputChange}
onInputBlur={handleInputBlur}
onSuggestionSelected={handleSuggestionSelected}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
onChange={handleChange}
/>
{hasFileBrowser ? (
<div>
<FormInputButton
className={styles.fileBrowserButton}
onPress={handleFileBrowserOpenPress}
>
<Icon name={icons.FOLDER_OPEN} />
</FormInputButton>
<FileBrowserModal
isOpen={isFileBrowserModalOpen}
name={name}
value={value}
includeFiles={includeFiles}
onChange={onChange}
onModalClose={handleFileBrowserModalClose}
/>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,81 @@
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 PathInput from './PathInput';
function createMapStateToProps() {
return createSelector(
(state) => state.paths,
(paths) => {
const {
currentPath,
directories,
files
} = paths;
const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
return path.toLowerCase().startsWith(currentPath.toLowerCase());
});
return {
paths: filteredPaths
};
}
);
}
const mapDispatchToProps = {
dispatchFetchPaths: fetchPaths,
dispatchClearPaths: clearPaths
};
class PathInputConnector extends Component {
//
// Listeners
onFetchPaths = (path) => {
const {
includeFiles,
dispatchFetchPaths
} = this.props;
dispatchFetchPaths({
path,
includeFiles
});
};
onClearPaths = () => {
this.props.dispatchClearPaths();
};
//
// Render
render() {
return (
<PathInput
onFetchPaths={this.onFetchPaths}
onClearPaths={this.onClearPaths}
{...this.props}
/>
);
}
}
PathInputConnector.propTypes = {
...PathInput.props,
includeFiles: PropTypes.bool.isRequired,
dispatchFetchPaths: PropTypes.func.isRequired,
dispatchClearPaths: PropTypes.func.isRequired
};
PathInputConnector.defaultProps = {
includeFiles: false
};
export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);

View File

@@ -0,0 +1,105 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')),
(state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(state, { includeMixed }) => includeMixed,
(qualityProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed) => {
const values = _.map(qualityProfiles.items, (qualityProfile) => {
return {
key: qualityProfile.id,
value: qualityProfile.name
};
});
if (includeNoChange) {
values.unshift({
key: 'noChange',
get value() {
return translate('NoChange');
},
isDisabled: includeNoChangeDisabled
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
get value() {
return `(${translate('Mixed')})`;
},
isDisabled: true
});
}
return {
values
};
}
);
}
class QualityProfileSelectInputConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
name,
value,
values
} = this.props;
if (!value || !values.some((option) => option.key === value || parseInt(option.key) === value)) {
const firstValue = values.find((option) => !isNaN(parseInt(option.key)));
if (firstValue) {
this.onChange({ name, value: firstValue.key });
}
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.props.onChange({ name, value: value === 'noChange' ? value : parseInt(value) });
};
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
QualityProfileSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
values: PropTypes.arrayOf(PropTypes.object).isRequired,
includeNoChange: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired
};
QualityProfileSelectInputConnector.defaultProps = {
includeNoChange: false
};
export default connect(createMapStateToProps)(QualityProfileSelectInputConnector);

View File

@@ -0,0 +1,109 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import EnhancedSelectInput from './EnhancedSelectInput';
import RootFolderSelectInputOption from './RootFolderSelectInputOption';
import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
class RootFolderSelectInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddNewRootFolderModalOpen: false,
newRootFolderPath: ''
};
}
componentDidUpdate(prevProps) {
const {
name,
isSaving,
saveError,
onChange
} = this.props;
const newRootFolderPath = this.state.newRootFolderPath;
if (
prevProps.isSaving &&
!isSaving &&
!saveError &&
newRootFolderPath
) {
onChange({ name, value: newRootFolderPath });
this.setState({ newRootFolderPath: '' });
}
}
//
// Listeners
onChange = ({ name, value }) => {
if (value === 'addNew') {
this.setState({ isAddNewRootFolderModalOpen: true });
} else {
this.props.onChange({ name, value });
}
};
onNewRootFolderSelect = ({ value }) => {
this.setState({ newRootFolderPath: value }, () => {
this.props.onNewRootFolderSelect(value);
});
};
onAddRootFolderModalClose = () => {
this.setState({ isAddNewRootFolderModalOpen: false });
};
//
// Render
render() {
const {
includeNoChange,
onNewRootFolderSelect,
...otherProps
} = this.props;
return (
<div>
<EnhancedSelectInput
{...otherProps}
selectedValueComponent={RootFolderSelectInputSelectedValue}
optionComponent={RootFolderSelectInputOption}
onChange={this.onChange}
/>
<FileBrowserModal
isOpen={this.state.isAddNewRootFolderModalOpen}
name="rootFolderPath"
value=""
onChange={this.onNewRootFolderSelect}
onModalClose={this.onAddRootFolderModalClose}
/>
</div>
);
}
}
RootFolderSelectInput.propTypes = {
name: PropTypes.string.isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
includeNoChange: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onNewRootFolderSelect: PropTypes.func.isRequired
};
RootFolderSelectInput.defaultProps = {
includeNoChange: false
};
export default RootFolderSelectInput;

View File

@@ -0,0 +1,175 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addRootFolder } from 'Store/Actions/rootFolderActions';
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
import translate from 'Utilities/String/translate';
import RootFolderSelectInput from './RootFolderSelectInput';
const ADD_NEW_KEY = 'addNew';
function createMapStateToProps() {
return createSelector(
createRootFoldersSelector(),
(state, { value }) => value,
(state, { includeMissingValue }) => includeMissingValue,
(state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(rootFolders, value, includeMissingValue, includeNoChange, includeNoChangeDisabled = true) => {
const values = rootFolders.items.map((rootFolder) => {
return {
key: rootFolder.path,
value: rootFolder.path,
freeSpace: rootFolder.freeSpace,
isMissing: false
};
});
if (includeNoChange) {
values.unshift({
key: 'noChange',
get value() {
return translate('NoChange');
},
isDisabled: includeNoChangeDisabled,
isMissing: false
});
}
if (!values.length) {
values.push({
key: '',
value: '',
isDisabled: true,
isHidden: true
});
}
if (includeMissingValue && !values.find((v) => v.key === value)) {
values.push({
key: value,
value,
isMissing: true,
isDisabled: true
});
}
values.push({
key: ADD_NEW_KEY,
value: translate('AddANewPath')
});
return {
values,
isSaving: rootFolders.isSaving,
saveError: rootFolders.saveError
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchAddRootFolder(path) {
dispatch(addRootFolder({ path }));
}
};
}
class RootFolderSelectInputConnector extends Component {
//
// Lifecycle
componentWillMount() {
const {
value,
values,
onChange
} = this.props;
if (value == null && values[0].key === '') {
onChange({ name, value: '' });
}
}
componentDidMount() {
const {
name,
value,
values,
onChange
} = this.props;
if (!value || !values.some((v) => v.key === value) || value === ADD_NEW_KEY) {
const defaultValue = values[0];
if (defaultValue.key === ADD_NEW_KEY) {
onChange({ name, value: '' });
} else {
onChange({ name, value: defaultValue.key });
}
}
}
componentDidUpdate(prevProps) {
const {
name,
value,
values,
onChange
} = this.props;
if (prevProps.values === values) {
return;
}
if (!value && values.length && values.some((v) => !!v.key && v.key !== ADD_NEW_KEY)) {
const defaultValue = values[0];
if (defaultValue.key !== ADD_NEW_KEY) {
onChange({ name, value: defaultValue.key });
}
}
}
//
// Listeners
onNewRootFolderSelect = (path) => {
this.props.dispatchAddRootFolder(path);
};
//
// Render
render() {
const {
dispatchAddRootFolder,
...otherProps
} = this.props;
return (
<RootFolderSelectInput
{...otherProps}
onNewRootFolderSelect={this.onNewRootFolderSelect}
/>
);
}
}
RootFolderSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
includeNoChange: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
dispatchAddRootFolder: PropTypes.func.isRequired
};
RootFolderSelectInputConnector.defaultProps = {
includeNoChange: false
};
export default connect(createMapStateToProps, createMapDispatchToProps)(RootFolderSelectInputConnector);

View File

@@ -0,0 +1,77 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './RootFolderSelectInputOption.css';
function RootFolderSelectInputOption(props) {
const {
id,
value,
freeSpace,
isMissing,
seriesFolder,
isMobile,
isWindows,
...otherProps
} = props;
const slashCharacter = isWindows ? '\\' : '/';
return (
<EnhancedSelectInputOption
id={id}
isMobile={isMobile}
{...otherProps}
>
<div className={classNames(
styles.optionText,
isMobile && styles.isMobile
)}
>
<div className={styles.value}>
{value}
{
seriesFolder && id !== 'addNew' ?
<div className={styles.seriesFolder}>
{slashCharacter}
{seriesFolder}
</div> :
null
}
</div>
{
freeSpace == null ?
null :
<div className={styles.freeSpace}>
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
</div>
}
{
isMissing ?
<div className={styles.isMissing}>
{translate('Missing')}
</div> :
null
}
</div>
</EnhancedSelectInputOption>
);
}
RootFolderSelectInputOption.propTypes = {
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
freeSpace: PropTypes.number,
isMissing: PropTypes.bool,
seriesFolder: PropTypes.string,
isMobile: PropTypes.bool.isRequired,
isWindows: PropTypes.bool
};
export default RootFolderSelectInputOption;

View File

@@ -0,0 +1,62 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import styles from './RootFolderSelectInputSelectedValue.css';
function RootFolderSelectInputSelectedValue(props) {
const {
value,
freeSpace,
seriesFolder,
includeFreeSpace,
isWindows,
...otherProps
} = props;
const slashCharacter = isWindows ? '\\' : '/';
return (
<EnhancedSelectInputSelectedValue
className={styles.selectedValue}
{...otherProps}
>
<div className={styles.pathContainer}>
<div className={styles.path}>
{value}
</div>
{
seriesFolder ?
<div className={styles.seriesFolder}>
{slashCharacter}
{seriesFolder}
</div> :
null
}
</div>
{
freeSpace != null && includeFreeSpace &&
<div className={styles.freeSpace}>
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
</div>
}
</EnhancedSelectInputSelectedValue>
);
}
RootFolderSelectInputSelectedValue.propTypes = {
value: PropTypes.string,
freeSpace: PropTypes.number,
seriesFolder: PropTypes.string,
isWindows: PropTypes.bool,
includeFreeSpace: PropTypes.bool.isRequired
};
RootFolderSelectInputSelectedValue.defaultProps = {
includeFreeSpace: true
};
export default RootFolderSelectInputSelectedValue;

View File

@@ -1,88 +0,0 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import { Protocol } from 'typings/DownloadClient';
import { EnhancedSelectInputChanged } from 'typings/inputs';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput, {
EnhancedSelectInputProps,
EnhancedSelectInputValue,
} from './EnhancedSelectInput';
function createDownloadClientsSelector(
includeAny: boolean,
protocol: Protocol
) {
return createSelector(
(state: AppState) => state.settings.downloadClients,
(downloadClients) => {
const { isFetching, isPopulated, error, items } = downloadClients;
const filteredItems = items.filter((item) => item.protocol === protocol);
const values = filteredItems
.sort(sortByProp('name'))
.map((downloadClient) => {
return {
key: downloadClient.id,
value: downloadClient.name,
hint: `(${downloadClient.id})`,
};
});
if (includeAny) {
values.unshift({
key: 0,
value: `(${translate('Any')})`,
hint: '',
});
}
return {
isFetching,
isPopulated,
error,
values,
};
}
);
}
interface DownloadClientSelectInputProps
extends EnhancedSelectInputProps<EnhancedSelectInputValue<number>, number> {
name: string;
value: number;
includeAny?: boolean;
protocol?: Protocol;
onChange: (change: EnhancedSelectInputChanged<number>) => void;
}
function DownloadClientSelectInput({
includeAny = false,
protocol = 'torrent',
...otherProps
}: DownloadClientSelectInputProps) {
const dispatch = useDispatch();
const { isFetching, isPopulated, values } = useSelector(
createDownloadClientsSelector(includeAny, protocol)
);
useEffect(() => {
if (!isPopulated) {
dispatch(fetchDownloadClients());
}
}, [isPopulated, dispatch]);
return (
<EnhancedSelectInput
{...otherProps}
isFetching={isFetching}
values={values}
/>
);
}
export default DownloadClientSelectInput;

View File

@@ -1,622 +0,0 @@
import classNames from 'classnames';
import React, {
ElementType,
KeyboardEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import 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 ArrayElement from 'typings/Helpers/ArrayElement';
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import getUniqueElementId from 'Utilities/getUniqueElementId';
import TextInput from '../TextInput';
import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import styles from './EnhancedSelectInput.css';
function isArrowKey(keyCode: number) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
function getSelectedOption<T extends EnhancedSelectInputValue<V>, V>(
selectedIndex: number,
values: T[]
) {
return values[selectedIndex];
}
function findIndex<T extends EnhancedSelectInputValue<V>, V>(
startingIndex: number,
direction: 1 | -1,
values: T[]
) {
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;
}
}
return null;
}
function previousIndex<T extends EnhancedSelectInputValue<V>, V>(
selectedIndex: number,
values: T[]
) {
return findIndex(selectedIndex, -1, values);
}
function nextIndex<T extends EnhancedSelectInputValue<V>, V>(
selectedIndex: number,
values: T[]
) {
return findIndex(selectedIndex, 1, values);
}
function getSelectedIndex<T extends EnhancedSelectInputValue<V>, V>(
value: V,
values: T[]
) {
if (Array.isArray(value)) {
return values.findIndex((v) => {
return v.key === value[0];
});
}
return values.findIndex((v) => {
return v.key === value;
});
}
function isSelectedItem<T extends EnhancedSelectInputValue<V>, V>(
index: number,
value: V,
values: T[]
) {
if (Array.isArray(value)) {
return value.includes(values[index].key);
}
return values[index].key === value;
}
export interface EnhancedSelectInputValue<V> {
key: ArrayElement<V>;
value: string;
hint?: ReactNode;
isDisabled?: boolean;
isHidden?: boolean;
parentKey?: V;
additionalProperties?: object;
}
export interface EnhancedSelectInputProps<
T extends EnhancedSelectInputValue<V>,
V
> {
className?: string;
disabledClassName?: string;
name: string;
value: V;
values: T[];
isDisabled?: boolean;
isFetching?: boolean;
isEditable?: boolean;
hasError?: boolean;
hasWarning?: boolean;
valueOptions?: object;
selectedValueOptions?: object;
selectedValueComponent?: string | ElementType;
optionComponent?: ElementType;
onOpen?: () => void;
onChange: (change: EnhancedSelectInputChanged<V>) => void;
}
function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
props: EnhancedSelectInputProps<T, V>
) {
const {
className = styles.enhancedSelect,
disabledClassName = styles.isDisabled,
name,
value,
values,
isDisabled = false,
isEditable,
isFetching,
hasError,
hasWarning,
valueOptions,
selectedValueOptions,
selectedValueComponent:
SelectedValueComponent = HintedSelectInputSelectedValue,
optionComponent: OptionComponent = HintedSelectInputOption,
onChange,
onOpen,
} = props;
const updater = useRef<(() => void) | null>(null);
const buttonId = useMemo(() => getUniqueElementId(), []);
const optionsId = useMemo(() => getUniqueElementId(), []);
const [selectedIndex, setSelectedIndex] = useState(
getSelectedIndex(value, values)
);
const [width, setWidth] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const isMobile = useMemo(() => isMobileUtil(), []);
const isMultiSelect = Array.isArray(value);
const selectedOption = getSelectedOption(selectedIndex, values);
const selectedValue = useMemo(() => {
if (values.length) {
return value;
}
if (isMultiSelect) {
return [];
} else if (typeof value === 'number') {
return 0;
}
return '';
}, [value, values, isMultiSelect]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleComputeMaxHeight = useCallback((data: any) => {
const { top, bottom } = data.offsets.reference;
const windowHeight = window.innerHeight;
if (/^botton/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
return data;
}, []);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const button = document.getElementById(buttonId);
const options = document.getElementById(optionsId);
const eventTarget = event.target as HTMLElement;
if (!button || !eventTarget.isConnected || isMobile) {
return;
}
if (
!button.contains(eventTarget) &&
options &&
!options.contains(eventTarget) &&
isOpen
) {
setIsOpen(false);
window.removeEventListener('click', handleWindowClick);
}
},
[isMobile, isOpen, buttonId, optionsId, setIsOpen]
);
const addListener = useCallback(() => {
window.addEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const removeListener = useCallback(() => {
window.removeEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const handlePress = useCallback(() => {
if (isOpen) {
removeListener();
} else {
addListener();
}
if (!isOpen && onOpen) {
onOpen();
}
setIsOpen(!isOpen);
}, [isOpen, setIsOpen, addListener, removeListener, onOpen]);
const handleSelect = useCallback(
(newValue: ArrayElement<V>) => {
const additionalProperties = values.find(
(v) => v.key === newValue
)?.additionalProperties;
if (Array.isArray(value)) {
const index = value.indexOf(newValue);
if (index === -1) {
const arrayValue = values
.map((v) => v.key)
.filter((v) => v === newValue || value.includes(v));
onChange({
name,
value: arrayValue as V,
additionalProperties,
});
} else {
const arrayValue = [...value];
arrayValue.splice(index, 1);
onChange({
name,
value: arrayValue as V,
additionalProperties,
});
}
} else {
setIsOpen(false);
onChange({
name,
value: newValue as V,
additionalProperties,
});
}
},
[name, value, values, onChange, setIsOpen]
);
const handleBlur = useCallback(() => {
if (!isEditable) {
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
const origIndex = getSelectedIndex(value, values);
if (origIndex !== selectedIndex) {
setSelectedIndex(origIndex);
}
}
}, [value, values, isEditable, selectedIndex, setSelectedIndex]);
const handleFocus = useCallback(() => {
if (isOpen) {
removeListener();
setIsOpen(false);
}
}, [isOpen, setIsOpen, removeListener]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
const keyCode = event.keyCode;
let nextIsOpen: boolean | null = null;
let nextSelectedIndex: number | null = null;
if (!isOpen) {
if (isArrowKey(keyCode)) {
event.preventDefault();
nextIsOpen = true;
}
if (
selectedIndex == null ||
selectedIndex === -1 ||
getSelectedOption(selectedIndex, values).isDisabled
) {
if (keyCode === keyCodes.UP_ARROW) {
nextSelectedIndex = previousIndex(0, values);
} else if (keyCode === keyCodes.DOWN_ARROW) {
nextSelectedIndex = nextIndex(values.length - 1, values);
}
}
if (nextIsOpen !== null) {
setIsOpen(nextIsOpen);
}
if (nextSelectedIndex !== null) {
setSelectedIndex(nextSelectedIndex);
}
return;
}
if (keyCode === keyCodes.UP_ARROW) {
event.preventDefault();
nextSelectedIndex = previousIndex(selectedIndex, values);
}
if (keyCode === keyCodes.DOWN_ARROW) {
event.preventDefault();
nextSelectedIndex = nextIndex(selectedIndex, values);
}
if (keyCode === keyCodes.ENTER) {
event.preventDefault();
nextIsOpen = false;
handleSelect(values[selectedIndex].key);
}
if (keyCode === keyCodes.TAB) {
nextIsOpen = false;
handleSelect(values[selectedIndex].key);
}
if (keyCode === keyCodes.ESCAPE) {
event.preventDefault();
event.stopPropagation();
nextIsOpen = false;
nextSelectedIndex = getSelectedIndex(value, values);
}
if (nextIsOpen !== null) {
setIsOpen(nextIsOpen);
}
if (nextSelectedIndex !== null) {
setSelectedIndex(nextSelectedIndex);
}
},
[
value,
isOpen,
selectedIndex,
values,
setIsOpen,
setSelectedIndex,
handleSelect,
]
);
const handleMeasure = useCallback(
({ width: newWidth }: { width: number }) => {
setWidth(newWidth);
},
[setWidth]
);
const handleOptionsModalClose = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
const handleEditChange = useCallback(
(change: InputChanged<string>) => {
onChange(change as EnhancedSelectInputChanged<V>);
},
[onChange]
);
useEffect(() => {
if (updater.current) {
updater.current();
}
});
return (
<div>
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={buttonId}>
<Measure whitelist={['width']} onMeasure={handleMeasure}>
{isEditable && typeof value === 'string' ? (
<div className={styles.editableContainer}>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleEditChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
)}
onPress={handlePress}
>
{isFetching ? (
<LoadingIndicator
className={styles.loading}
size={20}
/>
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</Link>
</div>
) : (
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPress={handlePress}
>
<SelectedValueComponent
values={values}
{...selectedValueOptions}
selectedValue={selectedValue}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : selectedValue}
</SelectedValueComponent>
<div
className={
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
}
>
{isFetching ? (
<LoadingIndicator
className={styles.loading}
size={20}
/>
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</div>
</Link>
)}
</Measure>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
}}
>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={ref}
id={optionsId}
className={styles.optionsContainer}
style={{
...style,
minWidth: width,
}}
>
{isOpen && !isMobile ? (
<Scroller
className={styles.options}
style={{
maxHeight: style.maxHeight,
}}
>
{values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected =
v.parentKey !== undefined &&
Array.isArray(value) &&
value.includes(v.parentKey);
const { key, ...other } = v;
return (
<OptionComponent
key={v.key}
id={v.key}
depth={depth}
isSelected={isSelectedItem(index, value, values)}
isDisabled={parentSelected}
isMultiSelect={isMultiSelect}
{...valueOptions}
{...other}
isMobile={false}
onSelect={handleSelect}
>
{v.value}
</OptionComponent>
);
})}
</Scroller>
) : null}
</div>
);
}}
</Popper>
</Portal>
</Manager>
{isMobile ? (
<Modal
className={styles.optionsModal}
size={sizes.EXTRA_SMALL}
isOpen={isOpen}
onModalClose={handleOptionsModalClose}
>
<ModalBody
className={styles.optionsModalBody}
innerClassName={styles.optionsInnerModalBody}
scrollDirection={scrollDirections.NONE}
>
<Scroller className={styles.optionsModalScroller}>
<div className={styles.mobileCloseButtonContainer}>
<Link
className={styles.mobileCloseButton}
onPress={handleOptionsModalClose}
>
<Icon name={icons.CLOSE} size={18} />
</Link>
</div>
{values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected =
v.parentKey !== undefined &&
isMultiSelect &&
value.includes(v.parentKey);
const { key, ...other } = v;
return (
<OptionComponent
key={key}
id={key}
depth={depth}
isSelected={isSelectedItem(index, value, values)}
isMultiSelect={isMultiSelect}
isDisabled={parentSelected}
{...valueOptions}
{...other}
isMobile={true}
onSelect={handleSelect}
>
{v.value}
</OptionComponent>
);
})}
</Scroller>
</ModalBody>
</Modal>
) : null}
</div>
);
}
export default EnhancedSelectInput;

View File

@@ -1,84 +0,0 @@
import classNames from 'classnames';
import React, { SyntheticEvent, useCallback } 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';
function handleCheckPress() {
// CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation.
}
export interface EnhancedSelectInputOptionProps {
className?: string;
id: string | number;
depth?: number;
isSelected: boolean;
isDisabled?: boolean;
isHidden?: boolean;
isMultiSelect?: boolean;
isMobile: boolean;
children: React.ReactNode;
onSelect: (...args: unknown[]) => unknown;
}
function EnhancedSelectInputOption({
className = styles.option,
id,
depth = 0,
isSelected,
isDisabled = false,
isHidden = false,
isMultiSelect = false,
isMobile,
children,
onSelect,
}: EnhancedSelectInputOptionProps) {
const handlePress = useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
onSelect(id);
},
[id, onSelect]
);
return (
<Link
className={classNames(
className,
isSelected && !isMultiSelect && styles.isSelected,
isDisabled && !isMultiSelect && styles.isDisabled,
isHidden && styles.isHidden,
isMobile && styles.isMobile
)}
component="div"
isDisabled={isDisabled}
onPress={handlePress}
>
{depth !== 0 && <div style={{ width: `${depth * 20}px` }} />}
{isMultiSelect && (
<CheckInput
className={styles.optionCheckInput}
containerClassName={styles.optionCheck}
name={`select-${id}`}
value={isSelected}
isDisabled={isDisabled}
onChange={handleCheckPress}
/>
)}
{children}
{isMobile && (
<div className={styles.iconContainer}>
<Icon name={isSelected ? icons.CHECK_CIRCLE : icons.CIRCLE_OUTLINE} />
</div>
)}
</Link>
);
}
export default EnhancedSelectInputOption;

View File

@@ -1,23 +0,0 @@
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import styles from './EnhancedSelectInputSelectedValue.css';
interface EnhancedSelectInputSelectedValueProps {
className?: string;
children: ReactNode;
isDisabled?: boolean;
}
function EnhancedSelectInputSelectedValue({
className = styles.selectedValue,
children,
isDisabled = false,
}: EnhancedSelectInputSelectedValueProps) {
return (
<div className={classNames(className, isDisabled && styles.isDisabled)}>
{children}
</div>
);
}
export default EnhancedSelectInputSelectedValue;

View File

@@ -1,52 +0,0 @@
import classNames from 'classnames';
import React from 'react';
import EnhancedSelectInputOption, {
EnhancedSelectInputOptionProps,
} from './EnhancedSelectInputOption';
import styles from './HintedSelectInputOption.css';
interface HintedSelectInputOptionProps extends EnhancedSelectInputOptionProps {
value: string;
hint?: React.ReactNode;
}
function HintedSelectInputOption(props: HintedSelectInputOptionProps) {
const {
id,
value,
hint,
depth,
isSelected = false,
isDisabled,
isMobile,
...otherProps
} = props;
return (
<EnhancedSelectInputOption
id={id}
depth={depth}
isSelected={isSelected}
isDisabled={isDisabled}
isHidden={isDisabled}
isMobile={isMobile}
{...otherProps}
>
<div
className={classNames(styles.optionText, isMobile && styles.isMobile)}
>
<div>{value}</div>
{hint != null && <div className={styles.hintText}>{hint}</div>}
</div>
</EnhancedSelectInputOption>
);
}
HintedSelectInputOption.defaultProps = {
isDisabled: false,
isHidden: false,
isMultiSelect: false,
};
export default HintedSelectInputOption;

View File

@@ -1,55 +0,0 @@
import React, { ReactNode, useMemo } from 'react';
import Label from 'Components/Label';
import ArrayElement from 'typings/Helpers/ArrayElement';
import { EnhancedSelectInputValue } from './EnhancedSelectInput';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import styles from './HintedSelectInputSelectedValue.css';
interface HintedSelectInputSelectedValueProps<T, V> {
selectedValue: V;
values: T[];
hint?: ReactNode;
isMultiSelect?: boolean;
includeHint?: boolean;
}
function HintedSelectInputSelectedValue<
T extends EnhancedSelectInputValue<V>,
V extends number | string
>(props: HintedSelectInputSelectedValueProps<T, V>) {
const {
selectedValue,
values,
hint,
isMultiSelect = false,
includeHint = true,
...otherProps
} = props;
const valuesMap = useMemo(() => {
return new Map(values.map((v) => [v.key, v.value]));
}, [values]);
return (
<EnhancedSelectInputSelectedValue
className={styles.selectedValue}
{...otherProps}
>
<div className={styles.valueText}>
{isMultiSelect && Array.isArray(selectedValue)
? selectedValue.map((key) => {
const v = valuesMap.get(key);
return <Label key={key}>{v ? v : key}</Label>;
})
: valuesMap.get(selectedValue as ArrayElement<V>)}
</div>
{hint != null && includeHint ? (
<div className={styles.hintText}>{hint}</div>
) : null}
</EnhancedSelectInputSelectedValue>
);
}
export default HintedSelectInputSelectedValue;

Some files were not shown because too many files have changed in this diff Show More