mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-05 13:20:20 -05:00
Compare commits
151 Commits
v4.0.8.215
...
v4.0.10.25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c41e3ce1e3 | ||
|
|
682d2b4e1b | ||
|
|
c114e2ddb7 | ||
|
|
f8a879f4c1 | ||
|
|
33139d4b53 | ||
|
|
de69d8ec7e | ||
|
|
03b9c957b8 | ||
|
|
41ddacc395 | ||
|
|
8a558b379a | ||
|
|
240a0339be | ||
|
|
ff724b7f40 | ||
|
|
fcf68d9259 | ||
|
|
404e6d68ea | ||
|
|
df672487cf | ||
|
|
0bc4903954 | ||
|
|
10b55bbee6 | ||
|
|
20ef22be94 | ||
|
|
57534db2f8 | ||
|
|
1e89a1a3cb | ||
|
|
f502eaffe3 | ||
|
|
fe40d83aa4 | ||
|
|
07374de747 | ||
|
|
135b5c2ddd | ||
|
|
0784f56b9a | ||
|
|
562e0dd7c0 | ||
|
|
28599f87af | ||
|
|
86446a7686 | ||
|
|
2f1793d87a | ||
|
|
a641f2897a | ||
|
|
32fa63d24d | ||
|
|
ebfa000375 | ||
|
|
39074b0b1d | ||
|
|
354ed96572 | ||
|
|
c8f419b014 | ||
|
|
a001216957 | ||
|
|
a6735e7a3f | ||
|
|
ea0bfed700 | ||
|
|
620220b269 | ||
|
|
c435fcd685 | ||
|
|
3828e475cc | ||
|
|
e6e1078c15 | ||
|
|
6660db22ec | ||
|
|
bc0fc623ee | ||
|
|
da610a1f40 | ||
|
|
6d0f10b877 | ||
|
|
4f0e1c54c1 | ||
|
|
2f0ca42341 | ||
|
|
768af433d1 | ||
|
|
8bf0298227 | ||
|
|
a7cb264cc8 | ||
|
|
10302323af | ||
|
|
dc1524c64f | ||
|
|
4d7a3d0909 | ||
|
|
30a52d11aa | ||
|
|
be4a9e9491 | ||
|
|
e196c1be69 | ||
|
|
106ffd410c | ||
|
|
c199fd05d3 | ||
|
|
75fae9262c | ||
|
|
faf9173b3b | ||
|
|
0fa8e24f48 | ||
|
|
27da041388 | ||
|
|
ca38a9b577 | ||
|
|
4b72a0a4e8 | ||
|
|
9875e550a8 | ||
|
|
c9aa59340c | ||
|
|
30c36fdc3b | ||
|
|
3976e5daf7 | ||
|
|
fca8c36156 | ||
|
|
85f53e8cb1 | ||
|
|
a73a5cc85c | ||
|
|
89d730cdfd | ||
|
|
99fc52039f | ||
|
|
e6bd58453a | ||
|
|
9603f0b086 | ||
|
|
d84c450094 | ||
|
|
97ebaf2796 | ||
|
|
31bf9e313e | ||
|
|
6cccacd4d7 | ||
|
|
3c857135c5 | ||
|
|
750a9353f8 | ||
|
|
71a19377d9 | ||
|
|
4b5ff3927d | ||
|
|
4d8a443681 | ||
|
|
6a332b40ac | ||
|
|
a929548ae3 | ||
|
|
55363f4e3d | ||
|
|
f20ac9dc34 | ||
|
|
8b20a9449c | ||
|
|
24f03fc1e9 | ||
|
|
5513d7bc5d | ||
|
|
a9072ac460 | ||
|
|
55aaaa5c40 | ||
|
|
ee99c3895d | ||
|
|
e1e10e195c | ||
|
|
0b9a212f33 | ||
|
|
0e384ee3aa | ||
|
|
d903529389 | ||
|
|
6f51e72d00 | ||
|
|
66cead6b48 | ||
|
|
7f0696c574 | ||
|
|
1584311914 | ||
|
|
278c7891a3 | ||
|
|
0a0e03dca0 | ||
|
|
546e9fd1d0 | ||
|
|
c80bd81bb9 | ||
|
|
e1cbc4a782 | ||
|
|
53d8c9ba8d | ||
|
|
9136ee4ad9 | ||
|
|
44fab9a96c | ||
|
|
66e4b7c819 | ||
|
|
98c4cbdd13 | ||
|
|
25d9f09a43 | ||
|
|
7ea1301221 | ||
|
|
f033799d7a | ||
|
|
cfa2f4d4c6 | ||
|
|
882b54be61 | ||
|
|
041fdd3929 | ||
|
|
4548dcdf97 | ||
|
|
4e14ce022c | ||
|
|
a9b93dd9c6 | ||
|
|
50d7e8fed4 | ||
|
|
402db9128c | ||
|
|
846333ddf0 | ||
|
|
dde28cbd7e | ||
|
|
8ceb306bf1 | ||
|
|
8af4246ff9 | ||
|
|
a2e06e9e65 | ||
|
|
ae7b187e41 | ||
|
|
63b4998c8e | ||
|
|
45665886d6 | ||
|
|
860424ac22 | ||
|
|
14005d8d10 | ||
|
|
da7d17f5e8 | ||
|
|
ea331feb88 | ||
|
|
7dca9060ca | ||
|
|
8af12cc4e7 | ||
|
|
aa488019cf | ||
|
|
47a05ecb36 | ||
|
|
35baebaf72 | ||
|
|
aedcd046fc | ||
|
|
f45713bff8 | ||
|
|
911a3d4c1e | ||
|
|
e16ace54a8 | ||
|
|
84710a31bd | ||
|
|
093a239e77 | ||
|
|
ee69351733 | ||
|
|
e92a67ad78 | ||
|
|
3eca63a67c | ||
|
|
8484a8beba | ||
|
|
cd3a1c18ab |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ env:
|
||||
FRAMEWORK: net6.0
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.8
|
||||
VERSION: 4.0.10
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
|
||||
29
.github/workflows/support-requests.yml
vendored
Normal file
29
.github/workflows/support-requests.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: 'Support Requests'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'Sonarr/Sonarr'
|
||||
steps:
|
||||
- uses: dessant/support-requests@v4
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: 'support'
|
||||
issue-comment: >
|
||||
:wave: @{issue-author}, we use the issue tracker exclusively
|
||||
for bug reports and feature requests. However, this issue appears
|
||||
to be a support request. Please use one of the support channels:
|
||||
[forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/),
|
||||
[discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr)
|
||||
for support/questions.
|
||||
close-issue: true
|
||||
issue-close-reason: 'not planned'
|
||||
lock-issue: false
|
||||
issue-lock-reason: 'off-topic'
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -162,3 +162,6 @@ src/.idea/
|
||||
|
||||
# API doc generation
|
||||
.config/
|
||||
|
||||
# Ignore Jetbrains IntelliJ Workspace Directories
|
||||
.idea/
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,66 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 4.8 KiB |
@@ -1,50 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1,64 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
22
README.md
22
README.md
@@ -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
|
||||
|
||||
[](https://translate.servarr.com/engage/servarr/)
|
||||
[](#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,13 +69,17 @@ This project would not be possible without the support of our users and software
|
||||
|
||||
#### JetBrains
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
* [<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/)
|
||||
[<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/)
|
||||
|
||||
### Licenses
|
||||
|
||||
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
|
||||
- Copyright 2010-2023
|
||||
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
|
||||
- Copyright 2010-2024
|
||||
|
||||
@@ -210,7 +210,6 @@ module.exports = {
|
||||
'no-undef-init': 'off',
|
||||
'no-undefined': 'off',
|
||||
'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }],
|
||||
'no-use-before-define': 'error',
|
||||
|
||||
// Node.js and CommonJS
|
||||
|
||||
|
||||
2
frontend/.vscode/settings.json
vendored
2
frontend/.vscode/settings.json
vendored
@@ -9,7 +9,7 @@
|
||||
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
|
||||
"typescript.preferences.quoteStyle": "single",
|
||||
|
||||
@@ -134,6 +134,12 @@ module.exports = (env) => {
|
||||
{
|
||||
source: 'frontend/src/Content/robots.txt',
|
||||
destination: path.join(distFolder, 'Content/robots.txt')
|
||||
},
|
||||
|
||||
// manifest.json and browserconfig.xml
|
||||
{
|
||||
source: 'frontend/src/Content/*.(json|xml)',
|
||||
destination: path.join(distFolder, 'Content')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const mixinsFiles = [
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
'autoprefixer',
|
||||
['postcss-mixins', {
|
||||
mixinsFiles
|
||||
}],
|
||||
|
||||
@@ -27,8 +27,6 @@ interface HistoryDetailsProps {
|
||||
sourceTitle: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
shortDateFormat: string;
|
||||
timeFormat: string;
|
||||
}
|
||||
|
||||
function HistoryDetails(props: HistoryDetailsProps) {
|
||||
|
||||
@@ -38,8 +38,6 @@ interface HistoryDetailsModalProps {
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
isMarkingAsFailed: boolean;
|
||||
shortDateFormat: string;
|
||||
timeFormat: string;
|
||||
onMarkAsFailedPress: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
@@ -52,8 +50,6 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed = false,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
onMarkAsFailedPress,
|
||||
onModalClose,
|
||||
} = props;
|
||||
@@ -69,8 +65,6 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
</ModalBody>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
@@ -20,7 +20,6 @@ import { QualityModel } from 'Quality/Quality';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
@@ -72,10 +71,6 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
const series = useSeries(seriesId);
|
||||
const episode = useEpisode(episodeId, 'episodes');
|
||||
|
||||
const { shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const handleDetailsPress = useCallback(() => {
|
||||
@@ -260,8 +255,6 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
isMarkingAsFailed={isMarkingAsFailed}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
onMarkAsFailedPress={handleMarkAsFailedPress}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import TooltipPosition from 'Helpers/Props/TooltipPosition';
|
||||
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
|
||||
import {
|
||||
QueueTrackedDownloadState,
|
||||
QueueTrackedDownloadStatus,
|
||||
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
|
||||
// status === 'downloading'
|
||||
let iconName = icons.DOWNLOADING;
|
||||
let iconKind = kinds.DEFAULT;
|
||||
let iconKind: IconProps['kind'] = kinds.DEFAULT;
|
||||
let title = translate('Downloading');
|
||||
|
||||
if (status === 'paused') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
@@ -129,7 +130,8 @@ class AddNewSeries extends Component {
|
||||
<div className={styles.helpText}>
|
||||
{translate('AddNewSeriesError')}
|
||||
</div>
|
||||
<div>{getErrorMessage(error)}</div>
|
||||
|
||||
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
|
||||
</div> : null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||
|
||||
export interface Error {
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
import CaptchaAppState from './CaptchaAppState';
|
||||
import CommandAppState from './CommandAppState';
|
||||
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
||||
import EpisodesAppState from './EpisodesAppState';
|
||||
import HistoryAppState from './HistoryAppState';
|
||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||
import OAuthAppState from './OAuthAppState';
|
||||
import ParseAppState from './ParseAppState';
|
||||
import PathsAppState from './PathsAppState';
|
||||
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
||||
import QueueAppState from './QueueAppState';
|
||||
import ReleasesAppState from './ReleasesAppState';
|
||||
import RootFolderAppState from './RootFolderAppState';
|
||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||
import SettingsAppState from './SettingsAppState';
|
||||
import SystemAppState from './SystemAppState';
|
||||
import TagsAppState from './TagsAppState';
|
||||
import WantedAppState from './WantedAppState';
|
||||
|
||||
interface FilterBuilderPropOption {
|
||||
id: string;
|
||||
@@ -61,20 +67,26 @@ interface AppState {
|
||||
app: AppSectionState;
|
||||
blocklist: BlocklistAppState;
|
||||
calendar: CalendarAppState;
|
||||
captcha: CaptchaAppState;
|
||||
commands: CommandAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodeFiles: EpisodeFilesAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodesSelection: EpisodesAppState;
|
||||
history: HistoryAppState;
|
||||
interactiveImport: InteractiveImportAppState;
|
||||
oAuth: OAuthAppState;
|
||||
parse: ParseAppState;
|
||||
paths: PathsAppState;
|
||||
providerOptions: ProviderOptionsAppState;
|
||||
queue: QueueAppState;
|
||||
releases: ReleasesAppState;
|
||||
rootFolders: RootFolderAppState;
|
||||
series: SeriesAppState;
|
||||
seriesIndex: SeriesIndexAppState;
|
||||
settings: SettingsAppState;
|
||||
system: SystemAppState;
|
||||
tags: TagsAppState;
|
||||
wanted: WantedAppState;
|
||||
}
|
||||
|
||||
export default AppState;
|
||||
|
||||
11
frontend/src/App/State/CaptchaAppState.ts
Normal file
11
frontend/src/App/State/CaptchaAppState.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
interface CaptchaAppState {
|
||||
refreshing: false;
|
||||
token: string;
|
||||
siteKey: unknown;
|
||||
secretToken: unknown;
|
||||
ray: unknown;
|
||||
stoken: unknown;
|
||||
responseUrl: unknown;
|
||||
}
|
||||
|
||||
export default CaptchaAppState;
|
||||
9
frontend/src/App/State/OAuthAppState.ts
Normal file
9
frontend/src/App/State/OAuthAppState.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Error } from './AppSectionState';
|
||||
|
||||
interface OAuthAppState {
|
||||
authorizing: boolean;
|
||||
result: Record<string, unknown> | null;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export default OAuthAppState;
|
||||
29
frontend/src/App/State/PathsAppState.ts
Normal file
29
frontend/src/App/State/PathsAppState.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
interface BasePath {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
interface File extends BasePath {
|
||||
type: 'file';
|
||||
}
|
||||
|
||||
interface Folder extends BasePath {
|
||||
type: 'folder';
|
||||
}
|
||||
|
||||
export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
|
||||
export type Path = File | Folder;
|
||||
|
||||
interface PathsAppState {
|
||||
currentPath: string;
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
directories: Folder[];
|
||||
files: File[];
|
||||
parent: string | null;
|
||||
}
|
||||
|
||||
export default PathsAppState;
|
||||
22
frontend/src/App/State/ProviderOptionsAppState.ts
Normal file
22
frontend/src/App/State/ProviderOptionsAppState.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import Field, { FieldSelectOption } from 'typings/Field';
|
||||
|
||||
export interface ProviderOptions {
|
||||
fields?: Field[];
|
||||
}
|
||||
|
||||
interface ProviderOptionsDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProviderOptionsAppState {
|
||||
devices: AppSectionState<ProviderOptionsDevice>;
|
||||
servers: AppSectionState<FieldSelectOption<unknown>>;
|
||||
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
|
||||
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
|
||||
getTags: AppSectionState<FieldSelectOption<unknown>>;
|
||||
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
|
||||
}
|
||||
|
||||
export default ProviderOptionsAppState;
|
||||
10
frontend/src/App/State/ReleasesAppState.ts
Normal file
10
frontend/src/App/State/ReleasesAppState.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Release from 'typings/Release';
|
||||
|
||||
interface ReleasesAppState
|
||||
extends AppSectionState<Release>,
|
||||
AppSectionFilterState<Release> {}
|
||||
|
||||
export default ReleasesAppState;
|
||||
@@ -3,7 +3,7 @@ import AppSectionState, {
|
||||
AppSectionSaveState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Column from 'Components/Table/Column';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import Series from 'Series/Series';
|
||||
import { Filter, FilterBuilderProp } from './AppState';
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppSectionState, {
|
||||
PagedAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Language from 'Language/Language';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import DownloadClient from 'typings/DownloadClient';
|
||||
import ImportList from 'typings/ImportList';
|
||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||
@@ -15,6 +16,9 @@ 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
|
||||
@@ -24,7 +28,16 @@ export interface DownloadClientAppState
|
||||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
export type GeneralAppState = AppSectionItemState<General>;
|
||||
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>,
|
||||
@@ -46,6 +59,17 @@ 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,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ImportListOptionsSettingsAppState
|
||||
extends AppSectionItemState<ImportListOptionsSettings>,
|
||||
AppSectionSaveState {}
|
||||
@@ -64,6 +88,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
||||
|
||||
interface SettingsAppState {
|
||||
advancedSettings: boolean;
|
||||
customFormats: CustomFormatAppState;
|
||||
downloadClients: DownloadClientAppState;
|
||||
general: GeneralAppState;
|
||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||
@@ -72,8 +97,11 @@ interface SettingsAppState {
|
||||
indexerFlags: IndexerFlagSettingsAppState;
|
||||
indexers: IndexerAppState;
|
||||
languages: LanguageSettingsAppState;
|
||||
naming: NamingAppState;
|
||||
namingExamples: NamingExamplesAppState;
|
||||
notifications: NotificationAppState;
|
||||
qualityProfiles: QualityProfilesAppState;
|
||||
releaseProfiles: ReleaseProfilesAppState;
|
||||
ui: UiSettingsAppState;
|
||||
}
|
||||
|
||||
|
||||
13
frontend/src/App/State/WantedAppState.ts
Normal file
13
frontend/src/App/State/WantedAppState.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import Episode from 'Episode/Episode';
|
||||
|
||||
interface WantedCutoffUnmetAppState extends AppSectionState<Episode> {}
|
||||
|
||||
interface WantedMissingAppState extends AppSectionState<Episode> {}
|
||||
|
||||
interface WantedAppState {
|
||||
cutoffUnmet: WantedCutoffUnmetAppState;
|
||||
missing: WantedMissingAppState;
|
||||
}
|
||||
|
||||
export default WantedAppState;
|
||||
@@ -26,6 +26,7 @@ export interface CommandBody {
|
||||
seriesId?: number;
|
||||
seriesIds?: number[];
|
||||
seasonNumber?: number;
|
||||
episodeIds?: number[];
|
||||
[key: string]: string | number | boolean | number[] | undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './Alert.css';
|
||||
|
||||
function Alert(props) {
|
||||
const { className, kind, children, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind]
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Alert.propTypes = {
|
||||
className: PropTypes.string,
|
||||
kind: PropTypes.oneOf(kinds.all),
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
Alert.defaultProps = {
|
||||
className: styles.alert,
|
||||
kind: kinds.INFO
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
18
frontend/src/Components/Alert.tsx
Normal file
18
frontend/src/Components/Alert.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import styles from './Alert.css';
|
||||
|
||||
interface AlertProps {
|
||||
className?: string;
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Alert(props: AlertProps) {
|
||||
const { className = styles.alert, kind = 'info', children } = props;
|
||||
|
||||
return <div className={classNames(className, styles[kind])}>{children}</div>;
|
||||
}
|
||||
|
||||
export default Alert;
|
||||
@@ -1,60 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './Card.css';
|
||||
|
||||
class Card extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
overlayClassName,
|
||||
overlayContent,
|
||||
children,
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
if (overlayContent) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
onPress={onPress}
|
||||
/>
|
||||
|
||||
<div className={overlayClassName}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={className}
|
||||
onPress={onPress}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Card.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
overlayClassName: PropTypes.string.isRequired,
|
||||
overlayContent: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
Card.defaultProps = {
|
||||
className: styles.card,
|
||||
overlayClassName: styles.overlay,
|
||||
overlayContent: false
|
||||
};
|
||||
|
||||
export default Card;
|
||||
39
frontend/src/Components/Card.tsx
Normal file
39
frontend/src/Components/Card.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import Link, { LinkProps } from 'Components/Link/Link';
|
||||
import styles from './Card.css';
|
||||
|
||||
interface CardProps extends Pick<LinkProps, 'onPress'> {
|
||||
// TODO: Consider using different properties for classname depending if it's overlaying content or not
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
overlayContent?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Card(props: CardProps) {
|
||||
const {
|
||||
className = styles.card,
|
||||
overlayClassName = styles.overlay,
|
||||
overlayContent = false,
|
||||
children,
|
||||
onPress,
|
||||
} = props;
|
||||
|
||||
if (overlayContent) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link className={styles.underlay} onPress={onPress} />
|
||||
|
||||
<div className={overlayClassName}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={className} onPress={onPress}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default Card;
|
||||
@@ -1,138 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './CircularProgressBar.css';
|
||||
|
||||
class CircularProgressBar extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
progress: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._progressStep();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const progress = this.props.progress;
|
||||
|
||||
if (prevProps.progress !== progress) {
|
||||
this._cancelProgressStep();
|
||||
this._progressStep();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelProgressStep();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_progressStep() {
|
||||
this.requestAnimationFrame = window.requestAnimationFrame(() => {
|
||||
this.setState({
|
||||
progress: this.state.progress + 1
|
||||
}, () => {
|
||||
if (this.state.progress < this.props.progress) {
|
||||
this._progressStep();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_cancelProgressStep() {
|
||||
if (this.requestAnimationFrame) {
|
||||
window.cancelAnimationFrame(this.requestAnimationFrame);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
containerClassName,
|
||||
size,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
showProgressText
|
||||
} = this.props;
|
||||
|
||||
const progress = this.state.progress;
|
||||
|
||||
const center = size / 2;
|
||||
const radius = center - strokeWidth;
|
||||
const circumference = Math.PI * (radius * 2);
|
||||
const sizeInPixels = `${size}px`;
|
||||
const strokeDashoffset = ((100 - progress) / 100) * circumference;
|
||||
const progressText = `${Math.round(progress)}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClassName}
|
||||
style={{
|
||||
width: sizeInPixels,
|
||||
height: sizeInPixels,
|
||||
lineHeight: sizeInPixels
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className={className}
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<circle
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx={center}
|
||||
cy={center}
|
||||
strokeDasharray={circumference}
|
||||
style={{
|
||||
stroke: strokeColor,
|
||||
strokeWidth,
|
||||
strokeDashoffset
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{
|
||||
showProgressText &&
|
||||
<div className={styles.circularProgressBarText}>
|
||||
{progressText}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CircularProgressBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
containerClassName: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
progress: PropTypes.number.isRequired,
|
||||
strokeWidth: PropTypes.number,
|
||||
strokeColor: PropTypes.string,
|
||||
showProgressText: PropTypes.bool
|
||||
};
|
||||
|
||||
CircularProgressBar.defaultProps = {
|
||||
className: styles.circularProgressBar,
|
||||
containerClassName: styles.circularProgressBarContainer,
|
||||
size: 60,
|
||||
strokeWidth: 5,
|
||||
strokeColor: '#35c5f4',
|
||||
showProgressText: false
|
||||
};
|
||||
|
||||
export default CircularProgressBar;
|
||||
99
frontend/src/Components/CircularProgressBar.tsx
Normal file
99
frontend/src/Components/CircularProgressBar.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styles from './CircularProgressBar.css';
|
||||
|
||||
interface CircularProgressBarProps {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
size?: number;
|
||||
progress: number;
|
||||
strokeWidth?: number;
|
||||
strokeColor?: string;
|
||||
showProgressText?: boolean;
|
||||
}
|
||||
|
||||
function CircularProgressBar({
|
||||
className = styles.circularProgressBar,
|
||||
containerClassName = styles.circularProgressBarContainer,
|
||||
size = 60,
|
||||
strokeWidth = 5,
|
||||
strokeColor = '#35c5f4',
|
||||
showProgressText = false,
|
||||
progress,
|
||||
}: CircularProgressBarProps) {
|
||||
const [currentProgress, setCurrentProgress] = useState(0);
|
||||
const raf = React.useRef<number>(0);
|
||||
const center = size / 2;
|
||||
const radius = center - strokeWidth;
|
||||
const circumference = Math.PI * (radius * 2);
|
||||
const sizeInPixels = `${size}px`;
|
||||
const strokeDashoffset = ((100 - currentProgress) / 100) * circumference;
|
||||
const progressText = `${Math.round(currentProgress)}%`;
|
||||
|
||||
const handleAnimation = useCallback(
|
||||
(p: number) => {
|
||||
setCurrentProgress((prevProgress) => {
|
||||
if (prevProgress < p) {
|
||||
return prevProgress + Math.min(1, p - prevProgress);
|
||||
}
|
||||
|
||||
return prevProgress;
|
||||
});
|
||||
},
|
||||
[setCurrentProgress]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress > currentProgress) {
|
||||
cancelAnimationFrame(raf.current);
|
||||
|
||||
raf.current = requestAnimationFrame(() => handleAnimation(progress));
|
||||
}
|
||||
}, [progress, currentProgress, handleAnimation]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
return () => cancelAnimationFrame(raf.current);
|
||||
},
|
||||
// We only want to run this effect once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClassName}
|
||||
style={{
|
||||
width: sizeInPixels,
|
||||
height: sizeInPixels,
|
||||
lineHeight: sizeInPixels,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className={className}
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<circle
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx={center}
|
||||
cy={center}
|
||||
strokeDasharray={circumference}
|
||||
style={{
|
||||
stroke: strokeColor,
|
||||
strokeWidth,
|
||||
strokeDashoffset,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{showProgressText && (
|
||||
<div className={styles.circularProgressBarText}>{progressText}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CircularProgressBar;
|
||||
@@ -1,33 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './DescriptionList.css';
|
||||
|
||||
class DescriptionList extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<dl className={className}>
|
||||
{children}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionList.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
DescriptionList.defaultProps = {
|
||||
className: styles.descriptionList
|
||||
};
|
||||
|
||||
export default DescriptionList;
|
||||
15
frontend/src/Components/DescriptionList/DescriptionList.tsx
Normal file
15
frontend/src/Components/DescriptionList/DescriptionList.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import styles from './DescriptionList.css';
|
||||
|
||||
interface DescriptionListProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionList(props: DescriptionListProps) {
|
||||
const { className = styles.descriptionList, children } = props;
|
||||
|
||||
return <dl className={className}>{children}</dl>;
|
||||
}
|
||||
|
||||
export default DescriptionList;
|
||||
@@ -1,46 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import DescriptionListItemDescription from './DescriptionListItemDescription';
|
||||
import DescriptionListItemTitle from './DescriptionListItemTitle';
|
||||
|
||||
class DescriptionListItem extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
title,
|
||||
data
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<DescriptionListItemTitle
|
||||
className={titleClassName}
|
||||
>
|
||||
{title}
|
||||
</DescriptionListItemTitle>
|
||||
|
||||
<DescriptionListItemDescription
|
||||
className={descriptionClassName}
|
||||
>
|
||||
{data}
|
||||
</DescriptionListItemDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionListItem.propTypes = {
|
||||
className: PropTypes.string,
|
||||
titleClassName: PropTypes.string,
|
||||
descriptionClassName: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
|
||||
};
|
||||
|
||||
export default DescriptionListItem;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import DescriptionListItemDescription, {
|
||||
DescriptionListItemDescriptionProps,
|
||||
} from './DescriptionListItemDescription';
|
||||
import DescriptionListItemTitle, {
|
||||
DescriptionListItemTitleProps,
|
||||
} from './DescriptionListItemTitle';
|
||||
|
||||
interface DescriptionListItemProps {
|
||||
className?: string;
|
||||
titleClassName?: DescriptionListItemTitleProps['className'];
|
||||
descriptionClassName?: DescriptionListItemDescriptionProps['className'];
|
||||
title?: DescriptionListItemTitleProps['children'];
|
||||
data?: DescriptionListItemDescriptionProps['children'];
|
||||
}
|
||||
|
||||
function DescriptionListItem(props: DescriptionListItemProps) {
|
||||
const { className, titleClassName, descriptionClassName, title, data } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<DescriptionListItemTitle className={titleClassName}>
|
||||
{title}
|
||||
</DescriptionListItemTitle>
|
||||
|
||||
<DescriptionListItemDescription className={descriptionClassName}>
|
||||
{data}
|
||||
</DescriptionListItemDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DescriptionListItem;
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DescriptionListItemDescription.css';
|
||||
|
||||
function DescriptionListItemDescription(props) {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<dd className={className}>
|
||||
{children}
|
||||
</dd>
|
||||
);
|
||||
}
|
||||
|
||||
DescriptionListItemDescription.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
|
||||
};
|
||||
|
||||
DescriptionListItemDescription.defaultProps = {
|
||||
className: styles.description
|
||||
};
|
||||
|
||||
export default DescriptionListItemDescription;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import styles from './DescriptionListItemDescription.css';
|
||||
|
||||
export interface DescriptionListItemDescriptionProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionListItemDescription(
|
||||
props: DescriptionListItemDescriptionProps
|
||||
) {
|
||||
const { className = styles.description, children } = props;
|
||||
|
||||
return <dd className={className}>{children}</dd>;
|
||||
}
|
||||
|
||||
export default DescriptionListItemDescription;
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DescriptionListItemTitle.css';
|
||||
|
||||
function DescriptionListItemTitle(props) {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<dt className={className}>
|
||||
{children}
|
||||
</dt>
|
||||
);
|
||||
}
|
||||
|
||||
DescriptionListItemTitle.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.string
|
||||
};
|
||||
|
||||
DescriptionListItemTitle.defaultProps = {
|
||||
className: styles.title
|
||||
};
|
||||
|
||||
export default DescriptionListItemTitle;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import styles from './DescriptionListItemTitle.css';
|
||||
|
||||
export interface DescriptionListItemTitleProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionListItemTitle(props: DescriptionListItemTitleProps) {
|
||||
const { className = styles.title, children } = props;
|
||||
|
||||
return <dt className={className}>{children}</dt>;
|
||||
}
|
||||
|
||||
export default DescriptionListItemTitle;
|
||||
@@ -1,22 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DragPreviewLayer.css';
|
||||
|
||||
function DragPreviewLayer({ children, ...otherProps }) {
|
||||
return (
|
||||
<div {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DragPreviewLayer.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
DragPreviewLayer.defaultProps = {
|
||||
className: styles.dragLayer
|
||||
};
|
||||
|
||||
export default DragPreviewLayer;
|
||||
21
frontend/src/Components/DragPreviewLayer.tsx
Normal file
21
frontend/src/Components/DragPreviewLayer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import styles from './DragPreviewLayer.css';
|
||||
|
||||
interface DragPreviewLayerProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function DragPreviewLayer({
|
||||
className = styles.dragLayer,
|
||||
children,
|
||||
...otherProps
|
||||
}: DragPreviewLayerProps) {
|
||||
return (
|
||||
<div className={className} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DragPreviewLayer;
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as sentry from '@sentry/browser';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
error: null,
|
||||
info: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({
|
||||
error,
|
||||
info
|
||||
});
|
||||
|
||||
sentry.captureException(error);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
errorComponent: ErrorComponent,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
error,
|
||||
info
|
||||
} = this.state;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorComponent
|
||||
error={error}
|
||||
info={info}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
ErrorBoundary.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
errorComponent: PropTypes.elementType.isRequired
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
46
frontend/src/Components/Error/ErrorBoundary.tsx
Normal file
46
frontend/src/Components/Error/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as sentry from '@sentry/browser';
|
||||
import React, { Component, ErrorInfo } from 'react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
errorComponent: React.ElementType;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null;
|
||||
info: ErrorInfo | null;
|
||||
}
|
||||
|
||||
// Class component until componentDidCatch is supported in functional components
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: null,
|
||||
info: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
info,
|
||||
});
|
||||
|
||||
sentry.captureException(error);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, errorComponent: ErrorComponent } = this.props;
|
||||
const { error, info } = this.state;
|
||||
|
||||
if (error) {
|
||||
return <ErrorComponent error={error} info={info} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -1,41 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import styles from './FieldSet.css';
|
||||
|
||||
class FieldSet extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
size,
|
||||
legend,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<fieldset className={styles.fieldSet}>
|
||||
<legend className={classNames(styles.legend, (size === sizes.SMALL) && styles.small)}>
|
||||
{legend}
|
||||
</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
FieldSet.propTypes = {
|
||||
size: PropTypes.oneOf(sizes.all).isRequired,
|
||||
legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
FieldSet.defaultProps = {
|
||||
size: sizes.MEDIUM
|
||||
};
|
||||
|
||||
export default FieldSet;
|
||||
29
frontend/src/Components/FieldSet.tsx
Normal file
29
frontend/src/Components/FieldSet.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import styles from './FieldSet.css';
|
||||
|
||||
interface FieldSetProps {
|
||||
size?: Size;
|
||||
legend?: ComponentProps<'legend'>['children'];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function FieldSet({ size = sizes.MEDIUM, legend, children }: FieldSetProps) {
|
||||
return (
|
||||
<fieldset className={styles.fieldSet}>
|
||||
<legend
|
||||
className={classNames(
|
||||
styles.legend,
|
||||
size === sizes.SMALL && styles.small
|
||||
)}
|
||||
>
|
||||
{legend}
|
||||
</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldSet;
|
||||
@@ -1,39 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import FileBrowserModalContentConnector from './FileBrowserModalContentConnector';
|
||||
import styles from './FileBrowserModal.css';
|
||||
|
||||
class FileBrowserModal extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={styles.modal}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<FileBrowserModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserModal;
|
||||
23
frontend/src/Components/FileBrowser/FileBrowserModal.tsx
Normal file
23
frontend/src/Components/FileBrowser/FileBrowserModal.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import FileBrowserModalContent, {
|
||||
FileBrowserModalContentProps,
|
||||
} from './FileBrowserModalContent';
|
||||
import styles from './FileBrowserModal.css';
|
||||
|
||||
interface FileBrowserModalProps extends FileBrowserModalContentProps {
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
const { isOpen, onModalClose, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<Modal className={styles.modal} isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<FileBrowserModalContent {...otherProps} onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileBrowserModal;
|
||||
@@ -1,246 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import PathInput from 'Components/Form/PathInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import { kinds, scrollDirections } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import FileBrowserRow from './FileBrowserRow';
|
||||
import styles from './FileBrowserModalContent.css';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'type',
|
||||
label: () => translate('Type'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
isVisible: true
|
||||
}
|
||||
];
|
||||
|
||||
class FileBrowserModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scrollerRef = React.createRef();
|
||||
|
||||
this.state = {
|
||||
isFileBrowserModalOpen: false,
|
||||
currentPath: props.value
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
currentPath
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
currentPath !== this.state.currentPath &&
|
||||
currentPath !== prevState.currentPath
|
||||
) {
|
||||
this.setState({ currentPath });
|
||||
this._scrollerRef.current.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPathInputChange = ({ value }) => {
|
||||
this.setState({ currentPath: value });
|
||||
};
|
||||
|
||||
onRowPress = (path) => {
|
||||
this.props.onFetchPaths(path);
|
||||
};
|
||||
|
||||
onOkPress = () => {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: this.state.currentPath
|
||||
});
|
||||
|
||||
this.props.onClearPaths();
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
directories,
|
||||
files,
|
||||
isWindowsService,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const emptyParent = parent === '';
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalHeader>
|
||||
{translate('FileBrowser')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody
|
||||
className={styles.modalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
{
|
||||
isWindowsService &&
|
||||
<Alert
|
||||
className={styles.mappedDrivesWarning}
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService', { url: 'https://wiki.servarr.com/sonarr/faq#why-cant-sonarr-see-my-files-on-a-remote-server' })} />
|
||||
</Alert>
|
||||
}
|
||||
|
||||
<PathInput
|
||||
className={styles.pathInput}
|
||||
placeholder={translate('FileBrowserPlaceholderText')}
|
||||
hasFileBrowser={false}
|
||||
{...otherProps}
|
||||
value={this.state.currentPath}
|
||||
onChange={this.onPathInputChange}
|
||||
/>
|
||||
|
||||
<Scroller
|
||||
ref={this._scrollerRef}
|
||||
className={styles.scroller}
|
||||
scrollDirection={scrollDirections.BOTH}
|
||||
>
|
||||
{
|
||||
!!error &&
|
||||
<div>{translate('ErrorLoadingContents')}</div>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !error &&
|
||||
<Table
|
||||
horizontalScroll={false}
|
||||
columns={columns}
|
||||
>
|
||||
<TableBody>
|
||||
{
|
||||
emptyParent &&
|
||||
<FileBrowserRow
|
||||
type="computer"
|
||||
name={translate('MyComputer')}
|
||||
path={parent}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!emptyParent && parent &&
|
||||
<FileBrowserRow
|
||||
type="parent"
|
||||
name="..."
|
||||
path={parent}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
directories.map((directory) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={directory.path}
|
||||
type={directory.type}
|
||||
name={directory.name}
|
||||
path={directory.path}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
files.map((file) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={file.path}
|
||||
type={file.type}
|
||||
name={file.name}
|
||||
path={file.path}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
}
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={this.onOkPress}
|
||||
>
|
||||
{translate('Ok')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModalContent.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
parent: PropTypes.string,
|
||||
currentPath: PropTypes.string.isRequired,
|
||||
directories: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
files: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isWindowsService: PropTypes.bool.isRequired,
|
||||
onFetchPaths: PropTypes.func.isRequired,
|
||||
onClearPaths: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserModalContent;
|
||||
237
frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
Normal file
237
frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Alert from 'Components/Alert';
|
||||
import { PathInputInternal } from 'Components/Form/PathInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds, scrollDirections } from 'Helpers/Props';
|
||||
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import createPathsSelector from './createPathsSelector';
|
||||
import FileBrowserRow from './FileBrowserRow';
|
||||
import styles from './FileBrowserModalContent.css';
|
||||
|
||||
const columns: Column[] = [
|
||||
{
|
||||
name: 'type',
|
||||
label: () => translate('Type'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleClearPaths = () => {};
|
||||
|
||||
export interface FileBrowserModalContentProps {
|
||||
name: string;
|
||||
value: string;
|
||||
includeFiles?: boolean;
|
||||
onChange: (args: InputChanged<string>) => unknown;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function FileBrowserModalContent(props: FileBrowserModalContentProps) {
|
||||
const { name, value, includeFiles = true, onChange, onModalClose } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isWindows, mode } = useSelector(createSystemStatusSelector());
|
||||
const { isFetching, isPopulated, error, parent, directories, files, paths } =
|
||||
useSelector(createPathsSelector());
|
||||
|
||||
const [currentPath, setCurrentPath] = useState(value);
|
||||
const scrollerRef = useRef(null);
|
||||
const previousValue = usePrevious(value);
|
||||
|
||||
const emptyParent = parent === '';
|
||||
const isWindowsService = isWindows && mode === 'service';
|
||||
|
||||
const handlePathInputChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
setCurrentPath(value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRowPress = useCallback(
|
||||
(path: string) => {
|
||||
setCurrentPath(path);
|
||||
|
||||
dispatch(
|
||||
fetchPaths({
|
||||
path,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles,
|
||||
})
|
||||
);
|
||||
},
|
||||
[includeFiles, dispatch, setCurrentPath]
|
||||
);
|
||||
|
||||
const handleOkPress = useCallback(() => {
|
||||
onChange({
|
||||
name,
|
||||
value: currentPath,
|
||||
});
|
||||
|
||||
dispatch(clearPaths());
|
||||
onModalClose();
|
||||
}, [name, currentPath, dispatch, onChange, onModalClose]);
|
||||
|
||||
const handleFetchPaths = useCallback(
|
||||
(path: string) => {
|
||||
dispatch(
|
||||
fetchPaths({
|
||||
path,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles,
|
||||
})
|
||||
);
|
||||
},
|
||||
[includeFiles, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== previousValue && value !== currentPath) {
|
||||
setCurrentPath(value);
|
||||
}
|
||||
}, [value, previousValue, currentPath, setCurrentPath]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
dispatch(
|
||||
fetchPaths({
|
||||
path: currentPath,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles,
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
dispatch(clearPaths());
|
||||
};
|
||||
},
|
||||
// This should only run once when the component mounts,
|
||||
// so we don't need to include the other dependencies.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('FileBrowser')}</ModalHeader>
|
||||
|
||||
<ModalBody
|
||||
className={styles.modalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
{isWindowsService ? (
|
||||
<Alert className={styles.mappedDrivesWarning} kind={kinds.WARNING}>
|
||||
<InlineMarkdown
|
||||
data={translate('MappedNetworkDrivesWindowsService', {
|
||||
url: 'https://wiki.servarr.com/sonarr/faq#why-cant-sonarr-see-my-files-on-a-remote-server',
|
||||
})}
|
||||
/>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<PathInputInternal
|
||||
className={styles.pathInput}
|
||||
placeholder={translate('FileBrowserPlaceholderText')}
|
||||
hasFileBrowser={false}
|
||||
includeFiles={includeFiles}
|
||||
paths={paths}
|
||||
name={name}
|
||||
value={currentPath}
|
||||
onChange={handlePathInputChange}
|
||||
onFetchPaths={handleFetchPaths}
|
||||
onClearPaths={handleClearPaths}
|
||||
/>
|
||||
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.scroller}
|
||||
scrollDirection="both"
|
||||
>
|
||||
{error ? <div>{translate('ErrorLoadingContents')}</div> : null}
|
||||
|
||||
{isPopulated && !error ? (
|
||||
<Table horizontalScroll={false} columns={columns}>
|
||||
<TableBody>
|
||||
{emptyParent ? (
|
||||
<FileBrowserRow
|
||||
type="computer"
|
||||
name={translate('MyComputer')}
|
||||
path={parent}
|
||||
onPress={handleRowPress}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!emptyParent && parent ? (
|
||||
<FileBrowserRow
|
||||
type="parent"
|
||||
name="..."
|
||||
path={parent}
|
||||
onPress={handleRowPress}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{directories.map((directory) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={directory.path}
|
||||
type={directory.type}
|
||||
name={directory.name}
|
||||
path={directory.path}
|
||||
onPress={handleRowPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{files.map((file) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={file.path}
|
||||
type={file.type}
|
||||
name={file.name}
|
||||
path={file.path}
|
||||
onPress={handleRowPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button onPress={handleOkPress}>{translate('Ok')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileBrowserModalContent;
|
||||
@@ -1,119 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import FileBrowserModalContent from './FileBrowserModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.paths,
|
||||
createSystemStatusSelector(),
|
||||
(paths, systemStatus) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files
|
||||
} = paths;
|
||||
|
||||
const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
|
||||
return path.toLowerCase().startsWith(currentPath.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files,
|
||||
paths: filteredPaths,
|
||||
isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service'
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchPaths: fetchPaths,
|
||||
dispatchClearPaths: clearPaths
|
||||
};
|
||||
|
||||
class FileBrowserModalContentConnector extends Component {
|
||||
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
value,
|
||||
includeFiles,
|
||||
dispatchFetchPaths
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchPaths({
|
||||
path: value,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onFetchPaths = (path) => {
|
||||
const {
|
||||
includeFiles,
|
||||
dispatchFetchPaths
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchPaths({
|
||||
path,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles
|
||||
});
|
||||
};
|
||||
|
||||
onClearPaths = () => {
|
||||
// this.props.dispatchClearPaths();
|
||||
};
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.dispatchClearPaths();
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<FileBrowserModalContent
|
||||
onFetchPaths={this.onFetchPaths}
|
||||
onClearPaths={this.onClearPaths}
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModalContentConnector.propTypes = {
|
||||
value: PropTypes.string,
|
||||
includeFiles: PropTypes.bool.isRequired,
|
||||
dispatchFetchPaths: PropTypes.func.isRequired,
|
||||
dispatchClearPaths: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
FileBrowserModalContentConnector.defaultProps = {
|
||||
includeFiles: false
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector);
|
||||
@@ -1,62 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRowButton from 'Components/Table/TableRowButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './FileBrowserRow.css';
|
||||
|
||||
function getIconName(type) {
|
||||
switch (type) {
|
||||
case 'computer':
|
||||
return icons.COMPUTER;
|
||||
case 'drive':
|
||||
return icons.DRIVE;
|
||||
case 'file':
|
||||
return icons.FILE;
|
||||
case 'parent':
|
||||
return icons.PARENT;
|
||||
default:
|
||||
return icons.FOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
class FileBrowserRow extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.props.onPress(this.props.path);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
type,
|
||||
name
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRowButton onPress={this.onPress}>
|
||||
<TableRowCell className={styles.type}>
|
||||
<Icon name={getIconName(type)} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>{name}</TableRowCell>
|
||||
</TableRowButton>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
FileBrowserRow.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserRow;
|
||||
49
frontend/src/Components/FileBrowser/FileBrowserRow.tsx
Normal file
49
frontend/src/Components/FileBrowser/FileBrowserRow.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { PathType } from 'App/State/PathsAppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRowButton from 'Components/Table/TableRowButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './FileBrowserRow.css';
|
||||
|
||||
function getIconName(type: PathType) {
|
||||
switch (type) {
|
||||
case 'computer':
|
||||
return icons.COMPUTER;
|
||||
case 'drive':
|
||||
return icons.DRIVE;
|
||||
case 'file':
|
||||
return icons.FILE;
|
||||
case 'parent':
|
||||
return icons.PARENT;
|
||||
default:
|
||||
return icons.FOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
interface FileBrowserRowProps {
|
||||
type: PathType;
|
||||
name: string;
|
||||
path: string;
|
||||
onPress: (path: string) => void;
|
||||
}
|
||||
|
||||
function FileBrowserRow(props: FileBrowserRowProps) {
|
||||
const { type, name, path, onPress } = props;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(path);
|
||||
}, [path, onPress]);
|
||||
|
||||
return (
|
||||
<TableRowButton onPress={handlePress}>
|
||||
<TableRowCell className={styles.type}>
|
||||
<Icon name={getIconName(type)} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>{name}</TableRowCell>
|
||||
</TableRowButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileBrowserRow;
|
||||
36
frontend/src/Components/FileBrowser/createPathsSelector.ts
Normal file
36
frontend/src/Components/FileBrowser/createPathsSelector.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
function createPathsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.paths,
|
||||
(paths) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files,
|
||||
} = paths;
|
||||
|
||||
const filteredPaths = [...directories, ...files].filter(({ path }) => {
|
||||
return path.toLowerCase().startsWith(currentPath.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files,
|
||||
paths: filteredPaths,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createPathsSelector;
|
||||
@@ -1,6 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import TagInput from 'Components/Form/TagInput';
|
||||
import TagInput from 'Components/Form/Tag/TagInput';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props';
|
||||
import tagShape from 'Helpers/Props/Shapes/tagShape';
|
||||
import convertToBytes from 'Utilities/Number/convertToBytes';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import TagInputTag from 'Components/Form/TagInputTag';
|
||||
import TagInputTag from 'Components/Form/Tag/TagInputTag';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './FilterBuilderRowValueTag.css';
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import jdu from 'jdu';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import AutoSuggestInput from './AutoSuggestInput';
|
||||
|
||||
class AutoCompleteInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
suggestions: []
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
getSuggestionValue(item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
renderSuggestion(item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = (event, { newValue }) => {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: newValue
|
||||
});
|
||||
};
|
||||
|
||||
onInputBlur = () => {
|
||||
this.setState({ suggestions: [] });
|
||||
};
|
||||
|
||||
onSuggestionsFetchRequested = ({ value }) => {
|
||||
const { values } = this.props;
|
||||
const lowerCaseValue = jdu.replace(value).toLowerCase();
|
||||
|
||||
const filteredValues = values.filter((v) => {
|
||||
return jdu.replace(v).toLowerCase().contains(lowerCaseValue);
|
||||
});
|
||||
|
||||
this.setState({ suggestions: filteredValues });
|
||||
};
|
||||
|
||||
onSuggestionsClearRequested = () => {
|
||||
this.setState({ suggestions: [] });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const { suggestions } = this.state;
|
||||
|
||||
return (
|
||||
<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;
|
||||
81
frontend/src/Components/Form/AutoCompleteInput.tsx
Normal file
81
frontend/src/Components/Form/AutoCompleteInput.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import jdu from 'jdu';
|
||||
import React, { SyntheticEvent, useCallback, useState } from 'react';
|
||||
import {
|
||||
ChangeEvent,
|
||||
SuggestionsFetchRequestedParams,
|
||||
} from 'react-autosuggest';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import AutoSuggestInput from './AutoSuggestInput';
|
||||
|
||||
interface AutoCompleteInputProps {
|
||||
name: string;
|
||||
value?: string;
|
||||
values: string[];
|
||||
onChange: (change: InputChanged<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;
|
||||
@@ -1,257 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Portal from 'Components/Portal';
|
||||
import styles from './AutoSuggestInput.css';
|
||||
|
||||
class AutoSuggestInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scheduleUpdate = null;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
this._scheduleUpdate &&
|
||||
prevProps.suggestions !== this.props.suggestions
|
||||
) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
renderInputComponent = (inputProps) => {
|
||||
const { renderInputComponent } = this.props;
|
||||
|
||||
return (
|
||||
<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;
|
||||
259
frontend/src/Components/Form/AutoSuggestInput.tsx
Normal file
259
frontend/src/Components/Form/AutoSuggestInput.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
FocusEvent,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
KeyboardEventHandler,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
Ref,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import Autosuggest, {
|
||||
AutosuggestPropsBase,
|
||||
BlurEvent,
|
||||
ChangeEvent,
|
||||
RenderInputComponentProps,
|
||||
RenderSuggestionsContainerParams,
|
||||
} from 'react-autosuggest';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Portal from 'Components/Portal';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import styles from './AutoSuggestInput.css';
|
||||
|
||||
interface AutoSuggestInputProps<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;
|
||||
@@ -1,84 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import FormInputButton from './FormInputButton';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './CaptchaInput.css';
|
||||
|
||||
function CaptchaInput(props) {
|
||||
const {
|
||||
className,
|
||||
name,
|
||||
value,
|
||||
hasError,
|
||||
hasWarning,
|
||||
refreshing,
|
||||
siteKey,
|
||||
secretToken,
|
||||
onChange,
|
||||
onRefreshPress,
|
||||
onCaptchaChange
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<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;
|
||||
118
frontend/src/Components/Form/CaptchaInput.tsx
Normal file
118
frontend/src/Components/Form/CaptchaInput.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import {
|
||||
getCaptchaCookie,
|
||||
refreshCaptcha,
|
||||
resetCaptcha,
|
||||
} from 'Store/Actions/captchaActions';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import FormInputButton from './FormInputButton';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './CaptchaInput.css';
|
||||
|
||||
interface CaptchaInputProps {
|
||||
className?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
provider: string;
|
||||
providerData: object;
|
||||
hasError?: boolean;
|
||||
hasWarning?: boolean;
|
||||
refreshing: boolean;
|
||||
siteKey?: string;
|
||||
secretToken?: string;
|
||||
onChange: (change: InputChanged<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;
|
||||
@@ -1,98 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions';
|
||||
import CaptchaInput from './CaptchaInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.captcha,
|
||||
(captcha) => {
|
||||
return captcha;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
refreshCaptcha,
|
||||
getCaptchaCookie,
|
||||
resetCaptcha
|
||||
};
|
||||
|
||||
class CaptchaInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
name,
|
||||
token,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
if (token && token !== prevProps.token) {
|
||||
onChange({ name, value: token });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
this.props.resetCaptcha();
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRefreshPress = () => {
|
||||
const {
|
||||
provider,
|
||||
providerData
|
||||
} = this.props;
|
||||
|
||||
this.props.refreshCaptcha({ provider, providerData });
|
||||
};
|
||||
|
||||
onCaptchaChange = (captchaResponse) => {
|
||||
// If the captcha has expired `captchaResponse` will be null.
|
||||
// In the event it's null don't try to get the captchaCookie.
|
||||
// TODO: Should we clear the cookie? or reset the captcha?
|
||||
|
||||
if (!captchaResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
provider,
|
||||
providerData
|
||||
} = this.props;
|
||||
|
||||
this.props.getCaptchaCookie({ provider, providerData, captchaResponse });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<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);
|
||||
@@ -1,191 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
import styles from './CheckInput.css';
|
||||
|
||||
class CheckInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._checkbox = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setIndeterminate();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.setIndeterminate();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
setIndeterminate() {
|
||||
if (!this._checkbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
value,
|
||||
uncheckedValue,
|
||||
checkedValue
|
||||
} = this.props;
|
||||
|
||||
this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
|
||||
}
|
||||
|
||||
toggleChecked = (checked, shiftKey) => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
checkedValue,
|
||||
uncheckedValue
|
||||
} = this.props;
|
||||
|
||||
const newValue = checked ? checkedValue : uncheckedValue;
|
||||
|
||||
if (value !== newValue) {
|
||||
this.props.onChange({
|
||||
name,
|
||||
value: newValue,
|
||||
shiftKey
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
setRef = (ref) => {
|
||||
this._checkbox = ref;
|
||||
};
|
||||
|
||||
onClick = (event) => {
|
||||
if (this.props.isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shiftKey = event.nativeEvent.shiftKey;
|
||||
const checked = !this._checkbox.checked;
|
||||
|
||||
event.preventDefault();
|
||||
this.toggleChecked(checked, shiftKey);
|
||||
};
|
||||
|
||||
onChange = (event) => {
|
||||
const checked = event.target.checked;
|
||||
const shiftKey = event.nativeEvent.shiftKey;
|
||||
|
||||
this.toggleChecked(checked, shiftKey);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
containerClassName,
|
||||
name,
|
||||
value,
|
||||
checkedValue,
|
||||
uncheckedValue,
|
||||
helpText,
|
||||
helpTextWarning,
|
||||
isDisabled,
|
||||
kind
|
||||
} = this.props;
|
||||
|
||||
const isChecked = value === checkedValue;
|
||||
const isUnchecked = value === uncheckedValue;
|
||||
const isIndeterminate = !isChecked && !isUnchecked;
|
||||
const isCheckClass = `${kind}IsChecked`;
|
||||
|
||||
return (
|
||||
<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;
|
||||
141
frontend/src/Components/Form/CheckInput.tsx
Normal file
141
frontend/src/Components/Form/CheckInput.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { SyntheticEvent, useCallback, useEffect, useRef } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
import styles from './CheckInput.css';
|
||||
|
||||
interface ChangeEvent<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;
|
||||
@@ -1,106 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import tagShape from 'Helpers/Props/Shapes/tagShape';
|
||||
import FormInputButton from './FormInputButton';
|
||||
import TagInput from './TagInput';
|
||||
import styles from './DeviceInput.css';
|
||||
|
||||
class DeviceInput extends Component {
|
||||
|
||||
onTagAdd = (device) => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
// New tags won't have an ID, only a name.
|
||||
const deviceId = device.id || device.name;
|
||||
|
||||
onChange({
|
||||
name,
|
||||
value: [...value, deviceId]
|
||||
});
|
||||
};
|
||||
|
||||
onTagDelete = ({ index }) => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
const newValue = value.slice();
|
||||
newValue.splice(index, 1);
|
||||
|
||||
onChange({
|
||||
name,
|
||||
value: newValue
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
name,
|
||||
items,
|
||||
selectedDevices,
|
||||
hasError,
|
||||
hasWarning,
|
||||
isFetching,
|
||||
onRefreshPress
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -1,104 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
|
||||
import DeviceInput from './DeviceInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { value }) => value,
|
||||
(state) => state.providerOptions.devices || defaultState,
|
||||
(value, devices) => {
|
||||
|
||||
return {
|
||||
...devices,
|
||||
selectedDevices: value.map((valueDevice) => {
|
||||
// Disable equality ESLint rule so we don't need to worry about
|
||||
// a type mismatch between the value items and the device ID.
|
||||
// eslint-disable-next-line eqeqeq
|
||||
const device = devices.items.find((d) => d.id == valueDevice);
|
||||
|
||||
if (device) {
|
||||
return {
|
||||
id: device.id,
|
||||
name: `${device.name} (${device.id})`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: valueDevice,
|
||||
name: `Unknown (${valueDevice})`
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchOptions: fetchOptions,
|
||||
dispatchClearOptions: clearOptions
|
||||
};
|
||||
|
||||
class DeviceInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount = () => {
|
||||
this._populate();
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
this.props.dispatchClearOptions({ section: 'devices' });
|
||||
};
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_populate() {
|
||||
const {
|
||||
provider,
|
||||
providerData,
|
||||
dispatchFetchOptions
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchOptions({
|
||||
section: 'devices',
|
||||
action: 'getDevices',
|
||||
provider,
|
||||
providerData
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRefreshPress = () => {
|
||||
this._populate();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<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);
|
||||
@@ -1,102 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.downloadClients,
|
||||
(state, { includeAny }) => includeAny,
|
||||
(state, { protocol }) => protocol,
|
||||
(downloadClients, includeAny, protocolFilter) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = downloadClients;
|
||||
|
||||
const filteredItems = items.filter((item) => item.protocol === protocolFilter);
|
||||
|
||||
const values = _.map(filteredItems.sort(sortByProp('name')), (downloadClient) => {
|
||||
return {
|
||||
key: downloadClient.id,
|
||||
value: downloadClient.name,
|
||||
hint: `(${downloadClient.id})`
|
||||
};
|
||||
});
|
||||
|
||||
if (includeAny) {
|
||||
values.unshift({
|
||||
key: 0,
|
||||
value: `(${translate('Any')})`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
values
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchDownloadClients: fetchDownloadClients
|
||||
};
|
||||
|
||||
class DownloadClientSelectInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.isPopulated) {
|
||||
this.props.dispatchFetchDownloadClients();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = ({ name, value }) => {
|
||||
this.props.onChange({ name, value: parseInt(value) });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<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);
|
||||
@@ -1,614 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Measure from 'Components/Measure';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import Portal from 'Components/Portal';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import { icons, scrollDirections, sizes } from 'Helpers/Props';
|
||||
import { isMobile as isMobileUtil } from 'Utilities/browser';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import getUniqueElememtId from 'Utilities/getUniqueElementId';
|
||||
import HintedSelectInputOption from './HintedSelectInputOption';
|
||||
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './EnhancedSelectInput.css';
|
||||
|
||||
function isArrowKey(keyCode) {
|
||||
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
|
||||
}
|
||||
|
||||
function getSelectedOption(selectedIndex, values) {
|
||||
return values[selectedIndex];
|
||||
}
|
||||
|
||||
function findIndex(startingIndex, direction, values) {
|
||||
let indexToTest = startingIndex + direction;
|
||||
|
||||
while (indexToTest !== startingIndex) {
|
||||
if (indexToTest < 0) {
|
||||
indexToTest = values.length - 1;
|
||||
} else if (indexToTest >= values.length) {
|
||||
indexToTest = 0;
|
||||
}
|
||||
|
||||
if (getSelectedOption(indexToTest, values).isDisabled) {
|
||||
indexToTest = indexToTest + direction;
|
||||
} else {
|
||||
return indexToTest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function previousIndex(selectedIndex, values) {
|
||||
return findIndex(selectedIndex, -1, values);
|
||||
}
|
||||
|
||||
function nextIndex(selectedIndex, values) {
|
||||
return findIndex(selectedIndex, 1, values);
|
||||
}
|
||||
|
||||
function getSelectedIndex(props) {
|
||||
const {
|
||||
value,
|
||||
values
|
||||
} = props;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return values.findIndex((v) => {
|
||||
return value.size && v.key === value[0];
|
||||
});
|
||||
}
|
||||
|
||||
return values.findIndex((v) => {
|
||||
return v.key === value;
|
||||
});
|
||||
}
|
||||
|
||||
function isSelectedItem(index, props) {
|
||||
const {
|
||||
value,
|
||||
values
|
||||
} = props;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes(values[index].key);
|
||||
}
|
||||
|
||||
return values[index].key === value;
|
||||
}
|
||||
|
||||
function getKey(selectedIndex, values) {
|
||||
return values[selectedIndex].key;
|
||||
}
|
||||
|
||||
class EnhancedSelectInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scheduleUpdate = null;
|
||||
this._buttonId = getUniqueElememtId();
|
||||
this._optionsId = getUniqueElememtId();
|
||||
|
||||
this.state = {
|
||||
isOpen: false,
|
||||
selectedIndex: getSelectedIndex(props),
|
||||
width: 0,
|
||||
isMobile: isMobileUtil()
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this._scheduleUpdate) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
|
||||
if (!Array.isArray(this.props.value)) {
|
||||
if (prevProps.value !== this.props.value || prevProps.values !== this.props.values) {
|
||||
this.setState({
|
||||
selectedIndex: getSelectedIndex(this.props)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_addListener() {
|
||||
window.addEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
_removeListener() {
|
||||
window.removeEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onComputeMaxHeight = (data) => {
|
||||
const {
|
||||
top,
|
||||
bottom
|
||||
} = data.offsets.reference;
|
||||
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if ((/^botton/).test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
onWindowClick = (event) => {
|
||||
const button = document.getElementById(this._buttonId);
|
||||
const options = document.getElementById(this._optionsId);
|
||||
|
||||
if (!button || !event.target.isConnected || this.state.isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!button.contains(event.target) &&
|
||||
options &&
|
||||
!options.contains(event.target) &&
|
||||
this.state.isOpen
|
||||
) {
|
||||
this.setState({ isOpen: false });
|
||||
this._removeListener();
|
||||
}
|
||||
};
|
||||
|
||||
onFocus = () => {
|
||||
if (this.state.isOpen) {
|
||||
this._removeListener();
|
||||
this.setState({ isOpen: false });
|
||||
}
|
||||
};
|
||||
|
||||
onBlur = () => {
|
||||
if (!this.props.isEditable) {
|
||||
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
|
||||
const origIndex = getSelectedIndex(this.props);
|
||||
|
||||
if (origIndex !== this.state.selectedIndex) {
|
||||
this.setState({ selectedIndex: origIndex });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onKeyDown = (event) => {
|
||||
const {
|
||||
values
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
selectedIndex
|
||||
} = this.state;
|
||||
|
||||
const keyCode = event.keyCode;
|
||||
const newState = {};
|
||||
|
||||
if (!isOpen) {
|
||||
if (isArrowKey(keyCode)) {
|
||||
event.preventDefault();
|
||||
newState.isOpen = true;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedIndex == null || selectedIndex === -1 ||
|
||||
getSelectedOption(selectedIndex, values).isDisabled
|
||||
) {
|
||||
if (keyCode === keyCodes.UP_ARROW) {
|
||||
newState.selectedIndex = previousIndex(0, values);
|
||||
} else if (keyCode === keyCodes.DOWN_ARROW) {
|
||||
newState.selectedIndex = nextIndex(values.length - 1, values);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.UP_ARROW) {
|
||||
event.preventDefault();
|
||||
newState.selectedIndex = previousIndex(selectedIndex, values);
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.DOWN_ARROW) {
|
||||
event.preventDefault();
|
||||
newState.selectedIndex = nextIndex(selectedIndex, values);
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.ENTER) {
|
||||
event.preventDefault();
|
||||
newState.isOpen = false;
|
||||
this.onSelect(getKey(selectedIndex, values));
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.TAB) {
|
||||
newState.isOpen = false;
|
||||
this.onSelect(getKey(selectedIndex, values));
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.ESCAPE) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
newState.isOpen = false;
|
||||
newState.selectedIndex = getSelectedIndex(this.props);
|
||||
}
|
||||
|
||||
if (!_.isEmpty(newState)) {
|
||||
this.setState(newState);
|
||||
}
|
||||
};
|
||||
|
||||
onPress = () => {
|
||||
if (this.state.isOpen) {
|
||||
this._removeListener();
|
||||
} else {
|
||||
this._addListener();
|
||||
}
|
||||
|
||||
if (!this.state.isOpen && this.props.onOpen) {
|
||||
this.props.onOpen();
|
||||
}
|
||||
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
};
|
||||
|
||||
onSelect = (newValue) => {
|
||||
const { name, value, values, onChange } = this.props;
|
||||
const additionalProperties = values.find((v) => v.key === newValue)?.additionalProperties;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let arrayValue = null;
|
||||
const index = value.indexOf(newValue);
|
||||
|
||||
if (index === -1) {
|
||||
arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v));
|
||||
} else {
|
||||
arrayValue = [...value];
|
||||
arrayValue.splice(index, 1);
|
||||
}
|
||||
onChange({
|
||||
name,
|
||||
value: arrayValue,
|
||||
additionalProperties
|
||||
});
|
||||
} else {
|
||||
this.setState({ isOpen: false });
|
||||
|
||||
onChange({
|
||||
name,
|
||||
value: newValue,
|
||||
additionalProperties
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({ width });
|
||||
};
|
||||
|
||||
onOptionsModalClose = () => {
|
||||
this.setState({ isOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
disabledClassName,
|
||||
name,
|
||||
value,
|
||||
values,
|
||||
isDisabled,
|
||||
isEditable,
|
||||
isFetching,
|
||||
hasError,
|
||||
hasWarning,
|
||||
valueOptions,
|
||||
selectedValueOptions,
|
||||
selectedValueComponent: SelectedValueComponent,
|
||||
optionComponent: OptionComponent,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
selectedIndex,
|
||||
width,
|
||||
isOpen,
|
||||
isMobile
|
||||
} = this.state;
|
||||
|
||||
const isMultiSelect = Array.isArray(value);
|
||||
const selectedOption = getSelectedOption(selectedIndex, values);
|
||||
let selectedValue = value;
|
||||
|
||||
if (!values.length) {
|
||||
selectedValue = isMultiSelect ? [] : '';
|
||||
}
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -1,162 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
const importantFieldNames = [
|
||||
'baseUrl',
|
||||
'apiPath',
|
||||
'apiKey',
|
||||
'authToken'
|
||||
];
|
||||
|
||||
function getProviderDataKey(providerData) {
|
||||
if (!providerData || !providerData.fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = providerData.fields
|
||||
.filter((f) => importantFieldNames.includes(f.name))
|
||||
.map((f) => f.value);
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function getSelectOptions(items) {
|
||||
if (!items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.map((option) => {
|
||||
return {
|
||||
key: option.value,
|
||||
value: option.name,
|
||||
hint: option.hint,
|
||||
parentKey: option.parentValue,
|
||||
isDisabled: option.isDisabled,
|
||||
additionalProperties: option.additionalProperties
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState,
|
||||
(options) => {
|
||||
if (options) {
|
||||
return {
|
||||
isFetching: options.isFetching,
|
||||
values: getSelectOptions(options.items)
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchOptions: fetchOptions,
|
||||
dispatchClearOptions: clearOptions
|
||||
};
|
||||
|
||||
class EnhancedSelectInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
refetchRequired: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
this._populate();
|
||||
};
|
||||
|
||||
componentDidUpdate = (prevProps) => {
|
||||
const prevKey = getProviderDataKey(prevProps.providerData);
|
||||
const nextKey = getProviderDataKey(this.props.providerData);
|
||||
|
||||
if (!_.isEqual(prevKey, nextKey)) {
|
||||
this.setState({ refetchRequired: true });
|
||||
}
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
this._cleanup();
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onOpen = () => {
|
||||
if (this.state.refetchRequired) {
|
||||
this._populate();
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_populate() {
|
||||
const {
|
||||
provider,
|
||||
providerData,
|
||||
selectOptionsProviderAction,
|
||||
dispatchFetchOptions
|
||||
} = this.props;
|
||||
|
||||
if (selectOptionsProviderAction) {
|
||||
this.setState({ refetchRequired: false });
|
||||
dispatchFetchOptions({
|
||||
section: selectOptionsProviderAction,
|
||||
action: selectOptionsProviderAction,
|
||||
provider,
|
||||
providerData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
const {
|
||||
selectOptionsProviderAction,
|
||||
dispatchClearOptions
|
||||
} = this.props;
|
||||
|
||||
if (selectOptionsProviderAction) {
|
||||
dispatchClearOptions({ section: selectOptionsProviderAction });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<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);
|
||||
@@ -1,113 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import CheckInput from './CheckInput';
|
||||
import styles from './EnhancedSelectInputOption.css';
|
||||
|
||||
class EnhancedSelectInputOption extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const {
|
||||
id,
|
||||
onSelect
|
||||
} = this.props;
|
||||
|
||||
onSelect(id);
|
||||
};
|
||||
|
||||
onCheckPress = () => {
|
||||
// CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation.
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
id,
|
||||
depth,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
isHidden,
|
||||
isMultiSelect,
|
||||
isMobile,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -1,35 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './EnhancedSelectInputSelectedValue.css';
|
||||
|
||||
function EnhancedSelectInputSelectedValue(props) {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
isDisabled
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -1,66 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './Form.css';
|
||||
|
||||
function Form(props) {
|
||||
const {
|
||||
children,
|
||||
validationErrors,
|
||||
validationWarnings,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<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;
|
||||
45
frontend/src/Components/Form/Form.tsx
Normal file
45
frontend/src/Components/Form/Form.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { ValidationError, ValidationWarning } from 'typings/pending';
|
||||
import styles from './Form.css';
|
||||
|
||||
export interface FormProps {
|
||||
children: ReactNode;
|
||||
validationErrors?: ValidationError[];
|
||||
validationWarnings?: ValidationWarning[];
|
||||
}
|
||||
|
||||
function Form({
|
||||
children,
|
||||
validationErrors = [],
|
||||
validationWarnings = [],
|
||||
}: FormProps) {
|
||||
return (
|
||||
<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;
|
||||
@@ -1,56 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { map } from 'Helpers/elementChildren';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import styles from './FormGroup.css';
|
||||
|
||||
function FormGroup(props) {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
size,
|
||||
advancedSettings,
|
||||
isAdvanced,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
if (!advancedSettings && isAdvanced) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childProps = isAdvanced ? { isAdvanced } : {};
|
||||
|
||||
return (
|
||||
<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;
|
||||
43
frontend/src/Components/Form/FormGroup.tsx
Normal file
43
frontend/src/Components/Form/FormGroup.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { Children, ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import styles from './FormGroup.css';
|
||||
|
||||
interface FormGroupProps extends ComponentPropsWithoutRef<'div'> {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
size?: Extract<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;
|
||||
@@ -1,54 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './FormInputButton.css';
|
||||
|
||||
function FormInputButton(props) {
|
||||
const {
|
||||
className,
|
||||
canSpin,
|
||||
isLastButton,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
if (canSpin) {
|
||||
return (
|
||||
<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;
|
||||
38
frontend/src/Components/Form/FormInputButton.tsx
Normal file
38
frontend/src/Components/Form/FormInputButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
@@ -1,307 +0,0 @@
|
||||
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;
|
||||
292
frontend/src/Components/Form/FormInputGroup.tsx
Normal file
292
frontend/src/Components/Form/FormInputGroup.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
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;
|
||||
@@ -1,74 +0,0 @@
|
||||
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;
|
||||
55
frontend/src/Components/Form/FormInputHelpText.tsx
Normal file
55
frontend/src/Components/Form/FormInputHelpText.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
42
frontend/src/Components/Form/FormLabel.tsx
Normal file
42
frontend/src/Components/Form/FormLabel.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
@@ -1,66 +0,0 @@
|
||||
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;
|
||||
@@ -1,68 +0,0 @@
|
||||
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;
|
||||
@@ -1,97 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { 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);
|
||||
@@ -1,21 +0,0 @@
|
||||
.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';
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
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;
|
||||
@@ -1,28 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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;
|
||||
@@ -1,52 +0,0 @@
|
||||
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);
|
||||
@@ -1,55 +0,0 @@
|
||||
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;
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
@@ -1,126 +0,0 @@
|
||||
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;
|
||||
108
frontend/src/Components/Form/NumberInput.tsx
Normal file
108
frontend/src/Components/Form/NumberInput.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
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;
|
||||
@@ -1,39 +0,0 @@
|
||||
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;
|
||||
72
frontend/src/Components/Form/OAuthInput.tsx
Normal file
72
frontend/src/Components/Form/OAuthInput.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user