Compare commits

...

188 Commits

Author SHA1 Message Date
Alejandro Celaya
44aca4aeda Merge pull request #192 from acelaya-forks/feature/version-constraints
Feature/version constraints
2020-01-19 21:37:56 +01:00
Alejandro Celaya
5762342d6c Ensured edit meta menu item is only displayed when shlink v1.18 or greater is run 2020-01-19 21:30:01 +01:00
Alejandro Celaya
2236ed467e Ensured date range filtering is only displayed if Shlink v1.21 ow higer is run 2020-01-19 21:25:45 +01:00
Alejandro Celaya
d244b830ac Updated date on license file 2020-01-19 21:20:32 +01:00
Alejandro Celaya
e89b68fe1e Merge pull request #190 from acelaya-forks/feature/edit-meta
Feature/edit meta
2020-01-19 21:15:25 +01:00
Alejandro Celaya
1f588c5b13 Updated changelog with v2.3.0 2020-01-19 21:00:31 +01:00
Alejandro Celaya
38cad143a0 Created EditMetaModal test 2020-01-19 20:59:01 +01:00
Alejandro Celaya
f52bcc5389 Ensured state is reset on edit meta modal after closing it 2020-01-19 20:37:12 +01:00
Alejandro Celaya
caa6f7bcd8 Created shortUrlMetaReducer test 2020-01-19 20:21:59 +01:00
Alejandro Celaya
207a8cef20 Updated tests from modified code 2020-01-19 13:20:46 +01:00
Alejandro Celaya
d44a4b260e Finished component to allow metadata to be edited on existing short URLs 2020-01-19 13:07:33 +01:00
Alejandro Celaya
80a8e0b55c Created component to edit short URLs meta 2020-01-17 21:07:59 +01:00
Alejandro Celaya
2d60f830f7 Improved icons on short URL menu 2020-01-15 20:25:58 +01:00
Alejandro Celaya
90751a09f7 Merge pull request #188 from acelaya-forks/feature/visits-amount
Feature/visits amount
2020-01-15 18:42:09 +01:00
Alejandro Celaya
301da4bb2a Recovered behavior to show amount of visits in selected date range on visits detail page 2020-01-15 18:31:28 +01:00
Alejandro Celaya
c90cd46095 Removed old ExternalLink component in favor of external one 2020-01-15 18:16:12 +01:00
Alejandro Celaya
7826000384 Merge pull request #187 from acelaya-forks/feature/date-filter
Feature/date filter
2020-01-14 20:59:01 +01:00
Alejandro Celaya
b48dcdd5e1 Fixed wrong files being picked for mutations 2020-01-14 20:48:01 +01:00
Alejandro Celaya
4f6326b139 Updated changelog 2020-01-14 20:21:14 +01:00
Alejandro Celaya
cff96eeccc Created DateRangeRow test 2020-01-14 20:20:27 +01:00
Alejandro Celaya
5eb4a3adec Fixed tests and typos 2020-01-14 20:12:30 +01:00
Alejandro Celaya
b60908a5e9 Added filtering by date to short URLs list 2020-01-14 19:59:25 +01:00
Alejandro Celaya
124441238b Moved style to the proper scope 2020-01-12 12:08:26 +01:00
Alejandro Celaya
4ec0287a74 Merge pull request #185 from Starbix/patch-2
Update nginx base image
2020-01-12 11:01:38 +01:00
Cédric Laubacher
05c67a5c99 Update nginx base image 2020-01-12 10:50:40 +01:00
Alejandro Celaya
f507a3628c Merge pull request #184 from acelaya-forks/feature/show-max-visits
Feature/show max visits
2020-01-11 20:23:02 +01:00
Alejandro Celaya
89e9d2b2d1 Fixed accidentally refactored string 2020-01-11 20:11:41 +01:00
Alejandro Celaya
595858ac4b Used visits count component in short URL visits view 2020-01-11 20:10:12 +01:00
Alejandro Celaya
3f2162fe62 Extracted visits count component to reuse it in other places 2020-01-11 19:58:04 +01:00
Alejandro Celaya
f2cb30409a Updated changelog 2020-01-11 19:41:41 +01:00
Alejandro Celaya
5c4fec5a2f Displayed amount of max visits on those URLs which have it 2020-01-11 19:40:16 +01:00
Alejandro Celaya
e96c119432 Merge pull request #183 from acelaya-forks/feature/support-shlink-2
Feature/support shlink 2
2020-01-11 14:25:25 +01:00
Alejandro Celaya
0920962d72 Used already unpacked property 2020-01-11 14:16:23 +01:00
Alejandro Celaya
aaeb0fff78 Updated changelog 2020-01-11 14:13:58 +01:00
Alejandro Celaya
de41f50945 Ensured preview menu item is hidden when consuming Shlink 2 2020-01-11 14:12:58 +01:00
Alejandro Celaya
0f51bf95e3 Updated ShlinkApiClient so that it retries API version when v2 is not supported 2020-01-11 13:55:37 +01:00
Alejandro Celaya
ba8cade6fc When handling API errors, use the type prop and fallback to error if not found 2020-01-11 12:24:45 +01:00
Alejandro Celaya
dbefae5a01 Merge pull request #173 from Starbix/patch-1
Update baseimages
2019-11-21 11:35:49 +01:00
Cédric Laubacher
727b219742 Update nginx image to latest version 2019-11-21 07:55:40 +01:00
Alejandro Celaya
fb25e44b58 Used strict version number for nginx base image 2019-11-21 07:52:50 +01:00
Cédric Laubacher
fe2d394831 Update baseimages
Nginx can be set to the latest patch version, as its API is really stable.
2019-11-16 10:24:34 +01:00
Alejandro Celaya
efd08ff1d6 Updated changelog 2019-11-10 13:04:15 +01:00
Alejandro Celaya
4b861a5376 Removed profanity 2019-11-10 08:47:13 +01:00
Alejandro Celaya
2076e7d5e8 Merge pull request #171 from MartinH0/master
Updated FavIcons
2019-11-10 08:44:59 +01:00
MartinH0
37f6f1f90c rename to favicon 2019-11-09 22:34:41 +01:00
MartinH0
81f76e0bd6 SVG FavIcon Fix
Added sizes="any" to the svg FavIcon
2019-11-09 22:32:13 +01:00
MartinH0
69b305cd8a Added SVG FavIcon
Added SVG FavIcon as File in Rootdirectory
2019-11-09 22:29:37 +01:00
MartinH0
45742a066e Added SVG FavIcon 2019-11-09 22:28:44 +01:00
MartinH0
86fb8b3f7c Added FavIcons
Added .gif and .png FavIcon
2019-11-09 19:38:50 +01:00
MartinH0
9c0fc8e1d2 Adjusted to FavIcons
Added MS Icons, Added all Apple Icons, Added all normal Icons and standard FavIcons
2019-11-09 19:37:31 +01:00
MartinH0
10d6302180 Added all FavIcons to Manifest
Added all missing FavIcons to the manifest
2019-11-09 19:22:55 +01:00
MartinH0
da7ed6992f FavIcon
Overwrite FavIcon with 128x128 new optimized FavIcon
2019-11-09 19:17:45 +01:00
MartinH0
32c9375ac8 FavIcons edits
Added new FavIcons and overwrite old ones
2019-11-09 19:16:12 +01:00
MartinH0
7ed1334a51 Updated FavIcons
Added all Versions of all available FavIcons.
Also Added normal FavIcons additionally to the apple ones.
Also added the "msapplication-TileImage" meta for Microsoft.
2019-11-07 21:47:57 +01:00
Alejandro Celaya
d9097896f6 Added github funding 2019-10-22 19:33:59 +02:00
Alejandro Celaya
359b16e700 Merge pull request #168 from acelaya-forks/feature/default-servers-issue
Feature/default servers issue
2019-10-21 19:52:31 +02:00
Alejandro Celaya
0237af771d Fixed outdated comment 2019-10-21 19:45:35 +02:00
Alejandro Celaya
86cce5b205 Updated changelog 2019-10-21 19:39:59 +02:00
Alejandro Celaya
fc7a2e0c6d Ensured response from servers.json has been parsed to a json array 2019-10-21 19:38:32 +02:00
Alejandro Celaya
f74d135922 Ensured default servers is validated as JSON and ignored otherwise 2019-10-21 19:26:09 +02:00
Alejandro Celaya
66124370a6 Added json extension to the list of known static files that have to fall back to 404 on nginx 2019-10-21 18:49:47 +02:00
Alejandro Celaya
e9fc2bb73a Merge pull request #166 from acelaya-forks/feature/fix-create-short-url
Ensured server version is properly parsed to avoid errors due to inva…
2019-10-18 17:48:02 +02:00
Alejandro Celaya
12f6b94ece Ensured server version is properly parsed to avoid errors due to invalid semver 2019-10-18 17:39:38 +02:00
Alejandro Celaya
d9a8243d36 Merge pull request #163 from acelaya-forks/feature/update-deps
Feature/update deps
2019-10-05 20:09:22 +02:00
Alejandro Celaya
232c54885e Updated node version in which builds are run 2019-10-05 19:58:27 +02:00
Alejandro Celaya
42c43f6c78 Added v2.2 to changelog 2019-10-05 19:54:10 +02:00
Alejandro Celaya
9d2494834c Fixed timing issue when navigating to another server 2019-10-05 19:51:50 +02:00
Alejandro Celaya
a7613435ea Fixed test throwing unhandled promise 2019-10-05 19:31:47 +02:00
Alejandro Celaya
c9df044e1a Updated docker image versions 2019-10-05 19:26:06 +02:00
Alejandro Celaya
5a37787042 Fixed warnings in tests 2019-10-05 19:13:57 +02:00
Alejandro Celaya
923cc3ba01 Updated dev dependencies 2019-10-05 19:08:50 +02:00
Alejandro Celaya
8fcf72f564 Updated production dependencies to latest versions 2019-10-05 18:50:49 +02:00
Alejandro Celaya
a7f7666ccd Merge pull request #162 from acelaya-forks/feature/domain
Feature/domain
2019-10-05 11:15:09 +02:00
Alejandro Celaya
c181948afe Updated changelog 2019-10-05 11:05:03 +02:00
Alejandro Celaya
ce9ecd7b93 Defined custom function to compare versions which defines the operator in the middle 2019-10-05 11:03:17 +02:00
Alejandro Celaya
354d19af1b Disabled domain component for Shlink versions not supporting it 2019-10-05 10:54:58 +02:00
Alejandro Celaya
6d996baf5d Added tests for new logics 2019-10-05 10:40:32 +02:00
Alejandro Celaya
4120d09220 Loaded version of selected server and created component to filter content based on that version 2019-10-05 10:20:33 +02:00
Alejandro Celaya
67a23bfe33 Added domain input to create short url form 2019-10-05 09:02:02 +02:00
Alejandro Celaya
08b710930d Merge pull request #161 from acelaya-forks/feature/further-issue-template-improvements
Solved inconsistencies in issue templates due to copy-pasting from ot…
2019-09-29 09:46:57 +02:00
Alejandro Celaya
7ec3b332ed Solved inconsistencies in issue templates due to copy-pasting from other project 2019-09-29 09:46:19 +02:00
Alejandro Celaya
722eb060f0 Merge pull request #160 from acelaya-forks/feature/improved-issue-templates
Added improved issue templates and funding config
2019-09-29 09:42:45 +02:00
Alejandro Celaya
ce740aed68 Added improved issue templates and funding config 2019-09-29 09:36:57 +02:00
Alejandro Celaya
09f582daa1 Merge pull request #159 from acelaya-forks/feature/fix-docker-image-reload
Added nginx congif which ensures client-side paths are served as the …
2019-09-22 12:02:18 +02:00
Alejandro Celaya
1b5f7b0d76 Added nginx congif which ensures client-side paths are served as the index.html 2019-09-22 11:55:21 +02:00
Alejandro Celaya
2c93e9a587 Merge pull request #158 from acelaya-forks/feature/improved-pagination
Feature/improved pagination
2019-09-22 11:44:54 +02:00
Alejandro Celaya
ab0976981b Fixed style files being excluded when finding what files to mutate 2019-09-22 11:35:52 +02:00
Alejandro Celaya
959ce42137 Updated changelog 2019-09-22 11:16:16 +02:00
Alejandro Celaya
1c25db9179 Created SimplePaginator test 2019-09-22 11:14:08 +02:00
Alejandro Celaya
810ddd7717 Added foldable pagination to SimplePaginator 2019-09-22 10:41:31 +02:00
Alejandro Celaya
7bbff114a4 Extracted paginator used in SortableBarGraph to its own component 2019-09-21 18:29:58 +02:00
Alejandro Celaya
99475fc311 Merge pull request #154 from Haocen/151
Fix an inaccurate variable name in test
2019-09-15 15:26:17 +02:00
Haocen Xu
df121eb294 Fix an inaccurate variable name in test 2019-09-15 09:14:00 -04:00
Alejandro Celaya
138194a149 Merge pull request #153 from Haocen/151
When no order is specified, the order by indicator(triangle) in column header should be Cleared
2019-09-15 09:30:11 +02:00
Haocen Xu
ab99213d8c When no order is specified, the order by indicator(triangle) in column header should be Cleared 2019-09-14 18:13:15 -04:00
Alejandro Celaya
2fe923678e Installed react-external-links 2019-08-29 17:47:18 +02:00
Alejandro Celaya
34f194c714 Merge pull request #143 from acelaya-forks/feature/docker-versions-bump
Feature/docker versions bump
2019-08-24 16:42:22 +02:00
Alejandro Celaya
2bef398d4c Added repository and license fields to package.json 2019-08-24 16:38:44 +02:00
Alejandro Celaya
404b5c45dd Updated changelog 2019-08-24 16:33:23 +02:00
Alejandro Celaya
f607ade508 Bumbed version of docker images 2019-08-24 16:31:54 +02:00
Alejandro Celaya
158ed84ec5 Updated changelog 2019-05-19 20:31:57 +02:00
Alejandro Celaya
7c22713d7d Merge pull request #139 from acelaya/feature/coverage-80
Created tags list reducer test
2019-05-19 20:30:13 +02:00
Alejandro Celaya
fb94077260 Created shortUrlTags reducer test 2019-05-19 13:22:16 +02:00
Alejandro Celaya
d3491869bd Created tags list reducer test 2019-05-19 12:54:19 +02:00
Alejandro Celaya
5cefadbf37 Added missing link in changelog to docs on new feature 2019-05-19 11:42:24 +02:00
Alejandro Celaya
95462b0c1d Merge pull request #137 from acelaya/feature/pre-configure-servers
Feature/pre configure servers
2019-04-28 18:01:46 +02:00
Alejandro Celaya
258330f985 Mentioned that pre-configured servers won't work on versions previous to 2.1.0 2019-04-28 17:53:35 +02:00
Alejandro Celaya
a09b661b51 Updated changelog 2019-04-28 17:42:20 +02:00
Alejandro Celaya
a1a0b935c7 Improved documentation mentioning how to pre-configure servers 2019-04-28 17:41:01 +02:00
Alejandro Celaya
4c11d9c6d5 Catched error when loading servers from a servers.json file 2019-04-28 13:07:55 +02:00
Alejandro Celaya
78c34a342d Added tests for new use cases 2019-04-28 12:40:50 +02:00
Alejandro Celaya
20820c47d4 Updated list servers action so that it tries to fetch servers from the servers.json file when no local servers are found 2019-04-28 12:07:09 +02:00
Alejandro Celaya
502c8a7e02 Echoing travis commit range 2019-04-22 19:13:02 +02:00
Alejandro Celaya
ce8a198acd Merge pull request #136 from acelaya/feature/stryker
Feature/stryker
2019-04-22 10:11:32 +02:00
Alejandro Celaya
32f171d861 Updated travis to run mutations on changed files only 2019-04-22 10:04:41 +02:00
Alejandro Celaya
b83c0e0aba Improved stryker config 2019-04-21 23:18:35 +02:00
Alejandro Celaya
831c0444d6 Added stryker to the project 2019-04-21 23:03:53 +02:00
Alejandro Celaya
e5ef2eb5c6 Merge pull request #135 from acelaya/feature/shlink-client-improvements
Removed duplicated code when building ShlinkApiClient
2019-04-21 11:37:14 +02:00
Alejandro Celaya
7b80d78dc5 Removed duplicated code when building ShlinkApiClient 2019-04-21 11:31:40 +02:00
Alejandro Celaya
48f7103205 Merge pull request #134 from acelaya/feature/jest-mocks
Feature/jest mocks
2019-04-19 13:02:28 +02:00
Alejandro Celaya
bc8de096be Updated changelog 2019-04-19 12:55:41 +02:00
Alejandro Celaya
ba3189fd46 Removed no longer needed constants 2019-04-19 12:54:56 +02:00
Alejandro Celaya
33d67cbe3d Simplified code making it easier to read 2019-04-19 12:52:55 +02:00
Alejandro Celaya
28ca54547e Removed remaining usages of sinon 2019-04-19 12:41:59 +02:00
Alejandro Celaya
f8de069567 First replacements of sinon mocks with jest mocks 2019-04-19 10:29:49 +02:00
Alejandro Celaya
2cd6e52e9c Merge pull request #133 from acelaya/feature/remove-yarn
Replaced yarn by npm
2019-04-14 22:06:59 +02:00
Alejandro Celaya
372d3f17cc Replaced yarn by npm 2019-04-14 21:58:10 +02:00
Alejandro Celaya
92d5b2eb3e Merge pull request #132 from acelaya/feature/issue-template
Created issue template with some reminders
2019-04-11 22:11:12 +02:00
Alejandro Celaya
6be55e30d9 Dockerignored .gihub folder 2019-04-11 22:03:53 +02:00
Alejandro Celaya
fd517ccbe2 Created issue template with some reminders 2019-04-11 22:01:11 +02:00
Alejandro Celaya
c2a34b4079 Merge pull request #127 from acelaya/feature/check-existing
Feature/check existing
2019-03-17 18:41:22 +01:00
Alejandro Celaya
ce0f036bef Created custom react hook that can be used to handle toggles 2019-03-17 18:35:47 +01:00
Alejandro Celaya
977e143b4e Fixed coding styles 2019-03-17 18:24:09 +01:00
Alejandro Celaya
d847ccf0f4 Updated changelog 2019-03-17 18:17:29 +01:00
Alejandro Celaya
7eeed76539 Created UseExistingIfFoundInfoIcon test 2019-03-17 18:15:44 +01:00
Alejandro Celaya
2e452993ff Created Checkbox test 2019-03-17 18:09:10 +01:00
Alejandro Celaya
f4dbd03c7e Added checkbox to control the findIfExists shlink flag 2019-03-17 17:48:05 +01:00
Alejandro Celaya
312c6cd550 Merge pull request #126 from acelaya/feature/redux-actions
Feature/redux actions
2019-03-17 10:31:13 +01:00
Alejandro Celaya
8d9e8565f0 Updated changelog 2019-03-17 10:23:17 +01:00
Alejandro Celaya
d1c10e4895 Removed test cases for the old default on reducers switch statements 2019-03-17 10:17:44 +01:00
Alejandro Celaya
232c059e4f Replaced usages of defaultState by initialState 2019-03-17 10:11:20 +01:00
Alejandro Celaya
5bb9d15e27 Refactored tagEdit reducer to take advantage of redux-actions 2019-03-17 10:07:28 +01:00
Alejandro Celaya
879034c9c6 Refactored tagDelete reducer to take advantage of redux-actions 2019-03-17 10:02:44 +01:00
Alejandro Celaya
740aacbbf1 Refactored tagsList reducer to take advantage of redux-actions 2019-03-17 09:59:26 +01:00
Alejandro Celaya
fcfab79bed Refactored shortUrlDetail reducer to take advantage of redux-actions 2019-03-17 09:38:37 +01:00
Alejandro Celaya
468e34aa3d Refactored shortUrlVisits reducer to take advantage of redux-actions 2019-03-17 09:36:07 +01:00
Alejandro Celaya
7ff7318089 Refactored shortUrlTags reducer to take advantage of redux-actions 2019-03-17 09:32:53 +01:00
Alejandro Celaya
4654bff737 Refactored shortUrlDeletion reducer to takle advantage of redux-actions 2019-03-17 09:27:01 +01:00
Alejandro Celaya
3075ccb4b9 Refactored shortUrlCreation reducer to takle advantage of redux-actions 2019-03-17 09:20:02 +01:00
Alejandro Celaya
4894ab9035 Refactored shortUrlsListParams reducer to takle advantage of redux-actions 2019-03-17 09:15:58 +01:00
Alejandro Celaya
4a09d99322 Refactored shortUrlsList to take advantage of redux-actions 2019-03-17 09:11:37 +01:00
Alejandro Celaya
51b5f6264d Refactored server reducer, removing duplicated code and taking advantage of redux-actions 2019-03-17 09:06:10 +01:00
Alejandro Celaya
724c804971 Installed redux-actions dependency and used it for selectedServer reducer 2019-03-17 08:49:24 +01:00
Alejandro Celaya
2ba86767fe Merge pull request #124 from acelaya/feature/paginated-charts
Feature/paginated charts
2019-03-16 09:11:01 +01:00
Alejandro Celaya
391424d8a1 Ensured bar charts are regenerated when their height changes 2019-03-16 09:02:40 +01:00
Alejandro Celaya
e0db6d5a57 Improved SortableBarGraph test 2019-03-10 17:55:02 +01:00
Alejandro Celaya
87dc24e8a2 Fixed and improved OpenMapModalBtn and ShortUrlVisit components tests 2019-03-10 13:05:20 +01:00
Alejandro Celaya
5233f5a07b Updated OpenMapModalBtn so that it allows showing only active cities 2019-03-10 12:09:54 +01:00
Alejandro Celaya
478ee59bb0 Updated cities chart so that map shows locations from current page when result set is paginated 2019-03-10 10:56:36 +01:00
Alejandro Celaya
b6f6b1ae9d Enabled stickiness on footer 2019-03-10 10:08:42 +01:00
Alejandro Celaya
1ad4290487 Applied some naming improvements 2019-03-10 09:54:40 +01:00
Alejandro Celaya
61480abd2e Updated charts to allow optional pagination 2019-03-10 08:28:14 +01:00
Alejandro Celaya
c094a27c97 Created PaginationDropdown component 2019-03-09 13:20:43 +01:00
Alejandro Celaya
83704ca4b5 Created rangeOf helper function which does a range + map 2019-03-09 12:19:33 +01:00
Alejandro Celaya
60576388c5 Merge pull request #122 from acelaya/feature/cancel-visits-load
Feature/cancel visits load
2019-03-08 19:56:45 +01:00
Alejandro Celaya
9f172c308c Ensured travis makes use of a working node version for builds 2019-03-08 19:50:47 +01:00
Alejandro Celaya
d7312d26f7 Added missing test for new action creator 2019-03-08 19:45:35 +01:00
Alejandro Celaya
4e6ef6ac53 Removed empty line 2019-03-08 19:43:27 +01:00
Alejandro Celaya
de563f9ebf Updated changelog 2019-03-08 19:42:07 +01:00
Alejandro Celaya
3982d77775 Ensured visits loading is cancelled when the visits page is unmounted 2019-03-08 19:40:43 +01:00
Alejandro Celaya
24bbbf6cb1 Merge pull request #121 from acelaya/feature/fix-empty-locations
Feature/fix empty locations
2019-03-05 14:33:33 +01:00
Alejandro Celaya
9ddd5de008 Updated changelog 2019-03-05 14:12:11 +01:00
Alejandro Celaya
87a4598391 Ensured maps modal btn is not rendered when the number of located cities is 0 2019-03-05 14:09:08 +01:00
Alejandro Celaya
701c143149 Updated ErrorHandler so that it logs errors in production 2019-03-05 14:04:52 +01:00
Alejandro Celaya
43097b93e5 Fixed docker build badge 2019-03-05 12:11:18 +01:00
Alejandro Celaya
e303a80683 Updated bootstrap to solve security issue 2019-03-04 21:05:30 +01:00
Alejandro Celaya
5defc20e9f Merge pull request #117 from acelaya/feature/error-handler
Feature/error handler
2019-03-04 20:55:48 +01:00
Alejandro Celaya
d75eff62e3 Updated changelog 2019-03-04 20:50:05 +01:00
Alejandro Celaya
ad9f0c00d0 Created ErrorHandler test 2019-03-04 20:49:18 +01:00
Alejandro Celaya
cd908fa358 Created ErrorHandler component 2019-03-04 20:41:02 +01:00
Alejandro Celaya
2bf79dbc80 Merge pull request #114 from acelaya/feature/lat-lang-error
Fixed crash when trying to load a map with just one location
2019-03-04 20:35:10 +01:00
Alejandro Celaya
4c729a405d Fixed crash when trying to load a map with just one location 2019-03-04 20:24:28 +01:00
Alejandro Celaya
28c9f9ac96 Merge pull request #112 from acelaya/feature/many-visits
Improved performance while calculating status
2019-03-04 19:37:00 +01:00
Alejandro Celaya
2820caf955 Updated changelog 2019-03-04 19:29:56 +01:00
Alejandro Celaya
ba5ea7407b Used native javascript reduce instead of ramda reduce 2019-03-04 19:28:24 +01:00
Alejandro Celaya
1bc406b0d9 Ensured requests when loading visits are made in parallel for big dataset 2019-03-04 19:21:46 +01:00
Alejandro Celaya
7e27ceb885 Ensured same timestamp is used when generating memoization ID after mounting the component 2019-03-04 18:19:50 +01:00
Alejandro Celaya
252edaa2ca Improved performance while calculating status by doing one iteration only and memoizing the result when possible 2019-03-04 18:14:45 +01:00
180 changed files with 22585 additions and 12148 deletions

View File

@@ -1,3 +1,4 @@
./.github
./build
./coverage
./dist

View File

@@ -26,7 +26,9 @@
"no-console": "warn",
"template-curly-spacing": ["error", "never"],
"no-warning-comments": "off",
"no-magic-numbers": "off",
"no-undefined": "off",
"no-inline-comments": "off",
"indent": ["error", 2, {
"SwitchCase": 1
}

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: ['acelaya']
custom: ['https://acel.me/donate']

6
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,6 @@
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

35
.github/ISSUE_TEMPLATE/Bug.md vendored Normal file
View File

@@ -0,0 +1,35 @@
---
name: Bug report
about: Something on shlink is broken or not working as documented?
labels: bug
---
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->
#### Shlink web client version
* Version: x.y.z
* How do you use shlink-web-client: app.shlink.io|Docker image|self-hosted
#### Summary
<!-- Provide a summary describing the problem you are experiencing. -->
#### Current behavior
<!-- How is it actually behaving (and it shouldn't)? -->
#### Expected behavior
<!-- How did you expected to behave? -->
#### How to reproduce
<!-- Provide steps to reproduce the bug. -->

View File

@@ -0,0 +1,18 @@
---
name: Feature request
about: Do you find shlink is missing some important feature that would make it more useful?
labels: feature
---
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->
#### Summary
<!-- Describe the new feature you would like to request. -->

View File

@@ -0,0 +1,23 @@
---
name: Question - Support
about: Do you have a problem setting up or using shlink?
labels: question
---
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->
#### Shlink web client version
* Version: x.y.z
* How do you use shlink-web-client: app.shlink.io|Docker image|self-hosted
#### Summary
<!-- Describe the issue you are facing here. -->

12
.gitignore vendored
View File

@@ -3,18 +3,14 @@
# testing
/coverage
/.stryker-tmp
/reports
# production
/build
# misc
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
docker-compose.override.yml
home
public/servers.json*

View File

@@ -1,5 +1,6 @@
build:
environment:
node: v10.4.1
node: v12.11.0
tools:
external_code_coverage: true
external_code_coverage:
timeout: 1200

View File

@@ -1,10 +1,9 @@
language: node_js
node_js:
- "stable"
- "12.11.0"
cache:
yarn: true
directories:
- node_modules
@@ -12,19 +11,24 @@ services:
- docker
install:
- yarn install
- npm ci
before_script:
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
script:
- yarn lint
- yarn test:ci
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi # Test docker image build only when no tag is present
- npm run lint
- npm run test:ci
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi
- if [[ -z $TRAVIS_TAG ]]; then npm run mutate:ci ; fi
after_success:
- yarn ocular coverage/clover.xml
- node_modules/.bin/ocular coverage/clover.xml
# Before deploying, build dist file for current travis tag
before_deploy:
- yarn build ${TRAVIS_TAG#?}
- npm run build ${TRAVIS_TAG#?}
deploy:
provider: releases

View File

@@ -4,6 +4,202 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## 2.3.0 - 2020-01-19
#### Added
* [#174](https://github.com/shlinkio/shlink-web-client/issues/174) Added complete support for Shlink v2.x together with currently supported Shlink versions.
* [#164](https://github.com/shlinkio/shlink-web-client/issues/164) Added max visits control on those URLs which have `maxVisits`.
* [#178](https://github.com/shlinkio/shlink-web-client/issues/178) Short URLs list can now be filtered by date range.
* [#46](https://github.com/shlinkio/shlink-web-client/issues/46) Allowed short URL's metadata to be edited (`maxVisits`, `validSince` and `validUntil`).
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#170](https://github.com/shlinkio/shlink-web-client/issues/170) Fixed apple icon referencing to incorrect file names.
## 2.2.2 - 2019-10-21
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#167](https://github.com/shlinkio/shlink-web-client/issues/167) Fixed `/servers.json` path not being ignored when returning something other than an array.
## 2.2.1 - 2019-10-18
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#165](https://github.com/shlinkio/shlink-web-client/issues/165) Fixed error thrown when opening "create" page while using a Shlink version which does not return a valid SemVer version (like `latest` docker image, or any development instance).
## 2.2.0 - 2019-10-05
#### Added
* [#144](https://github.com/shlinkio/shlink-web-client/issues/144) Added domain input to create domain page.
#### Changed
* [#140](https://github.com/shlinkio/shlink-web-client/issues/140) Updated project dependencies.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 2.1.1 - 2019-09-22
#### Added
* *Nothing*
#### Changed
* [#142](https://github.com/shlinkio/shlink-web-client/issues/142) Updated to newer versions of base docker images for dev and production.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#151](https://github.com/shlinkio/shlink-web-client/issues/151) Fixed "order by" indicator (caret) still indicate ASC on column header when no order is specified.
* [#157](https://github.com/shlinkio/shlink-web-client/issues/157) Fixed pagination control on graphs expanding too much when lots of pages need to be rendered.
* [#155](https://github.com/shlinkio/shlink-web-client/issues/155) Fixed client-side paths resolve to 404 when served from nginx in docker image instead of falling back to `index.html`.
## 2.1.0 - 2019-05-19
#### Added
* [#101](https://github.com/shlinkio/shlink-web-client/issues/101) Added checkbox to short URL creation form that allows to determine the value of the `findIfExists` flag introduced in Shlink v1.16.0.
* [#105](https://github.com/shlinkio/shlink-web-client/issues/105) Added support to pre-configure servers. See [how to pre-configure servers](README.md#pre-configuring-servers) to get more details on how to do it.
#### Changed
* [#125](https://github.com/shlinkio/shlink-web-client/issues/125) Refactored reducers to replace `switch` statements by `handleActions` from [redux-actions](https://github.com/redux-utilities/redux-actions).
* [#116](https://github.com/shlinkio/shlink-web-client/issues/116) Removed sinon in favor of jest mocks.
* [#72](https://github.com/shlinkio/shlink-web-client/issues/72) Increased code coverage up to 80%.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 2.0.3 - 2019-03-16
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#120](https://github.com/shlinkio/shlink-web-client/issues/120) Fixed crash when visits page is loaded and there are no visits with known cities.
* [#113](https://github.com/shlinkio/shlink-web-client/issues/113) Ensured visits loading is cancelled when the visits page is unmounted. Requests on flight will still finish.
* [#118](https://github.com/shlinkio/shlink-web-client/issues/118) Fixed chart crashing when trying to render lots of bars by adding pagination.
## 2.0.2 - 2019-03-04
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#103](https://github.com/shlinkio/shlink-web-client/issues/103) Fixed visits page getting freezed when loading large amounts of visits.
* [#111](https://github.com/shlinkio/shlink-web-client/issues/111) Fixed crash when trying to load a map modal with only one location.
* [#115](https://github.com/shlinkio/shlink-web-client/issues/115) Created `ErrorHandler` component which will prevent crashes in app to make it unusable.
## 2.0.1 - 2019-03-03
#### Added

View File

@@ -1,8 +1,9 @@
FROM node:10.15.2 as node
FROM node:12.11.1-alpine as node
COPY . /shlink-web-client
RUN cd /shlink-web-client && yarn install && yarn build
RUN cd /shlink-web-client && npm install && npm run build
FROM nginx:1.15.9-alpine
FROM nginx:1.17.7-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=node /shlink-web-client/build /usr/share/nginx/html

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018-2019 shlinkio
Copyright (c) 2018-2020 shlinkio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,7 +1,7 @@
# shlink-web-client
[![Build Status](https://img.shields.io/travis/shlinkio/shlink-web-client.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink-web-client)
[![Docker build status](https://img.shields.io/docker/build/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![Docker build status](https://img.shields.io/docker/cloud/build/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
@@ -14,9 +14,9 @@ A ReactJS-based progressive web application for [Shlink](https://shlink.io).
There are three ways in which you can use this application.
* The easiest way to use shlink-web-client is by just going to https://app.shlink.io.
* The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
The application runs 100% in the browser, so you can use that instance and access any shlink instance from it.
The application runs 100% in the browser, so you can safely access any shlink instance from there.
* Self hosting the application yourself.
@@ -24,28 +24,60 @@ There are three ways in which you can use this application.
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these simple steps](#serve-shlink-in-subpath).
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
* Use the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
* Using the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the image and do it.
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the `shlinkio/shlink-web-client` image and do it.
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the assets on port 80.
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
## Pre-configuring servers
The first time you access shlink-web-client from a browser, you will have to configure the list of shlink servers you want to manage, and they will be saved in the local storage.
Those servers can be exported and imported in other browsers, but if for some reason you need some servers to be there from the beginning, starting with shlink-web-client 2.1.0, you can provide a `servers.json` file in the project root folder (the same containing the `index.html`, `favicon.ico`, etc) with a structure like this:
```json
[
{
"name": "Main server",
"url": "https://doma.in",
"apiKey": "09c972b7-506b-49f1-a19a-d729e22e599c"
},
{
"name": "Local",
"url": "http://localhost:8080",
"apiKey": "580d0b42-4dea-419a-96bf-6c876b901451"
}
]
```
> The list can contain as many servers as you need.
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
## Serve project in subpath
Official distributable files have been build so that they are served from the root of a domain.
Official distributable files have been built so that they are served from the root of a domain.
If you need to host shlink-web-client yourself and serve it from a subpath, follow these steps:
* Download [node](https://nodejs.org/en/download/package-manager/) 10.4 or later (if you don't have it yet).
* Download [yarn](https://yarnpkg.com/en/docs/install) package manager.
* Download shlink-web-client source files for the version you want to build.
* Download shlink-web-client source code for the version you want to build.
* For example, if you want to build `v1.0.1`, use this link https://github.com/shlinkio/shlink-web-client/archive/v1.0.1.zip
* Replace the `v1.0.1` part in the link with the one of the version you want to build.
* Decompress the file and `cd` into the resulting folder.
* Install project dependencies by running `yarn install`.
* Open the `package.json` file in the root of the project, locate the `homepage` property and replace the value (which should be an empty string) by the path from which you want to serve shlink-web-client.
* For example: `"homepage": "/my-projects/shlink-web-client",`.
* Build the distributable contents by running `yarn build`.
* Once the command finishes, you will have a `build` folder with all the static assets you need to run shlink-web-client. Just place them wherever you want them to be served from.
* Build the project:
* For classic hosting:
* Download [node](https://nodejs.org/en/download/package-manager/) 10.15 or later.
* Install project dependencies by running `npm install`.
* Build the project by running `npm run build`.
* Once the command finishes, you will have a `build` folder with all the static assets you need to run shlink-web-client. Just place them wherever you want them to be served from.
* For docker image:
* Download [docker](https://docs.docker.com/install/).
* Build the docker image by running `docker build . -t shlink-web-client`.
* Once the command finishes, you will have an image with the name `shlink-web-client`.

17
config/docker/nginx.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 80 default_server;
charset utf-8;
root /usr/share/nginx/html;
index index.html;
# When requesting static paths with extension, try them, and return a 404 if not found
location ~ .+\.(css|js|html|png|jpg|jpeg|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
try_files $uri $uri/ =404;
}
# When requesting a path without extension, try it, and return the index if not found
# This allows HTML5 history paths to be handled by the client application
location / {
try_files $uri $uri/ /index.html$is_args$args;
}
}

View File

@@ -6,3 +6,4 @@ services:
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
- ./home:/home/alejandro

View File

@@ -3,8 +3,8 @@ version: '3'
services:
shlink_web_client_node:
container_name: shlink_web_client_node
image: node:10.15.0-alpine
command: /bin/sh -c "cd /home/shlink/www && yarn install && yarn start"
image: node:12.11.0-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes:
- ./:/home/shlink/www
ports:

18715
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,132 +1,144 @@
{
"name": "shlink-web-client-react",
"name": "shlink-web-client",
"description": "A React-based progressive web application for shlink",
"version": "1.0.0",
"version": "2.3.0",
"private": false,
"homepage": "",
"repository": "https://github.com/shlinkio/shlink-web-client",
"license": "MIT",
"scripts": {
"lint": "yarn lint:js && yarn lint:css",
"lint": "npm run lint:js && npm run lint:css",
"lint:js": "eslint src test scripts config",
"lint:js:fix": "yarn lint:js --fix",
"lint:js:fix": "npm run lint:js -- --fix",
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:css:fix": "yarn lint:css --fix",
"lint:css:fix": "npm run lint:css -- --fix",
"start": "node scripts/start.js",
"serve:build": "yarn serve ./build",
"serve:build": "serve ./build",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom --colors",
"test:ci": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
"test:pretty": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html"
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
"mutate": "./node_modules/.bin/stryker run",
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.6.3",
"@fortawesome/fontawesome-svg-core": "^1.2.0",
"@fortawesome/free-regular-svg-icons": "^5.6.3",
"@fortawesome/free-solid-svg-icons": "^5.6.3",
"@fortawesome/react-fontawesome": "^0.1.3",
"@fortawesome/fontawesome-free": "^5.11.2",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-regular-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5",
"array-filter": "^1.0.0",
"array-map": "^0.0.0",
"array-reduce": "^0.0.0",
"axios": "^0.18.0",
"bootstrap": "~4.1.1",
"bottlejs": "^1.7.1",
"chart.js": "^2.7.2",
"axios": "^0.19.0",
"bootstrap": "^4.3.1",
"bottlejs": "^1.7.2",
"chart.js": "^2.8.0",
"classnames": "^2.2.6",
"compare-versions": "^3.5.1",
"csvjson": "^5.1.0",
"leaflet": "^1.4.0",
"moment": "^2.22.2",
"promise": "^8.0.1",
"prop-types": "^15.6.2",
"qs": "^6.5.2",
"ramda": "^0.25.0",
"react": "^16.7.0",
"react-autosuggest": "^9.4.0",
"react-chartjs-2": "^2.7.4",
"react-color": "^2.14.1",
"leaflet": "^1.5.1",
"moment": "^2.24.0",
"promise": "^8.0.3",
"prop-types": "^15.7.2",
"qs": "^6.9.0",
"ramda": "^0.26.1",
"react": "^16.10.2",
"react-autosuggest": "^9.4.3",
"react-chartjs-2": "^2.8.0",
"react-color": "^2.17.3",
"react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "~1.5.0",
"react-dom": "^16.7.0",
"react-leaflet": "^2.1.4",
"react-moment": "^0.7.6",
"react-redux": "^5.0.7",
"react-router-dom": "^4.2.2",
"react-swipeable": "^4.3.0",
"react-dom": "^16.10.2",
"react-external-link": "^1.0.0",
"react-leaflet": "^2.4.0",
"react-moment": "^0.9.5",
"react-redux": "^7.1.1",
"react-router-dom": "^5.1.2",
"react-swipeable": "^5.4.0",
"react-tagsinput": "^3.19.0",
"reactstrap": "^6.0.1",
"redux": "^4.0.0",
"reactstrap": "^8.0.1",
"redux": "^4.0.4",
"redux-actions": "^2.6.5",
"redux-thunk": "^2.3.0",
"uuid": "^3.3.2"
"uuid": "^3.3.3"
},
"devDependencies": {
"@babel/core": "^7.1.6",
"@svgr/webpack": "^2.4.1",
"adm-zip": "0.4.11",
"autoprefixer": "^7.1.6",
"@babel/core": "^7.6.2",
"@stryker-mutator/core": "^2.1.0",
"@stryker-mutator/html-reporter": "^2.1.0",
"@stryker-mutator/javascript-mutator": "^2.1.0",
"@stryker-mutator/jest-runner": "^2.1.0",
"@svgr/webpack": "^4.3.3",
"adm-zip": "^0.4.13",
"autoprefixer": "^9.6.3",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.4",
"babel-plugin-named-asset-import": "^0.3.0",
"babel-preset-react-app": "^7.0.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
"babel-loader": "^8.0.6",
"babel-plugin-named-asset-import": "^0.3.4",
"babel-preset-react-app": "^9.0.2",
"babel-runtime": "^6.26.0",
"bfj": "^6.1.1",
"case-sensitive-paths-webpack-plugin": "^2.1.2",
"chalk": "^2.4.1",
"css-loader": "^1.0.0",
"dotenv": "^6.0.0",
"dotenv-expand": "^4.2.0",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"bfj": "^7.0.1",
"case-sensitive-paths-webpack-plugin": "^2.2.0",
"chalk": "^2.4.2",
"css-loader": "^3.2.0",
"dotenv": "^8.1.0",
"dotenv-expand": "^5.1.0",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
"eslint": "^5.11.1",
"eslint-config-adidas-babel": "^1.1.0",
"eslint-config-adidas-env": "^1.1.0",
"eslint-config-adidas-es6": "^1.2.0",
"eslint-config-adidas-react": "^1.1.1",
"eslint-loader": "^2.1.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jest": "^21.22.0",
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-react": "^7.11.1",
"file-loader": "^2.0.0",
"eslint-loader": "^3.0.2",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jest": "^22.17.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "^7.16.0",
"file-loader": "^4.2.0",
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
"fs-extra": "^7.0.0",
"html-webpack-plugin": "^4.0.0-alpha.2",
"fs-extra": "^8.1.0",
"html-webpack-plugin": "^4.0.0-beta.8",
"identity-obj-proxy": "^3.0.0",
"jest": "^23.6.0",
"jest-pnp-resolver": "^1.0.1",
"jest-resolve": "^23.6.0",
"mini-css-extract-plugin": "^0.4.3",
"node-sass": "^4.9.0",
"jest": "^24.9.0",
"jest-each": "^24.9.0",
"jest-pnp-resolver": "^1.2.1",
"jest-resolve": "^24.9.0",
"mini-css-extract-plugin": "^0.8.0",
"node-sass": "^4.12.0",
"object-assign": "^4.1.1",
"ocular.js": "^0.1.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"pnp-webpack-plugin": "^1.1.0",
"postcss": "^7.0.7",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"pnp-webpack-plugin": "^1.5.0",
"postcss": "^7.0.18",
"postcss-flexbugs-fixes": "^4.1.0",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.3.1",
"postcss-preset-env": "^6.7.0",
"postcss-safe-parser": "^4.0.1",
"raf": "^3.4.0",
"react-app-polyfill": "^0.2.0",
"react-dev-utils": "^7.0.1",
"resolve": "^1.8.1",
"sass-loader": "^7.1.0",
"serve": "^10.0.0",
"sinon": "^6.1.5",
"style-loader": "^0.23.0",
"stylelint": "^9.9.0",
"raf": "^3.4.1",
"react-app-polyfill": "^1.0.4",
"react-dev-utils": "^9.1.0",
"resolve": "^1.12.0",
"sass-loader": "^8.0.0",
"serve": "^11.2.0",
"stryker-cli": "^1.0.0",
"style-loader": "^1.0.0",
"stylelint": "^9.10.1",
"stylelint-config-adidas": "^1.2.1",
"stylelint-config-adidas-bem": "^1.2.0",
"stylelint-config-recommended-scss": "^3.2.0",
"stylelint-scss": "^3.3.0",
"sw-precache-webpack-plugin": "^0.11.4",
"terser-webpack-plugin": "^1.1.0",
"url-loader": "^1.1.1",
"webpack": "^4.19.1",
"webpack-dev-server": "^3.1.14",
"webpack-manifest-plugin": "^2.0.4",
"whatwg-fetch": "^2.0.3",
"workbox-webpack-plugin": "^3.6.3"
"stylelint-config-recommended-scss": "^4.0.0",
"stylelint-scss": "^3.11.1",
"sw-precache-webpack-plugin": "^0.11.5",
"terser-webpack-plugin": "^2.1.2",
"url-loader": "^2.2.0",
"webpack": "^4.41.0",
"webpack-dev-server": "^3.8.2",
"webpack-manifest-plugin": "^2.2.0",
"whatwg-fetch": "^3.0.0",
"workbox-webpack-plugin": "^4.3.1"
},
"babel": {
"presets": [

BIN
public/favicon.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="512pt" height="512pt" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg"><g fill="#4595e3"><path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" /><path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z" /><path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z" /><path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z" /></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/icons/icon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/icons/icon-24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/icons/icon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/icons/icon-40x40.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

BIN
public/icons/icon-48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/icons/icon-60x60.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

BIN
public/icons/icon-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 750 B

BIN
public/icons/icon-76x76.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 984 B

View File

@@ -11,12 +11,74 @@
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/shlink-128.png">
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/shlink-64.png">
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/shlink-32.png">
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/shlink-24.png">
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/shlink-16.png">
<!-- FavIcon itself -->
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
<link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/favicon.svg" sizes="any">
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon.png">
<link rel="icon" type="image/gif" href="%PUBLIC_URL%/favicon.gif">
<!-- Apple Touch -->
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
<link rel="apple-touch-icon" sizes="40x40" href="%PUBLIC_URL%/icons/icon-40x40.png">
<link rel="apple-touch-icon" sizes="48x48" href="%PUBLIC_URL%/icons/icon-48x48.png">
<link rel="apple-touch-icon" sizes="60x60" href="%PUBLIC_URL%/icons/icon-60x60.png">
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/icon-64x64.png">
<link rel="apple-touch-icon" sizes="72x72" href="%PUBLIC_URL%/icons/icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="%PUBLIC_URL%/icons/icon-76x76.png">
<link rel="apple-touch-icon" sizes="96x96" href="%PUBLIC_URL%/icons/icon-96x96.png">
<link rel="apple-touch-icon" sizes="114x114" href="%PUBLIC_URL%/icons/icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="%PUBLIC_URL%/icons/icon-120x120.png">
<link rel="apple-touch-icon" sizes="128x128" href="%PUBLIC_URL%/icons/icon-128x128.png">
<link rel="apple-touch-icon" sizes="144x144" href="%PUBLIC_URL%/icons/icon-144x144.png">
<link rel="apple-touch-icon" sizes="150x150" href="%PUBLIC_URL%/icons/icon-150x150.png">
<link rel="apple-touch-icon" sizes="152x152" href="%PUBLIC_URL%/icons/icon-152x152.png">
<link rel="apple-touch-icon" sizes="160x160" href="%PUBLIC_URL%/icons/icon-160x160.png">
<link rel="apple-touch-icon" sizes="167x167" href="%PUBLIC_URL%/icons/icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/icons/icon-180x180.png">
<link rel="apple-touch-icon" sizes="192x192" href="%PUBLIC_URL%/icons/icon-192x192.png">
<link rel="apple-touch-icon" sizes="196x196" href="%PUBLIC_URL%/icons/icon-196x196.png">
<link rel="apple-touch-icon" sizes="228x228" href="%PUBLIC_URL%/icons/icon-228x228.png">
<link rel="apple-touch-icon" sizes="256x256" href="%PUBLIC_URL%/icons/icon-256x256.png">
<link rel="apple-touch-icon" sizes="310x310" href="%PUBLIC_URL%/icons/icon-310x310.png">
<link rel="apple-touch-icon" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
<link rel="apple-touch-icon" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
<link rel="apple-touch-icon" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
<!-- Normal -->
<link rel="icon" type="image/png" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
<link rel="icon" type="image/png" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
<link rel="icon" type="image/png" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
<link rel="icon" type="image/png" sizes="310x310" href="%PUBLIC_URL%/icons/icon-310x310.png">
<link rel="icon" type="image/png" sizes="256x256" href="%PUBLIC_URL%/icons/icon-256x256.png">
<link rel="icon" type="image/png" sizes="228x228" href="%PUBLIC_URL%/icons/icon-228x228.png">
<link rel="icon" type="image/png" sizes="196x196" href="%PUBLIC_URL%/icons/icon-196x196.png">
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/icons/icon-192x192.png">
<link rel="icon" type="image/png" sizes="180x180" href="%PUBLIC_URL%/icons/icon-180x180.png">
<link rel="icon" type="image/png" sizes="167x167" href="%PUBLIC_URL%/icons/icon-167x167.png">
<link rel="icon" type="image/png" sizes="160x160" href="%PUBLIC_URL%/icons/icon-160x160.png">
<link rel="icon" type="image/png" sizes="152x152" href="%PUBLIC_URL%/icons/icon-152x152.png">
<link rel="icon" type="image/png" sizes="150x150" href="%PUBLIC_URL%/icons/icon-150x150.png">
<link rel="icon" type="image/png" sizes="144x144" href="%PUBLIC_URL%/icons/icon-144x144.png">
<link rel="icon" type="image/png" sizes="128x128" href="%PUBLIC_URL%/icons/icon-128x128.png">
<link rel="icon" type="image/png" sizes="120x120" href="%PUBLIC_URL%/icons/icon-120x120.png">
<link rel="icon" type="image/png" sizes="114x114" href="%PUBLIC_URL%/icons/icon-114x114.png">
<link rel="icon" type="image/png" sizes="96x96" href="%PUBLIC_URL%/icons/icon-96x96.png">
<link rel="icon" type="image/png" sizes="76x76" href="%PUBLIC_URL%/icons/icon-76x76.png">
<link rel="icon" type="image/png" sizes="72x72" href="%PUBLIC_URL%/icons/icon-72x72.png">
<link rel="icon" type="image/png" sizes="64x64" href="%PUBLIC_URL%/icons/icon-64x64.png">
<link rel="icon" type="image/png" sizes="60x60" href="%PUBLIC_URL%/icons/icon-60x60.png">
<link rel="icon" type="image/png" sizes="48x48" href="%PUBLIC_URL%/icons/icon-48x48.png">
<link rel="icon" type="image/png" sizes="40x40" href="%PUBLIC_URL%/icons/icon-40x40.png">
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
<link rel="icon" type="image/png" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
<!-- MS -->
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/icons/icon-144x144.png">
<meta name="msapplication-square70x70logo" content="%PUBLIC_URL%/icons/icon-70x70.png">
<meta name="msapplication-square144x144logo" content="%PUBLIC_URL%/icons/icon-144x144.png">
<meta name="msapplication-square150x150logo" content="%PUBLIC_URL%/icons/icon-150x150.png">
<meta name="msapplication-square310x310logo" content="%PUBLIC_URL%/icons/icon-310x310.png">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.

View File

@@ -6,16 +6,66 @@
"theme_color": "#4696e5",
"background_color": "#4696e5",
"icons": [
{
"src": "./icons/icon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"src": "./icons/icon-24x24.png",
"type": "image/png",
"sizes": "24x24"
},
{
"src": "./icons/icon-32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "./icons/icon-40x40.png",
"type": "image/png",
"sizes": "40x40"
},
{
"src": "./icons/icon-48x48.png",
"type": "image/png",
"sizes": "48x48"
},
{
"src": "./icons/icon-60x60.png",
"type": "image/png",
"sizes": "60x60"
},
{
"src": "./icons/icon-64x64.png",
"type": "image/png",
"sizes": "64x64"
},
{
"src": "./icons/icon-72x72.png",
"type": "image/png",
"sizes": "72x72"
},
{
"src": "./icons/icon-76x76.png",
"type": "image/png",
"sizes": "76x76"
},
{
"src": "./icons/icon-96x96.png",
"type": "image/png",
"sizes": "96x96"
},
{
"src": "./icons/icon-114x114.png",
"type": "image/png",
"sizes": "114x114"
},
{
"src": "./icons/icon-120x120.png",
"type": "image/png",
"sizes": "120x120"
},
{
"src": "./icons/icon-128x128.png",
"type": "image/png",
@@ -26,20 +76,70 @@
"type": "image/png",
"sizes": "144x144"
},
{
"src": "./icons/icon-150x150.png",
"type": "image/png",
"sizes": "150x150"
},
{
"src": "./icons/icon-152x152.png",
"type": "image/png",
"sizes": "152x152"
},
{
"src": "./icons/icon-160x160.png",
"type": "image/png",
"sizes": "160x160"
},
{
"src": "./icons/icon-167x167.png",
"type": "image/png",
"sizes": "167x167"
},
{
"src": "./icons/icon-180x180.png",
"type": "image/png",
"sizes": "180x180"
},
{
"src": "./icons/icon-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "./icons/icon-196x196.png",
"type": "image/png",
"sizes": "196x196"
},
{
"src": "./icons/icon-228x228.png",
"type": "image/png",
"sizes": "228x228"
},
{
"src": "./icons/icon-256x256.png",
"type": "image/png",
"sizes": "256x256"
},
{
"src": "./icons/icon-310x310.png",
"type": "image/png",
"sizes": "310x310"
},
{
"src": "./icons/icon-384x384.png",
"type": "image/png",
"sizes": "384x384"
},
{
"src": "./icons/icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "./icons/icon-1024x1024.png",
"type": "image/png",
"sizes": "1024x1024"
}
]
}

View File

@@ -81,7 +81,7 @@ checkBrowsers(paths.appPath, isInteractive)
const urls = prepareUrls(protocol, HOST, port);
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler(webpack, config, appName, urls, useYarn);
const compiler = createCompiler({ webpack, config, appName, urls, useYarn });
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import * as PropTypes from 'prop-types';
import './ErrorHandler.scss';
import { Button } from 'reactstrap';
// FIXME Replace with typescript: (window, console)
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
};
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(e) {
if (process.env.NODE_ENV !== 'development') {
error(e);
}
}
render() {
if (this.state.hasError) {
return (
<div className="error-handler">
<h1>Oops! This is awkward :S</h1>
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<br />
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
</div>
);
}
return this.props.children;
}
};
export default ErrorHandler;

View File

@@ -0,0 +1,9 @@
@import '../utils/mixins/vertical-align.scss';
.error-handler {
@include vertical-align();
padding: 20px;
text-align: center;
width: 100%;
}

View File

@@ -18,18 +18,20 @@ export default class Home extends React.Component {
}
render() {
const servers = values(this.props.servers);
const { servers: { list, loading } } = this.props;
const servers = values(list);
const hasServers = !isEmpty(servers);
return (
<div className="home">
<h1 className="home__title">Welcome to Shlink</h1>
<h5 className="home__intro">
{hasServers && <span>Please, select a server.</span>}
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
{!loading && hasServers && <span>Please, select a server.</span>}
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
{loading && <span>Trying to load servers...</span>}
</h5>
{hasServers && (
{!loading && hasServers && (
<ListGroup className="home__servers-list">
{servers.map(({ name, id }) => (
<ListGroupItem

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import Swipeable from 'react-swipeable';
import { Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
@@ -20,9 +20,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
state = { showSideBar: false };
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
/* eslint react/no-deprecated: "off" */
componentWillMount() {
componentDidMount() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import { range, max, min } from 'ramda';
import './SimplePaginator.scss';
const propTypes = {
pagesCount: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
setCurrentPage: PropTypes.func.isRequired,
};
export const ellipsis = '...';
const pagination = (currentPage, pageCount) => {
const delta = 2;
const pages = range(
max(delta, currentPage - delta),
min(pageCount - 1, currentPage + delta) + 1
);
if (currentPage - delta > delta) {
pages.unshift(ellipsis);
}
if (currentPage + delta < pageCount - 1) {
pages.push(ellipsis);
}
pages.unshift(1);
pages.push(pageCount);
return pages;
};
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
if (pagesCount < 2) {
return null;
}
const onClick = (page) => () => setCurrentPage(page);
return (
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator">
<PaginationItem disabled={currentPage <= 1}>
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
</PaginationItem>
{pagination(currentPage, pagesCount).map((page, index) => (
<PaginationItem
key={page !== ellipsis ? page : `${page}_${index}`}
active={page === currentPage}
disabled={page === ellipsis}
>
<PaginationLink tag="span" onClick={onClick(page)}>{page}</PaginationLink>
</PaginationItem>
))}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink next tag="span" onClick={onClick(currentPage + 1)} />
</PaginationItem>
</Pagination>
);
};
SimplePaginator.propTypes = propTypes;
export default SimplePaginator;

View File

@@ -0,0 +1,3 @@
.simple-paginator {
user-select: none;
}

View File

@@ -3,9 +3,11 @@ import MainHeader from '../MainHeader';
import Home from '../Home';
import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler';
const provideServices = (bottle, connect, withRouter) => {
bottle.constant('window', global.window);
bottle.constant('console', global.console);
bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
bottle.decorator('ScrollToTop', withRouter);
@@ -29,6 +31,8 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
};
export default provideServices;

View File

@@ -16,14 +16,16 @@ import './index.scss';
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
fixLeafletIcons();
const { App, ScrollToTop } = container;
const { App, ScrollToTop, ErrorHandler } = container;
render(
<Provider store={store}>
<BrowserRouter basename={homepage}>
<ScrollToTop>
<App />
</ScrollToTop>
<ErrorHandler>
<ScrollToTop>
<App />
</ScrollToTop>
</ErrorHandler>
</BrowserRouter>
</Provider>,
document.getElementById('root')

View File

@@ -51,3 +51,15 @@ body,
margin: 0 auto !important;
}
}
.pagination .page-link {
cursor: pointer;
}
.paddingless {
padding: 0;
}
.indivisible {
white-space: nowrap;
}

View File

@@ -6,6 +6,7 @@ import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListPara
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
@@ -20,6 +21,7 @@ export default combineReducers({
shortUrlCreationResult: shortUrlCreationReducer,
shortUrlDeletion: shortUrlDeletionReducer,
shortUrlTags: shortUrlTagsReducer,
shortUrlMeta: shortUrlMetaReducer,
shortUrlVisits: shortUrlVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,

View File

@@ -1,6 +1,5 @@
import { isEmpty, values } from 'ramda';
import React from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import PropTypes from 'prop-types';
import { serverType } from './prop-types';
@@ -11,10 +10,24 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
renderServers = () => {
const { servers, selectedServer, selectServer } = this.props;
const { servers: { list, loading }, selectedServer, selectServer } = this.props;
const servers = values(list);
const { push } = this.props.history;
const loadServer = (id) => {
selectServer(id)
.then(() => push(`/server/${id}/list-short-urls/1`))
.catch(() => {});
};
if (loading) {
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
}
if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
@@ -22,16 +35,8 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
return (
<React.Fragment>
{values(servers).map(({ name, id }) => (
<DropdownItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
active={selectedServer && selectedServer.id === id}
// FIXME This should be implicit
onClick={() => selectServer(id)}
>
{servers.map(({ name, id }) => (
<DropdownItem key={id} active={selectedServer && selectedServer.id === id} onClick={() => loadServer(id)}>
{name}
</DropdownItem>
))}
@@ -46,18 +51,14 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
);
};
componentDidMount() {
this.props.listServers();
}
componentDidMount = this.props.listServers;
render() {
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
}
render = () => (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};
export default ServersDropdown;

View File

@@ -1,7 +1,5 @@
import React from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { assoc, map } from 'ramda';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
@@ -22,10 +20,8 @@ const ImportServersBtn = (serversImporter) => class ImportServersBtn extends Rea
render() {
const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
const assocId = (server) => assoc('id', uuid(), server);
const onChange = ({ target }) =>
importServersFromFile(target.files[0])
.then(map(assocId))
.then(createServers)
.then(onImport)
.then(() => {

View File

@@ -1,32 +1,40 @@
import { createAction, handleActions } from 'redux-actions';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
import { versionIsValidSemVer } from '../../utils/utils';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
/* eslint-enable padding-line-between-statements, newline-after-var */
const defaultState = null;
export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999';
export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */
export default function reducer(state = defaultState, action) {
switch (action.type) {
case SELECT_SERVER:
return action.selectedServer;
case RESET_SELECTED_SERVER:
return defaultState;
default:
return state;
}
}
const initialState = null;
export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER });
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
export const selectServer = (serversService) => (serverId) => (dispatch) => {
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
dispatch(resetShortUrlParams());
const selectedServer = serversService.findServerById(serverId);
const selectedServer = findServerById(serverId);
const { health } = await buildShlinkApiClient(selectedServer);
const version = await health()
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
.catch(() => MIN_FALLBACK_VERSION);
dispatch({
type: SELECT_SERVER,
selectedServer,
selectedServer: {
...selectedServer,
version,
},
});
};
export default handleActions({
[RESET_SELECTED_SERVER]: () => initialState,
[SELECT_SERVER]: (state, { selectedServer }) => selectedServer,
}, initialState);

View File

@@ -1,33 +1,61 @@
import { handleActions } from 'redux-actions';
import { pipe, isEmpty, assoc, map, prop } from 'ramda';
import { v4 as uuid } from 'uuid';
import { homepage } from '../../../package.json';
/* eslint-disable padding-line-between-statements */
export const FETCH_SERVERS_START = 'shlink/servers/FETCH_SERVERS_START';
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
/* eslint-enable padding-line-between-statements */
export default function reducer(state = {}, action) {
switch (action.type) {
case FETCH_SERVERS:
return action.servers;
default:
return state;
const initialState = {
list: {},
loading: false,
};
const assocId = (server) => assoc('id', server.id || uuid(), server);
export default handleActions({
[FETCH_SERVERS_START]: (state) => ({ ...state, loading: true }),
[FETCH_SERVERS]: (state, { list }) => ({ list, loading: false }),
}, initialState);
export const listServers = ({ listServers, createServers }, { get }) => () => async (dispatch) => {
dispatch({ type: FETCH_SERVERS_START });
const localList = listServers();
if (!isEmpty(localList)) {
dispatch({ type: FETCH_SERVERS, list: localList });
return;
}
}
export const listServers = (serversService) => () => ({
type: FETCH_SERVERS,
servers: serversService.listServers(),
});
// If local list is empty, try to fetch it remotely (making sure it's an array) and calculate IDs for every server
const getDataAsArrayWithIds = pipe(
prop('data'),
(value) => {
if (!Array.isArray(value)) {
throw new Error('Value is not an array');
}
export const createServer = (serversService, listServers) => (server) => {
serversService.createServer(server);
return value;
},
map(assocId),
);
const remoteList = await get(`${homepage}/servers.json`)
.then(getDataAsArrayWithIds)
.catch(() => []);
return listServers();
createServers(remoteList);
dispatch({ type: FETCH_SERVERS, list: remoteList.reduce((map, server) => ({ ...map, [server.id]: server }), {}) });
};
export const deleteServer = (serversService, listServers) => (server) => {
serversService.deleteServer(server);
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
return listServers();
};
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
export const createServers = (serversService, listServers) => (servers) => {
serversService.createServers(servers);
return listServers();
};
export const createServers = ({ createServers }, listServersAction) => pipe(
map(assocId),
createServers,
listServersAction
);

View File

@@ -1,9 +1,3 @@
import PropTypes from 'prop-types';
export const serversImporterType = PropTypes.shape({
importServersFromFile: PropTypes.func,
});
export default class ServersImporter {
constructor(csvjson) {
this.csvjson = csvjson;

View File

@@ -23,9 +23,6 @@ export default class ServersService {
this.storage.set(SERVERS_STORAGE_KEY, allServers);
};
deleteServer = (server) =>
this.storage.set(
SERVERS_STORAGE_KEY,
dissoc(server.id, this.listServers())
);
deleteServer = ({ id }) =>
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
}

View File

@@ -16,6 +16,7 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
bottle.decorator('ServersDropdown', withRouter);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
@@ -34,11 +35,11 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
// Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService');
bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
bottle.serviceFactory('listServers', listServers, 'ServersService');
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
};

View File

@@ -1,11 +1,16 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isNil, pipe, replace, trim } from 'ramda';
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda';
import React from 'react';
import { Collapse } from 'reactstrap';
import { Collapse, FormGroup, Input } from 'reactstrap';
import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
import ForVersion from '../utils/ForVersion';
import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/utils';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format();
@@ -15,15 +20,18 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
resetCreateShortUrl: PropTypes.func,
selectedServer: serverType,
};
state = {
longUrl: '',
tags: [],
customSlug: undefined,
domain: undefined,
validSince: undefined,
validUntil: undefined,
maxVisits: undefined,
findIfExists: false,
moreOptionsVisible: false,
};
@@ -32,9 +40,8 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
<div className="form-group">
<input
className="form-control"
<FormGroup>
<Input
id={id}
type={type}
placeholder={placeholder}
@@ -42,7 +49,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
onChange={(e) => this.setState({ [id]: e.target.value })}
{...props}
/>
</div>
</FormGroup>
);
const renderDateInput = (id, placeholder, props = {}) => (
<div className="form-group">
@@ -63,6 +70,8 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
assoc('validUntil', formatDate(this.state.validUntil))
)(this.state));
};
const currentServerVersion = this.props.selectedServer ? this.props.selectedServer.version : '';
const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1');
return (
<div className="shlink-container">
@@ -86,19 +95,45 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('customSlug', 'Custom slug')}
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-6">
{renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
</div>
</div>
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-3">
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
</div>
<div className="col-sm-3">
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
</div>
<ForVersion minVersion="1.16.0" currentServerVersion={currentServerVersion}>
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
checked={this.state.findIfExists}
onChange={(findIfExists) => this.setState({ findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForVersion>
</Collapse>
<div>
<button
type="button"
className="btn btn-outline-secondary create-short-url__btn"
className="btn btn-outline-secondary"
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
>
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
@@ -106,8 +141,8 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary create-short-url__btn float-right"
disabled={shortUrlCreationResult.loading}
className="btn btn-outline-primary float-right"
disabled={shortUrlCreationResult.loading || isEmpty(this.state.longUrl)}
>
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
</button>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import PropTypes from 'prop-types';
import { range } from 'ramda';
import { rangeOf } from '../utils/utils';
const propTypes = {
serverId: PropTypes.string.isRequired,
@@ -20,7 +20,7 @@ export default function Paginator({ paginator = {}, serverId }) {
}
const renderPages = () =>
range(1, pagesCount + 1).map((pageNumber) => (
rangeOf(pagesCount, (pageNumber) => (
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
<PaginationLink
tag={Link}

View File

@@ -1,31 +1,56 @@
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { isEmpty } from 'ramda';
import { isEmpty, pipe } from 'ramda';
import PropTypes from 'prop-types';
import moment from 'moment';
import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import DateRangeRow from '../utils/DateRangeRow';
import { compareVersions, formatDate } from '../utils/utils';
import { serverType } from '../servers/prop-types';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './SearchBar.scss';
const propTypes = {
listShortUrls: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
selectedServer: serverType,
};
const dateOrUndefined = (date) => date ? moment(date) : undefined;
const SearchBar = (colorGenerator) => {
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
const SearchBar = ({ listShortUrls, shortUrlsListParams, selectedServer }) => {
const currentServerVersion = selectedServer ? selectedServer.version : '';
const enableDateFiltering = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '>=', '1.21.0');
const selectedTags = shortUrlsListParams.tags || [];
const setDate = (dateName) => pipe(
formatDate(),
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date })
);
return (
<div className="serach-bar-container">
<SearchField onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
}
<div className="search-bar-container">
<SearchField
onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
}
/>
{enableDateFiltering && (
<div className="mt-3">
<DateRangeRow
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
</div>
)}
{!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-2">
<h4 className="search-bar__selected-tag mt-3">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp;
{selectedTags.map((tag) => (

View File

@@ -11,13 +11,14 @@ import { shortUrlType } from './reducers/shortUrlsList';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss';
const SORTABLE_FIELDS = {
export const SORTABLE_FIELDS = {
dateCreated: 'Created at',
shortCode: 'Short URL',
longUrl: 'Long URL',
visits: 'Visits',
};
// FIXME Replace with typescript: (ShortUrlsRow component)
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
static propTypes = {
listShortUrls: PropTypes.func,
@@ -39,17 +40,24 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
...extraParams,
});
};
handleOrderBy = (orderField, orderDir) => {
this.setState({ orderField, orderDir });
this.refreshList({ orderBy: { [orderField]: orderDir } });
};
orderByColumn = (columnName) => () =>
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
renderOrderIcon = (field) => {
if (this.state.orderField !== field) {
return null;
}
if (!this.state.orderDir) {
return null;
}
return (
<FontAwesomeIcon
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
@@ -72,8 +80,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
componentDidMount() {
const { match: { params }, location, shortUrlsListParams } = this.props;
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : shortUrlsListParams.tags });
this.refreshList({ page: params.page, tags });
}
componentWillUnmount() {

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import './UseExistingIfFoundInfoIcon.scss';
import { useToggle } from '../utils/utils';
const renderInfoModal = (isOpen, toggle) => (
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
<ModalHeader toggle={toggle}>Info</ModalHeader>
<ModalBody>
<p>
When the&nbsp;
<b><i>&quot;Use existing URL if found&quot;</i></b>
&nbsp;checkbox is checked, the server will return an existing short URL if it matches provided params.
</p>
<p>
These are the checks performed by Shlink in order to determine if an existing short URL should be returned:
</p>
<ul>
<li>
When only the long URL is provided: The most recent match will be returned, or a new short URL will be created
if none is found.
</li>
<li>
When long URL and custom slug and/or domain are provided: Same as in previous case, but it will try to match
the short URL using both the long URL and the slug, the long URL and the domain, or the three of them.
<br />
If the slug is being used by another long URL, an error will be returned.
</li>
<li>
When other params are provided: Same as in previous cases, but it will try to match existing short URLs with
all provided data. If any of them does not match, a new short URL will be created
</li>
</ul>
</ModalBody>
</Modal>
);
const UseExistingIfFoundInfoIcon = () => {
const [ isModalOpen, toggleModal ] = useToggle(false);
return (
<React.Fragment>
<span title="What does this mean?">
<FontAwesomeIcon icon={infoIcon} style={{ cursor: 'pointer' }} onClick={toggleModal} />
</span>
{renderInfoModal(isModalOpen, toggleModal)}
</React.Fragment>
);
};
export default UseExistingIfFoundInfoIcon;

View File

@@ -0,0 +1,7 @@
.use-existing-if-found-info-icon__modal-quote {
margin-bottom: 0;
padding: 10px 15px;
font-size: 17.5px;
border-left: 5px solid #eee;
background-color: #f9f9f9;
}

View File

@@ -5,6 +5,8 @@ import { identity } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
export default class DeleteShortUrlModal extends React.Component {
static propTypes = {
shortUrl: shortUrlType,
@@ -39,9 +41,10 @@ export default class DeleteShortUrlModal extends React.Component {
render() {
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
const hasThresholdError = shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED;
const { error, errorData } = shortUrlDeletion;
const errorCode = error && (errorData.type || errorData.error);
const hasThresholdError = errorCode === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
@@ -63,7 +66,8 @@ export default class DeleteShortUrlModal extends React.Component {
{hasThresholdError && (
<div className="p-2 mt-2 bg-warning text-center">
This short URL has received too many visits and therefore, it cannot be deleted
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
</div>
)}
{hasErrorOtherThanThreshold && (

View File

@@ -0,0 +1,100 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link';
import moment from 'moment';
import { pipe } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
import DateInput from '../../utils/DateInput';
import { formatIsoDate } from '../../utils/utils';
const propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlMeta: shortUrlEditMetaType,
editShortUrlMeta: PropTypes.func,
resetShortUrlMeta: PropTypes.func,
};
const dateOrUndefined = (shortUrl, dateName) => {
const date = shortUrl && shortUrl.meta && shortUrl.meta[dateName];
return date && moment(date);
};
const EditMetaModal = (
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }
) => {
const { saving, error } = shortUrlMeta;
const url = shortUrl && (shortUrl.shortUrl || '');
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
const [ validUntil, setValidUntil ] = useState(dateOrUndefined(shortUrl, 'validUntil'));
const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits);
const close = pipe(resetShortUrlMeta, toggle);
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, {
maxVisits: maxVisits && parseInt(maxVisits),
validSince: validSince && formatIsoDate(validSince),
validUntil: validUntil && formatIsoDate(validUntil),
}).then(close);
return (
<Modal isOpen={isOpen} toggle={close} centered>
<ModalHeader toggle={close}>
<FontAwesomeIcon icon={infoIcon} id="metaTitleInfo" /> Edit metadata for <ExternalLink href={url} />
<UncontrolledTooltip target="metaTitleInfo" placement="bottom">
<p>Using these metadata properties, you can limit when and how many times your short URL can be visited.</p>
<p>If any of the params is not met, the URL will behave as if it was an invalid short URL.</p>
</UncontrolledTooltip>
</ModalHeader>
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
<ModalBody>
<FormGroup>
<DateInput
placeholderText="Enabled since..."
selected={validSince}
maxDate={validUntil}
isClearable
onChange={setValidSince}
/>
</FormGroup>
<FormGroup>
<DateInput
placeholderText="Enabled until..."
selected={validUntil}
minDate={validSince}
isClearable
onChange={setValidUntil}
/>
</FormGroup>
<FormGroup className="mb-0">
<Input
type="number"
placeholder="Maximum number of visits allowed"
min={1}
value={maxVisits || ''}
onChange={(e) => setMaxVisits(e.target.value)}
/>
</FormGroup>
{error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the metadata :(
</div>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" type="button" onClick={close}>Cancel</button>
<button className="btn btn-primary" type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
</ModalFooter>
</form>
</Modal>
);
};
EditMetaModal.propTypes = propTypes;
export default EditMetaModal;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import ExternalLink from '../../utils/ExternalLink';
import { ExternalLink } from 'react-external-link';
import { shortUrlTagsType } from '../reducers/shortUrlTags';
import { shortUrlType } from '../reducers/shortUrlsList';
@@ -9,7 +9,6 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
static propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
url: PropTypes.string.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlTags: shortUrlTagsType,
editShortUrlTags: PropTypes.func,
@@ -51,12 +50,13 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
}
render() {
const { isOpen, toggle, url, shortUrlTags } = this.props;
const { isOpen, toggle, shortUrl, shortUrlTags } = this.props;
const url = shortUrl && (shortUrl.shortUrl || '');
return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}>
<ModalHeader toggle={toggle}>
Edit tags for <ExternalLink href={url}>{url}</ExternalLink>
Edit tags for <ExternalLink href={url} />
</ModalHeader>
<ModalBody>
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link';
import './PreviewModal.scss';
import ExternalLink from '../../utils/ExternalLink';
const propTypes = {
url: PropTypes.string,

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link';
import './QrCodeModal.scss';
import ExternalLink from '../../utils/ExternalLink';
const propTypes = {
url: PropTypes.string,

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import { shortUrlMetaType } from '../reducers/shortUrlMeta';
import './ShortUrlVisitsCount.scss';
const propTypes = {
visitsCount: PropTypes.number.isRequired,
meta: shortUrlMetaType,
};
const ShortUrlVisitsCount = ({ visitsCount, meta }) => {
const maxVisits = meta && meta.maxVisits;
if (!maxVisits) {
return <span>{visitsCount}</span>;
}
return (
<React.Fragment>
<span className="indivisible">
{visitsCount}
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control">
{' '}/ {maxVisits}{' '}
<sup>
<FontAwesomeIcon icon={infoIcon} />
</sup>
</small>
</span>
<UncontrolledTooltip target="maxVisitsControl" placement="bottom">
This short URL will not accept more than <b>{maxVisits}</b> visits.
</UncontrolledTooltip>
</React.Fragment>
);
};
ShortUrlVisitsCount.propTypes = propTypes;
export default ShortUrlVisitsCount;

View File

@@ -0,0 +1,3 @@
.short-urls-visits-count__max-visits-control {
cursor: help;
}

View File

@@ -2,11 +2,12 @@ import { isEmpty } from 'ramda';
import React from 'react';
import Moment from 'react-moment';
import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link';
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList';
import Tag from '../../tags/helpers/Tag';
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
import './ShortUrlsRow.scss';
const ShortUrlsRow = (
@@ -56,7 +57,9 @@ const ShortUrlsRow = (
<ExternalLink href={shortUrl.longUrl} />
</td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">{shortUrl.visitsCount}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
<ShortUrlVisitsCount visitsCount={shortUrl.visitsCount} meta={shortUrl.meta} />
</td>
<td className="short-urls-row__cell short-urls-row__cell--relative">
<small
className="badge badge-warning short-urls-row__copy-hint"

View File

@@ -5,6 +5,7 @@ import {
faEllipsisV as menuIcon,
faQrcode as qrIcon,
faMinusCircle as deleteIcon,
faEdit as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
@@ -12,13 +13,19 @@ import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Link } from 'react-router-dom';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import { isEmpty } from 'ramda';
import { serverType } from '../../servers/prop-types';
import { compareVersions } from '../../utils/utils';
import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal';
import './ShortUrlsRowMenu.scss';
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
const ShortUrlsRowMenu = (
DeleteShortUrlModal,
EditTagsModal,
EditMetaModal
) => class ShortUrlsRowMenu extends React.Component {
static propTypes = {
onCopyToClipboard: PropTypes.func,
selectedServer: serverType,
@@ -30,61 +37,74 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
isQrModalOpen: false,
isPreviewModalOpen: false,
isTagsModalOpen: false,
isMetaModalOpen: false,
isDeleteModalOpen: false,
};
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
render() {
const { onCopyToClipboard, shortUrl, selectedServer: { id } } = this.props;
const { onCopyToClipboard, shortUrl, selectedServer } = this.props;
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const currentServerVersion = selectedServer ? selectedServer.version : '';
const showEditMetaBtn = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '>=', '1.18.0');
const showPreviewBtn = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '<', '2.0.0');
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewModalOpen');
const toggleTags = toggleModal('isTagsModalOpen');
const toggleMeta = toggleModal('isMetaModalOpen');
const toggleDelete = toggleModal('isDeleteModalOpen');
return (
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen}>
<DropdownToggle size="sm" caret className="short-urls-row-menu__dropdown-toggle btn-outline-secondary">
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu right>
<DropdownItem tag={Link} to={`/server/${id}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit stats
<DropdownItem tag={Link} to={`/server/${selectedServer ? selectedServer.id : ''}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem>
<DropdownItem onClick={toggleTags}>
<FontAwesomeIcon icon={tagsIcon} /> &nbsp;Edit tags
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
</DropdownItem>
<EditTagsModal
url={completeShortUrl}
shortUrl={shortUrl}
isOpen={this.state.isTagsModalOpen}
toggle={toggleTags}
/>
<EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} />
{showEditMetaBtn && (
<React.Fragment>
<DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
</React.Fragment>
)}
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} /> &nbsp;Delete short URL
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
</DropdownItem>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} />
<DropdownItem divider />
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} /> &nbsp;Preview
</DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
{showPreviewBtn && (
<React.Fragment>
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
</DropdownItem>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
</React.Fragment>
)}
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} /> &nbsp;QR code
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem>
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
<DropdownItem divider />
{showPreviewBtn && <DropdownItem divider />}
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
<DropdownItem>
<FontAwesomeIcon icon={copyIcon} /> &nbsp;Copy to clipboard
<FontAwesomeIcon icon={copyIcon} fixedWidth /> Copy to clipboard
</DropdownItem>
</CopyToClipboard>
</DropdownMenu>

View File

@@ -1,11 +1,12 @@
import PropTypes from 'prop-types';
import { createAction, handleActions } from 'redux-actions';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const createShortUrlResultType = PropTypes.shape({
result: PropTypes.shape({
@@ -15,47 +16,26 @@ export const createShortUrlResultType = PropTypes.shape({
error: PropTypes.bool,
});
const defaultState = {
const initialState = {
result: null,
saving: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case CREATE_SHORT_URL_START:
return {
...state,
saving: true,
error: false,
};
case CREATE_SHORT_URL_ERROR:
return {
...state,
saving: false,
error: true,
};
case CREATE_SHORT_URL:
return {
result: action.result,
saving: false,
error: false,
};
case RESET_CREATE_SHORT_URL:
return defaultState;
default:
return state;
}
}
export default handleActions({
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
[CREATE_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[CREATE_SHORT_URL]: (state, { result }) => ({ result, saving: false, error: false }),
[RESET_CREATE_SHORT_URL]: () => initialState,
}, initialState);
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
dispatch({ type: CREATE_SHORT_URL_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { createShortUrl } = await buildShlinkApiClient(getState);
try {
const result = await shlinkApiClient.createShortUrl(data);
const result = await createShortUrl(data);
dispatch({ type: CREATE_SHORT_URL, result });
} catch (e) {
@@ -63,4 +43,4 @@ export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatc
}
};
export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL });
export const resetCreateShortUrl = createAction(RESET_CREATE_SHORT_URL);

View File

@@ -1,64 +1,40 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { apiErrorType } from '../../utils/services/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
export const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const shortUrlDeletionType = PropTypes.shape({
shortCode: PropTypes.string.isRequired,
loading: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
errorData: PropTypes.shape({
error: PropTypes.string,
message: PropTypes.string,
}).isRequired,
errorData: apiErrorType.isRequired,
});
const defaultState = {
const initialState = {
shortCode: '',
loading: false,
error: false,
errorData: {},
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case DELETE_SHORT_URL_START:
return {
...state,
loading: true,
error: false,
};
case DELETE_SHORT_URL_ERROR:
return {
...state,
loading: false,
error: true,
errorData: action.errorData,
};
case DELETE_SHORT_URL:
return {
...state,
shortCode: action.shortCode,
loading: false,
error: false,
};
case RESET_DELETE_SHORT_URL:
return defaultState;
default:
return state;
}
}
export default handleActions({
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
[DELETE_SHORT_URL]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
[RESET_DELETE_SHORT_URL]: () => initialState,
}, initialState);
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START });
const { selectedServer } = getState();
const { deleteShortUrl } = buildShlinkApiClient(selectedServer);
const { deleteShortUrl } = await buildShlinkApiClient(getState);
try {
await deleteShortUrl(shortCode);
@@ -70,6 +46,6 @@ export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (di
}
};
export const resetDeleteShortUrl = () => ({ type: RESET_DELETE_SHORT_URL });
export const resetDeleteShortUrl = createAction(RESET_DELETE_SHORT_URL);
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View File

@@ -0,0 +1,52 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START';
export const EDIT_SHORT_URL_META_ERROR = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_ERROR';
export const SHORT_URL_META_EDITED = 'shlink/shortUrlMeta/SHORT_URL_META_EDITED';
export const RESET_EDIT_SHORT_URL_META = 'shlink/shortUrlMeta/RESET_EDIT_SHORT_URL_META';
/* eslint-enable padding-line-between-statements */
export const shortUrlMetaType = PropTypes.shape({
validSince: PropTypes.string,
validUntil: PropTypes.string,
maxVisits: PropTypes.number,
});
export const shortUrlEditMetaType = PropTypes.shape({
shortCode: PropTypes.string,
meta: shortUrlMetaType.isRequired,
saving: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
});
const initialState = {
shortCode: null,
meta: {},
saving: false,
error: false,
};
export default handleActions({
[EDIT_SHORT_URL_META_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_META_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[SHORT_URL_META_EDITED]: (state, { shortCode, meta }) => ({ shortCode, meta, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_META]: () => initialState,
}, initialState);
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, meta) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_META_START });
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, meta);
dispatch({ shortCode, meta, type: SHORT_URL_META_EDITED });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_META_ERROR });
throw e;
}
};
export const resetShortUrlMeta = createAction(RESET_EDIT_SHORT_URL_META);

View File

@@ -1,13 +1,13 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
export const EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS';
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const shortUrlTagsType = PropTypes.shape({
shortCode: PropTypes.string,
@@ -16,47 +16,26 @@ export const shortUrlTagsType = PropTypes.shape({
error: PropTypes.bool.isRequired,
});
const defaultState = {
const initialState = {
shortCode: null,
tags: [],
saving: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case EDIT_SHORT_URL_TAGS_START:
return {
...state,
saving: true,
error: false,
};
case EDIT_SHORT_URL_TAGS_ERROR:
return {
...state,
saving: false,
error: true,
};
case EDIT_SHORT_URL_TAGS:
return {
...pick([ 'shortCode', 'tags' ], action),
saving: false,
error: false,
};
case RESET_EDIT_SHORT_URL_TAGS:
return defaultState;
default:
return state;
}
}
export default handleActions({
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[EDIT_SHORT_URL_TAGS]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
}, initialState);
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { updateShortUrlTags } = await buildShlinkApiClient(getState);
try {
const normalizedTags = await shlinkApiClient.updateShortUrlTags(shortCode, tags);
const normalizedTags = await updateShortUrlTags(shortCode, tags);
dispatch({ tags: normalizedTags, shortCode, type: EDIT_SHORT_URL_TAGS });
} catch (e) {
@@ -66,7 +45,7 @@ export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => a
}
};
export const resetShortUrlsTags = () => ({ type: RESET_EDIT_SHORT_URL_TAGS });
export const resetShortUrlsTags = createAction(RESET_EDIT_SHORT_URL_TAGS);
export const shortUrlTagsEdited = (shortCode, tags) => ({
tags,

View File

@@ -1,18 +1,22 @@
import { handleActions } from 'redux-actions';
import { assoc, assocPath, propEq, reject } from 'ramda';
import PropTypes from 'prop-types';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const shortUrlType = PropTypes.shape({
shortCode: PropTypes.string,
shortUrl: PropTypes.string,
longUrl: PropTypes.string,
visitsCount: PropTypes.number,
meta: shortUrlMetaType,
tags: PropTypes.arrayOf(PropTypes.string),
});
@@ -22,45 +26,31 @@ const initialState = {
error: false,
};
export default function reducer(state = initialState, action) {
switch (action.type) {
case LIST_SHORT_URLS_START:
return { ...state, loading: true, error: false };
case LIST_SHORT_URLS:
return {
loading: false,
error: false,
shortUrls: action.shortUrls,
};
case LIST_SHORT_URLS_ERROR:
return {
loading: false,
error: true,
shortUrls: {},
};
case SHORT_URL_TAGS_EDITED:
const { data } = state.shortUrls;
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, [prop]: propValue }) => assocPath(
[ 'shortUrls', 'data' ],
state.shortUrls.data.map(
(shortUrl) => shortUrl.shortCode === shortCode ? assoc(prop, propValue, shortUrl) : shortUrl
),
state
);
return assocPath([ 'shortUrls', 'data' ], data.map((shortUrl) =>
shortUrl.shortCode === action.shortCode
? assoc('tags', action.tags, shortUrl)
: shortUrl), state);
case SHORT_URL_DELETED:
return assocPath(
[ 'shortUrls', 'data' ],
reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
state,
);
default:
return state;
}
}
export default handleActions({
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_SHORT_URLS]: (state, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true, shortUrls: {} }),
[SHORT_URL_DELETED]: (state, { shortCode }) => assocPath(
[ 'shortUrls', 'data' ],
reject(propEq('shortCode', shortCode), state.shortUrls.data),
state,
),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
}, initialState);
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
dispatch({ type: LIST_SHORT_URLS_START });
const { selectedServer = {} } = getState();
const { listShortUrls } = buildShlinkApiClient(selectedServer);
const { listShortUrls } = await buildShlinkApiClient(getState);
try {
const shortUrls = await listShortUrls(params);

View File

@@ -1,3 +1,4 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { LIST_SHORT_URLS } from './shortUrlsList';
@@ -7,19 +8,16 @@ export const shortUrlsListParamsType = PropTypes.shape({
page: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
searchTerm: PropTypes.string,
startDate: PropTypes.string,
endDate: PropTypes.string,
orderBy: PropTypes.object,
});
const defaultState = { page: '1' };
const initialState = { page: '1' };
export default function reducer(state = defaultState, action) {
switch (action.type) {
case LIST_SHORT_URLS:
return { ...state, ...action.params };
case RESET_SHORT_URL_PARAMS:
return defaultState;
default:
return state;
}
}
export default handleActions({
[LIST_SHORT_URLS]: (state, { params }) => ({ ...state, ...params }),
[RESET_SHORT_URL_PARAMS]: () => initialState,
}, initialState);
export const resetShortUrlParams = () => ({ type: RESET_SHORT_URL_PARAMS });
export const resetShortUrlParams = createAction(RESET_SHORT_URL_PARAMS);

View File

@@ -8,11 +8,13 @@ import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
import CreateShortUrl from '../CreateShortUrl';
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
import EditTagsModal from '../helpers/EditTagsModal';
import EditMetaModal from '../helpers/EditMetaModal';
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags';
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
const provideServices = (bottle, connect) => {
@@ -23,7 +25,7 @@ const provideServices = (bottle, connect) => {
));
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams', 'selectedServer' ], [ 'listShortUrls' ]));
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.decorator('ShortUrlsList', connect(
@@ -33,13 +35,13 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal');
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal', 'EditMetaModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
bottle.decorator(
'CreateShortUrl',
connect([ 'shortUrlCreationResult' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
);
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
@@ -54,6 +56,9 @@ const provideServices = (bottle, connect) => {
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
));
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
// Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
@@ -68,6 +73,9 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
};
export default provideServices;

View File

@@ -1,52 +1,36 @@
import { handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const tagDeleteType = PropTypes.shape({
deleting: PropTypes.bool,
error: PropTypes.bool,
});
const defaultState = {
const initialState = {
deleting: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case DELETE_TAG_START:
return {
deleting: true,
error: false,
};
case DELETE_TAG_ERROR:
return {
deleting: false,
error: true,
};
case DELETE_TAG:
return {
deleting: false,
error: false,
};
default:
return state;
}
}
export default handleActions({
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
[DELETE_TAG_ERROR]: () => ({ deleting: false, error: true }),
[DELETE_TAG]: () => ({ deleting: false, error: false }),
}, initialState);
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
dispatch({ type: DELETE_TAG_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { deleteTags } = await buildShlinkApiClient(getState);
try {
await shlinkApiClient.deleteTags([ tag ]);
await deleteTags([ tag ]);
dispatch({ type: DELETE_TAG });
} catch (e) {
dispatch({ type: DELETE_TAG_ERROR });

View File

@@ -1,44 +1,30 @@
import { pick } from 'ramda';
import { handleActions } from 'redux-actions';
/* eslint-disable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-enable padding-line-between-statements */
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
const defaultState = {
const initialState = {
oldName: '',
newName: '',
editing: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case EDIT_TAG_START:
return {
...state,
editing: true,
error: false,
};
case EDIT_TAG_ERROR:
return {
...state,
editing: false,
error: true,
};
case EDIT_TAG:
return {
...pick([ 'oldName', 'newName' ], action),
editing: false,
error: false,
};
default:
return state;
}
}
export default handleActions({
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
[EDIT_TAG_ERROR]: (state) => ({ ...state, editing: false, error: true }),
[EDIT_TAG]: (state, action) => ({
...pick([ 'oldName', 'newName' ], action),
editing: false,
error: false,
}),
}, initialState);
export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newName, color) => async (
dispatch,
@@ -46,11 +32,10 @@ export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newNa
) => {
dispatch({ type: EDIT_TAG_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const { editTag } = await buildShlinkApiClient(getState);
try {
await shlinkApiClient.editTag(oldName, newName);
await editTag(oldName, newName);
colorGenerator.setColorForKey(newName, color);
dispatch({ type: EDIT_TAG, oldName, newName });
} catch (e) {

View File

@@ -1,73 +1,47 @@
import { handleActions } from 'redux-actions';
import { isEmpty, reject } from 'ramda';
import { buildShlinkApiClientWithAxios as buildShlinkApiClient } from '../../utils/services/ShlinkApiClientBuilder';
import { TAG_DELETED } from './tagDelete';
import { TAG_EDITED } from './tagEdit';
/* eslint-disable padding-line-between-statements, newline-after-var */
const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR';
const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements, newline-after-var */
/* eslint-disable padding-line-between-statements */
export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
export const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR';
export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements */
const defaultState = {
const initialState = {
tags: [],
filteredTags: [],
loading: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case LIST_TAGS_START:
return {
...state,
loading: true,
error: false,
};
case LIST_TAGS_ERROR:
return {
...state,
loading: false,
error: true,
};
case LIST_TAGS:
return {
tags: action.tags,
filteredTags: action.tags,
loading: false,
error: false,
};
case TAG_DELETED:
return {
...state,
const renameTag = (oldName, newName) => (tag) => tag === oldName ? newName : tag;
const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, tags);
// FIXME This should be optimized somehow...
tags: reject((tag) => tag === action.tag, state.tags),
filteredTags: reject((tag) => tag === action.tag, state.filteredTags),
};
case TAG_EDITED:
const renameTag = (tag) => tag === action.oldName ? action.newName : tag;
export default handleActions({
[LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[LIST_TAGS]: (state, { tags }) => ({ tags, filteredTags: tags, loading: false, error: false }),
[TAG_DELETED]: (state, { tag }) => ({
...state,
tags: rejectTag(state.tags, tag),
filteredTags: rejectTag(state.filteredTags, tag),
}),
[TAG_EDITED]: (state, { oldName, newName }) => ({
...state,
tags: state.tags.map(renameTag(oldName, newName)).sort(),
filteredTags: state.filteredTags.map(renameTag(oldName, newName)).sort(),
}),
[FILTER_TAGS]: (state, { searchTerm }) => ({
...state,
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)),
}),
}, initialState);
return {
...state,
// FIXME This should be optimized somehow...
tags: state.tags.map(renameTag).sort(),
filteredTags: state.filteredTags.map(renameTag).sort(),
};
case FILTER_TAGS:
return {
...state,
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(action.searchTerm)),
};
default:
return state;
}
}
export const _listTags = (buildShlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList, selectedServer } = getState();
export const listTags = (buildShlinkApiClient, force = true) => () => async (dispatch, getState) => {
const { tagsList } = getState();
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
return;
@@ -76,8 +50,8 @@ export const _listTags = (buildShlinkApiClient, force = false) => async (dispatc
dispatch({ type: LIST_TAGS_START });
try {
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const tags = await shlinkApiClient.listTags();
const { listTags } = await buildShlinkApiClient(getState);
const tags = await listTags();
dispatch({ tags, type: LIST_TAGS });
} catch (e) {
@@ -85,10 +59,6 @@ export const _listTags = (buildShlinkApiClient, force = false) => async (dispatc
}
};
export const listTags = () => _listTags(buildShlinkApiClient);
export const forceListTags = () => _listTags(buildShlinkApiClient, true);
export const filterTags = (searchTerm) => ({
type: FILTER_TAGS,
searchTerm,

View File

@@ -3,7 +3,7 @@ import TagCard from '../TagCard';
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
import EditTagModal from '../helpers/EditTagModal';
import TagsList from '../TagsList';
import { filterTags, forceListTags, listTags } from '../reducers/tagsList';
import { filterTags, listTags } from '../reducers/tagsList';
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
import { editTag, tagEdited } from '../reducers/tagEdit';
@@ -24,9 +24,11 @@ const provideServices = (bottle, connect) => {
bottle.decorator('TagsList', connect([ 'tagsList' ], [ 'forceListTags', 'filterTags' ]));
// Actions
const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force);
bottle.factory('listTags', listTagsActionFactory(false));
bottle.factory('forceListTags', listTagsActionFactory(true));
bottle.serviceFactory('filterTags', () => filterTags);
bottle.serviceFactory('forceListTags', () => forceListTags);
bottle.serviceFactory('listTags', () => listTags);
bottle.serviceFactory('tagDeleted', () => tagDeleted);
bottle.serviceFactory('tagEdited', () => tagEdited);

27
src/utils/Checkbox.js Normal file
View File

@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { v4 as uuid } from 'uuid';
const propTypes = {
checked: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
children: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]),
className: PropTypes.string,
};
const Checkbox = ({ checked, onChange, className, children }) => {
const id = uuid();
const onChecked = (e) => onChange(e.target.checked, e);
return (
<span className={classNames('custom-control custom-checkbox', className)} style={{ display: 'inline' }}>
<input type="checkbox" className="custom-control-input" id={id} checked={checked} onChange={onChecked} />
<label className="custom-control-label" htmlFor={id}>{children}</label>
</span>
);
};
Checkbox.propTypes = propTypes;
export default Checkbox;

View File

@@ -4,6 +4,7 @@ import DatePicker from 'react-datepicker';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
import * as PropTypes from 'prop-types';
import classNames from 'classnames';
import './DateInput.scss';
const propTypes = {
@@ -21,7 +22,7 @@ const DateInput = (props) => {
<div className="date-input-container">
<DatePicker
{...props}
className={`date-input-container__input form-control ${className || ''}`}
className={classNames('date-input-container__input form-control', className)}
dateFormat="YYYY-MM-DD"
readOnly
ref={ref}

40
src/utils/DateRangeRow.js Normal file
View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import DateInput from './DateInput';
import './DateRangeRow.scss';
const dateType = PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]);
const propTypes = {
startDate: dateType,
endDate: dateType,
onStartDateChange: PropTypes.func.isRequired,
onEndDateChange: PropTypes.func.isRequired,
};
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => (
<div className="row">
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
<DateInput
selected={startDate}
placeholderText="Since"
isClearable
maxDate={endDate}
onChange={onStartDateChange}
/>
</div>
<div className="col-xl-3 col-lg-4 col-md-6">
<DateInput
className="date-range-row__date-input"
selected={endDate}
placeholderText="Until"
isClearable
minDate={startDate}
onChange={onEndDateChange}
/>
</div>
</div>
);
DateRangeRow.propTypes = propTypes;
export default DateRangeRow;

View File

@@ -1,6 +1,6 @@
@import '../utils/base';
.short-url-visits__date-input {
.date-range-row__date-input {
@media (max-width: $smMax) {
margin-top: .5rem;
}

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