Compare commits

..

130 Commits

Author SHA1 Message Date
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
Alejandro Celaya
9a6fad4db5 Merge pull request #110 from acelaya/feature/swipe-map
Prevented side menu to be swipeable while a modal window is displayed
2019-03-03 12:11:28 +01:00
Alejandro Celaya
127bcc14eb Prevented side menu to be swipeable while a modal window is displayed 2019-03-03 12:05:29 +01:00
Alejandro Celaya
220d634f80 Merge pull request #109 from acelaya/feature/map-center
Fixed initial zoom and center on maps
2019-03-03 11:54:44 +01:00
Alejandro Celaya
6291af2865 Fixed initial zoom and center on maps 2019-03-03 11:47:19 +01:00
Alejandro Celaya
e9e808d339 Merge pull request #108 from acelaya/feature/not-found-page
Feature/not found page
2019-03-03 11:24:56 +01:00
Alejandro Celaya
780e4a6e9e Replaced component by render on route rendering not found component with custom props 2019-03-03 11:18:58 +01:00
Alejandro Celaya
c4bc2f24d6 Used not-found component for menu layout inner router 2019-03-03 11:15:34 +01:00
Alejandro Celaya
d23ddd0e0b Created NotFound component 2019-03-03 11:02:29 +01:00
Alejandro Celaya
4f0ee79409 Added missing changelogs 2019-03-03 10:33:37 +01:00
Alejandro Celaya
7b07445c5d Merge pull request #107 from acelaya/feature/improve-docker-image
Feature/improve docker image
2019-03-03 10:28:30 +01:00
Alejandro Celaya
dcbf4bfef8 Added missing docker dependency in travis config 2019-03-03 10:22:26 +01:00
Alejandro Celaya
6ec870bb08 Updated travis so that it tries to build the docker image, whcih in turn builds the project 2019-03-03 10:18:23 +01:00
Alejandro Celaya
2c6dbb42c1 Simplified Dockerfile using multi-stage build 2019-03-03 10:12:59 +01:00
Alejandro Celaya
98725dce04 Added missing array-related dependencies 2019-03-03 10:01:10 +01:00
Alejandro Celaya
2d3363153a Merge pull request #100 from acelaya/feature/tests
Feature/tests
2019-01-13 23:38:01 +01:00
Alejandro Celaya
9318c1c6fb Updated changelog with v2.0.0 2019-01-13 23:32:23 +01:00
Alejandro Celaya
11d49fb70f Created DeleteTagConfirmModal test 2019-01-13 23:31:10 +01:00
Alejandro Celaya
056286636d Created ScrollToTop test 2019-01-13 23:03:31 +01:00
Alejandro Celaya
d020ed0b13 Created ShortUrlsRowMenu test 2019-01-13 13:08:47 +01:00
Alejandro Celaya
30b4cb4068 Created ShortUrlsRow test 2019-01-13 09:49:02 +01:00
Alejandro Celaya
1aa1d29d97 Removed direct calls between actions without DI 2019-01-12 23:59:03 +01:00
Alejandro Celaya
4f8c7afc76 Created SortableBarGraph test 2019-01-12 23:47:41 +01:00
Alejandro Celaya
c2ee688176 Merge pull request #99 from acelaya/feature/visits-pagination
Feature/visits pagination
2019-01-10 20:17:28 +01:00
Alejandro Celaya
f58b815ef8 Fixed typo 2019-01-10 20:11:32 +01:00
Alejandro Celaya
daaf91b836 Updated changelog 2019-01-10 20:06:57 +01:00
Alejandro Celaya
dee5994b1e Fixed tests 2019-01-10 20:05:02 +01:00
Alejandro Celaya
0c1f533747 Updated short URL visits loading so that it loads visits in several requests 2019-01-10 19:50:25 +01:00
Alejandro Celaya
58d3a59e58 Merge pull request #98 from acelaya/feature/clean-api-client
Feature/clean api client
2019-01-10 19:26:28 +01:00
Alejandro Celaya
811008ee1c Updated changelog 2019-01-10 19:20:09 +01:00
Alejandro Celaya
23af0de34a Simplified ShlinkApiCLient by using the new simplified authentication approach 2019-01-10 19:17:15 +01:00
Alejandro Celaya
b96ea89f90 Merge pull request #92 from acelaya/feature/map
Created component to show a map on a modal window
2019-01-09 20:39:59 +01:00
Alejandro Celaya
77a624a889 Removed not needed call to function 2019-01-09 20:34:35 +01:00
Alejandro Celaya
40892f3e91 Updated changelog 2019-01-09 20:31:52 +01:00
Alejandro Celaya
b12dac1e35 Improved map modal title 2019-01-09 20:30:59 +01:00
Alejandro Celaya
150dcd2d5d Created tests for new map-related components 2019-01-09 20:11:22 +01:00
Alejandro Celaya
c599d2837b Improved VisitsParser test 2019-01-09 07:59:56 +01:00
Alejandro Celaya
bb6fb6b9ea Created utils test 2019-01-08 21:19:38 +01:00
Alejandro Celaya
8b8be2d7ca Updated stateFlagTimeout to get the setTimeout function injected as a dependency 2019-01-08 20:49:47 +01:00
Alejandro Celaya
00d386f19f Skipped locations from unknown cities when processing cities stats for map 2019-01-07 21:11:09 +01:00
Alejandro Celaya
4870801f8f Implemented map to show visits from every city 2019-01-07 21:00:28 +01:00
Alejandro Celaya
78745366c2 Moved button to open map to separated component 2019-01-07 19:43:25 +01:00
Alejandro Celaya
2be771cbcc Improved styles 2019-01-07 13:45:16 +01:00
Alejandro Celaya
6abc0e7d02 Created component to show a map on a modal window 2019-01-07 13:35:25 +01:00
Alejandro Celaya
95220b913a Merge pull request #91 from acelaya/feature/cities-graph
Feature/cities graph
2019-01-07 12:02:11 +01:00
Alejandro Celaya
a2c1f5ce7d Improved function name 2019-01-07 11:56:54 +01:00
Alejandro Celaya
5208a266e4 Updated changelog 2019-01-07 11:54:32 +01:00
Alejandro Celaya
dc9c1712ff Added cities stats graphic on short url visits page 2019-01-07 11:53:14 +01:00
Alejandro Celaya
f9360683e9 Added docker build status badge 2019-01-07 10:37:24 +01:00
Alejandro Celaya
456f50620b Updated base docker image used during development 2019-01-07 09:50:14 +01:00
Alejandro Celaya
d76008582f Merge pull request #90 from acelaya/feature/create-react-app-2
Feature/create react app 2
2019-01-07 09:48:19 +01:00
Alejandro Celaya
6e9cbb9b28 Updated docker base image and removed deprecated MAINTAINER instruction 2019-01-07 09:41:54 +01:00
Alejandro Celaya
b5f799528d Updated changelog 2019-01-07 09:27:18 +01:00
Alejandro Celaya
f410657133 Updated build script 2019-01-07 09:24:46 +01:00
Alejandro Celaya
b1e0124aff Fixed fontawesome import 2019-01-07 09:06:29 +01:00
Alejandro Celaya
b3e3bbdd9b Slightly fixed dependencies 2019-01-06 10:46:19 +01:00
Alejandro Celaya
fac3edaea7 Updated coding styles and test configs 2019-01-05 23:16:13 +01:00
Alejandro Celaya
08488e9bad Imported config files and start script from new create react app 2019-01-05 22:54:54 +01:00
Alejandro Celaya
835d54e90c Updated dependencies and source code 2019-01-05 22:25:54 +01:00
Alejandro Celaya
10c3abd29a Happy 2019! 2019-01-05 08:42:34 +01:00
Alejandro Celaya
906e9ddb5c Updated yarn.lock 2019-01-01 12:33:53 +01:00
Alejandro Celaya
c165c8e301 Merge pull request #88 from acelaya/feature/update-dependencies
Updated linting dependencies
2018-12-31 18:31:28 +01:00
Alejandro Celaya
7d63e05900 Updated changelog 2018-12-31 18:27:16 +01:00
Alejandro Celaya
03f409a803 Updated linting dependencies 2018-12-31 18:23:47 +01:00
Alejandro Celaya
f427234373 Move out of Travis container-based infrastructure 2018-12-29 09:38:23 +01:00
Alejandro Celaya
019ce2e8ed Added v1.2.1 to changelog 2018-12-21 11:03:51 +01:00
Alejandro Celaya
e93082a64d Merge pull request #86 from acelaya/feature/code-coverage
Feature/code coverage
2018-12-21 11:03:12 +01:00
Alejandro Celaya
8a9a4f40a7 Created shortUrlsList reducer test 2018-12-21 10:58:51 +01:00
Alejandro Celaya
047d99be6d Updated ScrollTop component so that it gets the window object injected as a dependency 2018-12-21 10:38:40 +01:00
Alejandro Celaya
16cf30f26f Created EditTagsModal component test 2018-12-21 10:34:12 +01:00
Alejandro Celaya
9268114fe1 Created shortUrlDeletion reducer test 2018-12-21 10:02:42 +01:00
Alejandro Celaya
fefbb73568 Created Storage service test 2018-12-19 20:43:55 +01:00
Alejandro Celaya
12cddac27a Merge pull request #83 from acelaya/feature/insensitive-ordering
Ensured bar graphs are sorted case insensitive
2018-12-19 11:15:30 +01:00
Alejandro Celaya
77a2f32cfd Ensured bar graphs are sorted case insensitive 2018-12-19 11:07:47 +01:00
Alejandro Celaya
9d513e9ea0 Merge pull request #82 from acelaya/feature/disable-tag-build
Disabled yarn build when a tag exists in travis
2018-12-19 10:44:48 +01:00
Alejandro Celaya
9df2de5b30 Disabled yarn build when a tag exists in travis 2018-12-19 10:38:00 +01:00
Alejandro Celaya
fd2367b005 Updated nginx base image for Docker. Closes #79 2018-12-18 20:33:37 +01:00
Alejandro Celaya
a2b08277dc Merge pull request #81 from acelaya/feature/dependency-injection
Feature/dependency injection
2018-12-18 20:31:25 +01:00
Alejandro Celaya
8bd3a15a1d Updated changelog 2018-12-18 20:28:21 +01:00
Alejandro Celaya
cd11dd9848 Updated tests config excluding config files form code coverage 2018-12-18 20:24:18 +01:00
Alejandro Celaya
eec79043cc Moved common and utils services to their own service providers 2018-12-18 20:19:22 +01:00
Alejandro Celaya
4b1f5e9f4c Extracted short-url related services to its own service provider 2018-12-18 20:00:23 +01:00
Alejandro Celaya
cf1239cf6e Moved all server-related services to its own service provider 2018-12-18 19:45:09 +01:00
Alejandro Celaya
566322a8c5 Extracted tag related services to its own service provider 2018-12-18 14:55:00 +01:00
Alejandro Celaya
fa3e1eba93 Moved all visits-related services to its own service provide function inside visits 2018-12-18 14:36:32 +01:00
Alejandro Celaya
471322f4db Implemented dependency injection in all tag related components 2018-12-18 11:28:15 +01:00
Alejandro Celaya
79a0a5e4ea Fixed tests 2018-12-18 10:23:09 +01:00
Alejandro Celaya
4f54e3315f Simplified ShlinkApiClient and moved runtime creation logic to external service 2018-12-18 10:14:25 +01:00
Alejandro Celaya
7bd4b39b5a Added lazy loading to action services 2018-12-18 05:03:38 +01:00
Alejandro Celaya
12ddeebedf Registered first actions as services 2018-12-18 04:54:32 +01:00
Alejandro Celaya
d6e53918a2 Created function which dynamically resolve action services from the container for connected components 2018-12-18 04:34:37 +01:00
Alejandro Celaya
bab1e57ab1 Registered remaining short URLs components in DI container 2018-12-17 23:11:55 +01:00
Alejandro Celaya
bec755b121 Fixed tests 2018-12-17 22:32:51 +01:00
Alejandro Celaya
5616d045ab Migrated a lot more components to new DI system 2018-12-17 22:18:47 +01:00
Alejandro Celaya
5e6ad14a85 More components migrated for dependency injection 2018-12-17 20:24:31 +01:00
Alejandro Celaya
79a518b02d Registered first components as services 2018-12-17 20:03:36 +01:00
156 changed files with 8740 additions and 5042 deletions

View File

@@ -1,4 +1,5 @@
./build
./coverage
./dist
./node_modules
./test

View File

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

4
.gitignore vendored
View File

@@ -1,8 +1,5 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
package-lock.json
# testing
/coverage
@@ -11,7 +8,6 @@ package-lock.json
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local

View File

@@ -1,22 +1,23 @@
language: node_js
sudo: false
node_js:
- "stable"
- "10.15.3"
cache:
yarn: true
directories:
- node_modules
services:
- docker
install:
- yarn install
script:
- yarn lint
- yarn test:ci
- yarn build # Make sure the app can be built without errors
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi # Test docker image build only when no tag is present
after_success:
- yarn ocular coverage/clover.xml

View File

@@ -4,6 +4,134 @@ 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.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
* *Nothing*
#### Changed
* [#106](https://github.com/shlinkio/shlink-web-client/issues/106) Reduced size of docker image by using a multi-stage build Dockerfile.
* [#95](https://github.com/shlinkio/shlink-web-client/issues/95) Tested docker image build during travis executions.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#104](https://github.com/shlinkio/shlink-web-client/issues/104) Fixed blank page being showed when not-found paths are loaded.
* [#94](https://github.com/shlinkio/shlink-web-client/issues/94) Fixed initial zoom and center on maps.
* [#93](https://github.com/shlinkio/shlink-web-client/issues/93) Prevented side menu to be swipeable while a modal window is displayed.
## 2.0.0 - 2019-01-13
#### Added
* [#54](https://github.com/shlinkio/shlink-web-client/issues/54) Added stats by city graphic in visits page.
* [#55](https://github.com/shlinkio/shlink-web-client/issues/55) Added map in visits page locating cities from which visits have occurred.
#### Changed
* [#87](https://github.com/shlinkio/shlink-web-client/issues/87) and [#89](https://github.com/shlinkio/shlink-web-client/issues/89) Updated all dependencies to latest major versions.
* [#96](https://github.com/shlinkio/shlink-web-client/issues/96) Updated visits page to load visits in multiple paginated requests of `5000` visits when used shlink server supports it. This will prevent shlink to hang when trying to load big amounts of visits.
* [#71](https://github.com/shlinkio/shlink-web-client/issues/71) Improved tests and increased code coverage.
#### Deprecated
* *Nothing*
#### Removed
* [#59](https://github.com/shlinkio/shlink-web-client/issues/59) Dropped support for old browsers. Internet explorer and dead browsers are no longer supported.
* [#97](https://github.com/shlinkio/shlink-web-client/issues/97) Dropped support for authentication via `Authorization` header with Bearer type and JWT, which will make this version no longer work with shlink earlier than v1.13.0.
#### Fixed
* *Nothing*
## 1.2.1 - 2018-12-21
#### Added
* *Nothing*
#### Changed
* [#80](https://github.com/shlinkio/shlink-web-client/issues/80) Deeply refactored app to do true dependency injection with an IoC container.
* [#79](https://github.com/shlinkio/shlink-web-client/issues/79) Updated to nginx 1.15.7 as the base docker image.
* [#75](https://github.com/shlinkio/shlink-web-client/issues/75) Prevented duplicated `yarn build` in travis when a tag exists.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#77](https://github.com/shlinkio/shlink-web-client/issues/77) Sortable graphs ordering is now case insensitive.
## 1.2.0 - 2018-11-01
#### Added

View File

@@ -1,21 +1,8 @@
FROM nginx:1.15.2-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
FROM node:10.15.3-alpine as node
COPY . /shlink-web-client
RUN cd /shlink-web-client && yarn install && yarn build
# Install node and yarn
RUN apk add --no-cache --virtual nodejs && apk add --no-cache --virtual yarn
ADD . ./shlink-web-client
# Install dependencies and build project
RUN cd ./shlink-web-client && \
yarn install && \
yarn build && \
# Move build contents to document root
cd .. && \
rm -r /usr/share/nginx/html/* && \
mv ./shlink-web-client/build/* /usr/share/nginx/html && \
rm -r ./shlink-web-client && \
# Delete and uninstall build tools
yarn cache clean && apk del yarn && apk del nodejs
FROM nginx:1.15.9-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html
COPY --from=node /shlink-web-client/build /usr/share/nginx/html

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 shlinkio
Copyright (c) 2018-2019 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,6 +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/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)

View File

@@ -43,12 +43,12 @@ dotenvFiles.forEach((dotenvFile) => {
// We support resolving modules according to `NODE_PATH`.
// This lets you use absolute paths in imports inside large monorepos:
// https://github.com/facebookincubator/create-react-app/issues/253.
// https://github.com/facebook/create-react-app/issues/253.
// It works similar to `NODE_PATH` in Node itself:
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims.
// https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421
// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
// We also resolve them to make sure all tools using them work consistently.
const appDirectory = fs.realpathSync(process.cwd());

View File

@@ -6,6 +6,24 @@ const path = require('path');
module.exports = {
process(src, filename) {
return `module.exports = ${JSON.stringify(path.basename(filename))};`;
const assetFilename = JSON.stringify(path.basename(filename));
if (filename.match(/\.svg$/)) {
return `module.exports = {
__esModule: true,
default: ${assetFilename},
ReactComponent: (props) => ({
$$typeof: Symbol.for('react.element'),
type: 'svg',
ref: null,
key: null,
props: Object.assign({}, props, {
children: ${assetFilename}
})
}),
};`;
}
return `module.exports = ${assetFilename};`;
},
};

View File

@@ -4,22 +4,22 @@ const fs = require('fs');
const url = require('url');
// Make sure any symlinks in the project folder are resolved:
// https://github.com/facebookincubator/create-react-app/issues/637
// https://github.com/facebook/create-react-app/issues/637
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath);
const envPublicUrl = process.env.PUBLIC_URL;
function ensureSlash(path, needsSlash) {
const hasSlash = path.endsWith('/');
function ensureSlash(inputPath, needsSlash) {
const hasSlash = inputPath.endsWith('/');
if (hasSlash && !needsSlash) {
return path.substr(path, path.length - 1);
return inputPath.substr(0, inputPath.length - 1);
} else if (!hasSlash && needsSlash) {
return `${path}/`;
return `${inputPath}/`;
}
return path;
return inputPath;
}
const getPublicUrl = (appPackageJson) =>
@@ -33,23 +33,55 @@ const getPublicUrl = (appPackageJson) =>
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
function getServedPath(appPackageJson) {
const publicUrl = getPublicUrl(appPackageJson);
const servedUrl = envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/');
const servedUrl =
envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/');
return ensureSlash(servedUrl, true);
}
const moduleFileExtensions = [
'web.mjs',
'mjs',
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
];
// Resolve file paths in the same order as webpack
const resolveModule = (resolveFn, filePath) => {
const extension = moduleFileExtensions.find((extension) =>
fs.existsSync(resolveFn(`${filePath}.${extension}`)));
if (extension) {
return resolveFn(`${filePath}.${extension}`);
}
return resolveFn(`${filePath}.js`);
};
// config after eject: we're in ./config/
module.exports = {
dotenv: resolveApp('.env'),
appPath: resolveApp('.'),
appBuild: resolveApp('build'),
appPublic: resolveApp('public'),
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveApp('src/index.js'),
appIndexJs: resolveModule(resolveApp, 'src/index'),
appPackageJson: resolveApp('package.json'),
appSrc: resolveApp('src'),
appTsConfig: resolveApp('tsconfig.json'),
yarnLockFile: resolveApp('yarn.lock'),
testsSetup: resolveApp('src/setupTests.js'),
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
proxySetup: resolveApp('src/setupProxy.js'),
appNodeModules: resolveApp('node_modules'),
publicUrl: getPublicUrl(resolveApp('package.json')),
servedPath: getServedPath(resolveApp('package.json')),
};
module.exports.moduleFileExtensions = moduleFileExtensions;

View File

@@ -1,21 +0,0 @@
if (typeof Promise === 'undefined') {
// Rejection tracking prevents a common issue where React gets into an
// inconsistent state due to an error, but it gets swallowed by a Promise,
// and the user has no idea what causes React's erratic future behavior.
require('promise/lib/rejection-tracking').enable();
window.Promise = require('promise/lib/es6-extensions.js');
}
// fetch() polyfill for making API calls.
require('whatwg-fetch');
// Object.assign() is commonly used with React.
// It will use the native implementation if it's present and isn't buggy.
Object.assign = require('object-assign');
// In tests, polyfill requestAnimationFrame since jsdom doesn't provide it yet.
// We don't polyfill it in the browser--this is user's responsibility.
if (process.env.NODE_ENV === 'test') {
require('raf').polyfill(global);
}

View File

@@ -1,308 +0,0 @@
const path = require('path');
const autoprefixer = require('autoprefixer');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const eslintFormatter = require('react-dev-utils/eslintFormatter');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const getClientEnvironment = require('./env');
const paths = require('./paths');
// Webpack uses `publicPath` to determine where the app is being served from.
// In development, we always serve from the root. This makes config easier.
const publicPath = '/';
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_PATH%/xyz looks better than %PUBLIC_PATH%xyz.
const publicUrl = '';
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
const postCssLoader = {
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
autoprefixer({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9',
],
flexbox: 'no-2009',
}),
],
},
};
// This is the development configuration.
// It is focused on developer experience and fast rebuilds.
// The production configuration is different and lives in a separate file.
module.exports = {
// You may want 'eval' instead if you prefer to see the compiled output in DevTools.
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
devtool: 'cheap-module-source-map',
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
// The first two entry points enable "hot" CSS and auto-refreshes for JS.
entry: [
// We ship a few polyfills by default:
require.resolve('./polyfills'),
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
require.resolve('react-dev-utils/webpackHotDevClient'),
// Finally, this is your app's code:
paths.appIndexJs,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
],
output: {
// Add /* filename */ comments to generated require()s in the output.
pathinfo: true,
// This does not produce a real file. It's just the virtual path that is
// served by WebpackDevServer in development. This is the JS bundle
// containing code from all our entry points, and the Webpack runtime.
filename: 'static/js/bundle.js',
// There are also additional JS chunk files if you use code splitting.
chunkFilename: 'static/js/[name].chunk.js',
// This is the URL that app is served from. We use "/" in development.
publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: (info) =>
path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'),
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: [ 'node_modules', paths.appNodeModules ].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebookincubator/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: [ '.web.js', '.mjs', '.js', '.json', '.web.jsx', '.jsx' ],
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
],
},
module: {
strictExportPresence: true,
rules: [
// TODO: Disable require.ensure as it's not a standard language feature.
// We are waiting for https://github.com/facebookincubator/create-react-app/issues/2176.
// { parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|jsx|mjs)$/,
enforce: 'pre',
use: [
{
options: {
formatter: eslintFormatter,
eslintPath: require.resolve('eslint'),
},
loader: require.resolve('eslint-loader'),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [ /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/ ],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
// Process JS with Babel.
{
test: /\.(js|jsx|mjs)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
},
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use a plugin to extract that CSS to a file, but
// in development "style" loader enables hot editing of CSS.
{
test: /\.css$/,
use: [
require.resolve('style-loader'),
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
},
},
postCssLoader,
],
},
{
test: /\.scss$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [ 'css-loader', 'sass-loader', postCssLoader ],
}),
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [ /\.(js|jsx|mjs)$/, /\.html$/, /\.json$/ ],
loader: require.resolve('file-loader'),
options: {
name: 'static/media/[name].[hash:8].[ext]',
},
},
],
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
plugins: [
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In development, this will be an empty string.
new InterpolateHtmlPlugin(env.raw),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appHtml,
}),
// Add module names to factory functions so they appear in browser profiler.
new webpack.NamedModulesPlugin(),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'development') { ... }. See `./env.js`.
new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (currently CSS only):
new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebookincubator/create-react-app/issues/240
new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebookincubator/create-react-app/issues/186
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
new ExtractTextPlugin('main.css'),
],
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: 'empty',
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
// Turn off performance hints during development because we don't do any
// splitting or minification in interest of speed. These warnings become
// cumbersome.
performance: {
hints: false,
},
};

678
config/webpack.config.js Normal file
View File

@@ -0,0 +1,678 @@
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const resolve = require('resolve');
const PnpWebpackPlugin = require('pnp-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
const TerserPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const safePostCssParser = require('postcss-safe-parser');
const ManifestPlugin = require('webpack-manifest-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin-alt');
const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
const getClientEnvironment = require('./env');
const paths = require('./paths');
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
// makes for a smoother build process.
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
// Check if TypeScript is setup
const useTypeScript = fs.existsSync(paths.appTsConfig);
// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
// This is the production and development configuration.
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
/* eslint-disable complexity */
module.exports = (webpackEnv) => {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
// In development, we always serve from the root. This makes config easier.
const publicPath = isEnvProduction
? paths.servedPath
: isEnvDevelopment && '/';
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === './';
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = isEnvProduction
? publicPath.slice(0, -1)
: isEnvDevelopment && '';
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// common function to get style loaders
const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
options: Object.assign(
{},
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined
),
},
{
loader: require.resolve('css-loader'),
options: cssOptions,
},
{
// Options for PostCSS as we reference these options twice
// Adds vendor prefixing based on your specified browser support in
// package.json
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebook/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
}),
],
sourceMap: isEnvProduction && shouldUseSourceMap,
},
},
].filter(Boolean);
if (preProcessor) {
loaders.push({
loader: require.resolve(preProcessor),
options: {
sourceMap: isEnvProduction && shouldUseSourceMap,
},
});
}
return loaders;
};
return {
mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
// Stop compilation early in production
bail: isEnvProduction,
devtool: isEnvProduction
? shouldUseSourceMap
? 'source-map'
: false
: isEnvDevelopment && 'cheap-module-source-map',
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
entry: [
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
isEnvDevelopment &&
require.resolve('react-dev-utils/webpackHotDevClient'),
// Finally, this is your app's code:
paths.appIndexJs,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
].filter(Boolean),
output: {
// The build folder.
path: isEnvProduction ? paths.appBuild : undefined,
// Add /* filename */ comments to generated require()s in the output.
pathinfo: isEnvDevelopment,
// There will be one main bundle, and one file per asynchronous chunk.
// In development, it does not produce real files.
filename: isEnvProduction
? 'static/js/[name].[chunkhash:8].js'
: isEnvDevelopment && 'static/js/bundle.js',
// There are also additional JS chunk files if you use code splitting.
chunkFilename: isEnvProduction
? 'static/js/[name].[chunkhash:8].chunk.js'
: isEnvDevelopment && 'static/js/[name].chunk.js',
// We inferred the "public path" (such as / or /my-project) from homepage.
// We use "/" in development.
publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: isEnvProduction
? (info) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, '/')
: isEnvDevelopment &&
((info) => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
},
optimization: {
minimize: isEnvProduction,
minimizer: [
// This is only used in production mode
new TerserPlugin({
terserOptions: {
parse: {
// we want terser to parse ecma 8 code. However, we don't want it
// to apply any minfication steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 8,
},
compress: {
ecma: 5,
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebook/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
// Disabled because of an issue with Terser breaking valid code:
// https://github.com/facebook/create-react-app/issues/5250
// Pending futher investigation:
// https://github.com/terser-js/terser/issues/120
inline: 2,
},
mangle: {
safari10: true,
},
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
ascii_only: true,
},
},
// Use multi-process parallel running to improve the build speed
// Default number of concurrent runs: os.cpus().length - 1
parallel: true,
// Enable file caching
cache: true,
sourceMap: shouldUseSourceMap,
}),
// This is only used in production mode
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
parser: safePostCssParser,
map: shouldUseSourceMap
? {
// `inline: false` forces the sourcemap to be output into a
// separate file
inline: false,
// `annotation: true` appends the sourceMappingURL to the end of
// the css file, helping the browser find the sourcemap
annotation: true,
}
: false,
},
}),
],
// Automatically split vendor and commons
// https://twitter.com/wSokra/status/969633336732905474
// https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
splitChunks: {
chunks: 'all',
name: false,
},
// Keep the runtime chunk separated to enable long term caching
// https://twitter.com/wSokra/status/969679223278505985
runtimeChunk: true,
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebook/create-react-app/issues/253
modules: [ 'node_modules' ].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebook/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: paths.moduleFileExtensions
.map((ext) => `.${ext}`)
.filter((ext) => useTypeScript || !ext.includes('ts')),
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
},
plugins: [
// Adds support for installing with Plug'n'Play, leading to faster installs and adding
// guards against forgotten dependencies and such.
PnpWebpackPlugin,
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
],
},
resolveLoader: {
plugins: [
// Also related to Plug'n'Play, but this time it tells Webpack to load its loaders
// from the current package.
PnpWebpackPlugin.moduleLoader(module),
],
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|mjs|jsx)$/,
enforce: 'pre',
use: [
{
options: {
formatter: require.resolve('react-dev-utils/eslintFormatter'),
eslintPath: require.resolve('eslint'),
},
loader: require.resolve('eslint-loader'),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [ /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/ ],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
// Process application JS with Babel.
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
customize: require.resolve(
'babel-preset-react-app/webpack-overrides'
),
plugins: [
[
require.resolve('babel-plugin-named-asset-import'),
{
loaderMap: {
svg: {
ReactComponent:
'@svgr/webpack?-prettier,-svgo![path]',
},
},
},
],
],
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
cacheCompression: isEnvProduction,
compact: isEnvProduction,
},
},
// Process any JS outside of the app with Babel.
// Unlike the application JS, we only compile the standard ES features.
{
test: /\.(js|mjs)$/,
exclude: /@babel(?:\/|\\{1,2})runtime/,
loader: require.resolve('babel-loader'),
options: {
babelrc: false,
configFile: false,
compact: false,
presets: [
[
require.resolve('babel-preset-react-app/dependencies'),
{ helpers: true },
],
],
cacheDirectory: true,
cacheCompression: isEnvProduction,
// If an error happens in a package, it's possible to be
// because it was compiled. Thus, we don't want the browser
// debugger to show the original code. Instead, the code
// being evaluated would be much more helpful.
sourceMaps: false,
},
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction && shouldUseSourceMap,
}),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
// using the extension .module.css
{
test: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction && shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
}),
},
// Opt-in support for SASS (using .scss or .sass extensions).
// By default we support SASS Modules with the
// extensions .module.scss or .module.sass
{
test: sassRegex,
exclude: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: isEnvProduction && shouldUseSourceMap,
},
'sass-loader'
),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules, but using SASS
// using the extension .module.scss or .module.sass
{
test: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: isEnvProduction && shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
},
'sass-loader'
),
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
loader: require.resolve('file-loader'),
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise be processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [ /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/ ],
options: {
name: 'static/media/[name].[hash:8].[ext]',
},
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
],
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: undefined
)
),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
isEnvProduction &&
shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [ /runtime~.+[.]js/ ]),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In production, it will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
// In development, this will be an empty string.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production
// during a production build.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (currently CSS only):
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebook/create-react-app/issues/186
isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
isEnvProduction &&
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: 'static/css/[name].[contenthash:8].css',
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
}),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: 'asset-manifest.json',
publicPath,
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the Webpack build.
isEnvProduction &&
new WorkboxWebpackPlugin.GenerateSW({
clientsClaim: true,
exclude: [ /\.map$/, /asset-manifest\.json$/ ],
importWorkboxFrom: 'cdn',
navigateFallback: `${publicUrl}/index.html`,
navigateFallbackBlacklist: [
// Exclude URLs starting with /_, as they're likely an API call
new RegExp('^/_'),
// Exclude URLs containing a dot, as they're likely a resource in
// public/ and not a SPA route
new RegExp('/[^/]+\\.[^/]+$'),
],
}),
// TypeScript type checking
useTypeScript &&
new ForkTsCheckerWebpackPlugin({
typescript: resolve.sync('typescript', {
basedir: paths.appNodeModules,
}),
async: false,
checkSyntacticErrors: true,
tsconfig: paths.appTsConfig,
compilerOptions: {
module: 'esnext',
moduleResolution: 'node',
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
jsx: 'preserve',
},
reportFiles: [
'**',
'!**/*.json',
'!**/__tests__/**',
'!**/?(*.)(spec|test).*',
'!**/src/setupProxy.*',
'!**/src/setupTests.*',
],
watch: paths.appSrc,
silent: true,
formatter: typescriptFormatter,
}),
].filter(Boolean),
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: 'empty',
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false,
};
};

View File

@@ -1,385 +0,0 @@
const path = require('path');
const autoprefixer = require('autoprefixer');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const ManifestPlugin = require('webpack-manifest-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
const eslintFormatter = require('react-dev-utils/eslintFormatter');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const paths = require('./paths');
const getClientEnvironment = require('./env');
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
const publicPath = paths.servedPath;
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === './';
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = publicPath.slice(0, -1);
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// Assert this just to be safe.
// Development builds of React are slow and not intended for production.
if (env.stringified['process.env'].NODE_ENV !== '"production"') {
throw new Error('Production builds must have NODE_ENV=production.');
}
// Note: defined here because it will be used more than once.
const cssFilename = 'static/css/[name].[contenthash:8].css';
// ExtractTextPlugin expects the build output to be flat.
// (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27)
// However, our output is structured with css, js and media folders.
// To have this structure working with relative paths, we have to use custom options.
// Making sure that the publicPath goes back to to build folder.
const extractTextPluginOptions = shouldUseRelativeAssetPaths
? { publicPath: Array(cssFilename.split('/').length).join('../') }
: {};
// This is the production configuration.
// It compiles slowly and is focused on producing a fast and minimal bundle.
// The development configuration is different and lives in a separate file.
module.exports = {
// Don't attempt to continue if there are any errors.
bail: true,
// We generate sourcemaps in production. This is slow but gives good results.
// You can exclude the *.map files from the build during deployment.
devtool: shouldUseSourceMap ? 'source-map' : false,
// In production, we only want to load the polyfills and the app code.
entry: [ require.resolve('./polyfills'), paths.appIndexJs ],
output: {
// The build folder.
path: paths.appBuild,
// Generated JS file names (with nested folders).
// There will be one main bundle, and one file per asynchronous chunk.
// We don't currently advertise code splitting but Webpack supports it.
filename: 'static/js/[name].[chunkhash:8].js',
chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: (info) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, '/'),
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: [ 'node_modules', paths.appNodeModules ].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebookincubator/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: [ '.web.js', '.mjs', '.js', '.json', '.web.jsx', '.jsx' ],
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
],
},
module: {
strictExportPresence: true,
rules: [
// TODO: Disable require.ensure as it's not a standard language feature.
// We are waiting for https://github.com/facebookincubator/create-react-app/issues/2176.
// { parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|jsx|mjs)$/,
enforce: 'pre',
use: [
{
options: {
formatter: eslintFormatter,
eslintPath: require.resolve('eslint'),
},
loader: require.resolve('eslint-loader'),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// "url" loader works just like "file" loader but it also embeds
// assets smaller than specified size as data URLs to avoid requests.
{
test: [ /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/ ],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
// Process JS with Babel.
{
test: /\.(js|jsx|mjs)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
compact: true,
},
},
// The notation here is somewhat confusing.
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader normally turns CSS into JS modules injecting <style>,
// but unlike in development configuration, we do something different.
// `ExtractTextPlugin` first applies the "postcss" and "css" loaders
// (second argument), then grabs the result CSS and puts it into a
// separate file in our build process. This way we actually ship
// a single CSS file in production instead of JS code injecting <style>
// tags. If you use code splitting, however, any async bundles will still
// use the "style" loader inside the async code so CSS from them won't be
// in the main CSS file.
{
test: [ /\.css$/, /\.scss$/ ],
loader: ExtractTextPlugin.extract(
Object.assign(
{
fallback: {
loader: require.resolve('style-loader'),
options: {
hmr: false,
},
},
use: [
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
minimize: true,
sourceMap: shouldUseSourceMap,
},
},
{
loader: require.resolve('sass-loader'),
},
{
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
autoprefixer({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9',
],
flexbox: 'no-2009',
}),
],
},
},
],
},
extractTextPluginOptions
)
),
// Note: this won't work without `new ExtractTextPlugin()` in `plugins`.
},
// "file" loader makes sure assets end up in the `build` folder.
// When you `import` an asset, you get its filename.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
loader: require.resolve('file-loader'),
// Exclude `js` files to keep "css" loader working as it injects
// it's runtime that would otherwise processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [ /\.(js|jsx|mjs)$/, /\.html$/, /\.json$/ ],
options: {
name: 'static/media/[name].[hash:8].[ext]',
},
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
],
},
plugins: [
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In production, it will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
new InterpolateHtmlPlugin(env.raw),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appHtml,
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV was set to production here.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// Minify the code.
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebookincubator/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
},
mangle: {
safari10: true,
},
output: {
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebookincubator/create-react-app/issues/2488
ascii_only: true,
},
sourceMap: shouldUseSourceMap,
}),
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
new ExtractTextPlugin({
filename: cssFilename,
}),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: 'asset-manifest.json',
}),
// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the Webpack build.
new SWPrecacheWebpackPlugin({
// By default, a cache-busting query parameter is appended to requests
// used to populate the caches, to ensure the responses are fresh.
// If a URL is already hashed by Webpack, then there is no concern
// about it being stale, and the cache-busting can be skipped.
dontCacheBustUrlsMatching: /\.\w{8}\./,
filename: 'service-worker.js',
logger(message) {
if (message.indexOf('Total precache size is') === 0) {
// This message occurs for every build and is a bit too noisy.
return;
}
if (message.indexOf('Skipping static resource') === 0) {
// This message obscures real errors so we ignore it.
// https://github.com/facebookincubator/create-react-app/issues/2612
return;
}
console.log(message); // eslint-disable-line
},
minify: true,
// For unknown URLs, fallback to the index page
navigateFallback: `${publicUrl}/index.html`,
// Ignores URLs starting from /__ (useful for Firebase):
// https://github.com/facebookincubator/create-react-app/issues/2237#issuecomment-302693219
navigateFallbackWhitelist: [ /^(?!\/__).*/ ],
// Don't precache sourcemaps (they're large) and build asset manifest:
staticFileGlobsIgnorePatterns: [ /\.map$/, /asset-manifest\.json$/ ],
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: 'empty',
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
};

View File

@@ -1,8 +1,9 @@
const fs = require('fs');
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
const evalSourceMapMiddleware = require('react-dev-utils/evalSourceMapMiddleware');
const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware');
const ignoredFiles = require('react-dev-utils/ignoredFiles');
const config = require('./webpack.config.dev');
const paths = require('./paths');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
@@ -17,8 +18,8 @@ module.exports = function(proxy, allowedHost) {
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
// However, it made several existing use cases such as development in cloud
// environment or subdomains in development significantly more complicated:
// https://github.com/facebookincubator/create-react-app/issues/2271
// https://github.com/facebookincubator/create-react-app/issues/2233
// https://github.com/facebook/create-react-app/issues/2271
// https://github.com/facebook/create-react-app/issues/2233
// While we're investigating better solutions, for now we will take a
// compromise. Since our WDS configuration only serves files in the `public`
// folder we won't consider accessing them a vulnerability. However, if you
@@ -65,16 +66,16 @@ module.exports = function(proxy, allowedHost) {
// It is important to tell WebpackDevServer to use the same "root" path
// as we specified in the config. In development, we always serve from /.
publicPath: config.output.publicPath,
publicPath: '/',
// WebpackDevServer is noisy by default so we emit custom message instead
// by listening to the compiler events with `compiler.plugin` calls above.
// by listening to the compiler events with `compiler.hooks[...].tap` calls above.
quiet: true,
// Reportedly, this avoids CPU overload on some systems.
// https://github.com/facebookincubator/create-react-app/issues/293
// https://github.com/facebook/create-react-app/issues/293
// src/node_modules is not ignored to support absolute imports
// https://github.com/facebookincubator/create-react-app/issues/1065
// https://github.com/facebook/create-react-app/issues/1065
watchOptions: {
ignored: ignoredFiles(paths.appSrc),
},
@@ -86,12 +87,20 @@ module.exports = function(proxy, allowedHost) {
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebookincubator/create-react-app/issues/387.
// See https://github.com/facebook/create-react-app/issues/387.
disableDotRule: true,
},
public: allowedHost,
proxy,
before(app) {
before(app, server) {
if (fs.existsSync(paths.proxySetup)) {
// This registers user provided middleware for proxy reasons
require(paths.proxySetup)(app);
}
// This lets us fetch source contents from webpack for the error overlay
app.use(evalSourceMapMiddleware(server));
// This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware());
@@ -99,7 +108,7 @@ module.exports = function(proxy, allowedHost) {
// previous service worker registered for the same host:port combination.
// We do this in development to avoid hitting the production cache if
// it used the same host and port.
// https://github.com/facebookincubator/create-react-app/issues/2272#issuecomment-302832432
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
app.use(noopServiceWorkerMiddleware());
},
};

View File

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

View File

@@ -1,2 +1,8 @@
#!/usr/bin/env bash
# Run docker container if it's not up yet
if ! [[ $(docker ps | grep shlink_web_client_node) ]]; then
docker-compose up -d
fi
docker exec -it shlink_web_client_node /bin/sh -c "cd /home/shlink/www && $*"

View File

@@ -1,33 +1,44 @@
module.exports = {
coverageDirectory: '<rootDir>/coverage',
collectCoverageFrom: [
'src/**/*.{js,jsx,mjs}',
'src/**/*.js',
'!src/registerServiceWorker.js',
'!src/index.js',
'!src/reducers/index.js',
'!src/**/provideServices.js',
'!src/container/*.js',
],
resolver: 'jest-pnp-resolver',
setupFiles: [
'<rootDir>/config/polyfills.js',
'react-app-polyfill/jsdom',
'<rootDir>/config/setupEnzyme.js',
],
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,mjs}' ],
testEnvironment: 'node',
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
testEnvironment: 'jsdom',
testURL: 'http://localhost',
transform: {
'^.+\\.(js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
},
transformIgnorePatterns: [ '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$' ],
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$',
],
moduleNameMapper: {
'^react-native$': 'react-native-web',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
},
moduleFileExtensions: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node',
'mjs',
],
};

View File

@@ -18,27 +18,34 @@
"test:pretty": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html"
},
"dependencies": {
"@fortawesome/fontawesome": "^1.1.8",
"@fortawesome/fontawesome-free-regular": "^5.0.13",
"@fortawesome/fontawesome-free-solid": "^5.0.13",
"@fortawesome/react-fontawesome": "0.0.19",
"@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",
"array-filter": "^1.0.0",
"array-map": "^0.0.0",
"array-reduce": "^0.0.0",
"axios": "^0.18.0",
"bootstrap": "^4.1.1",
"bootstrap": "^4.3.1",
"bottlejs": "^1.7.1",
"chart.js": "^2.7.2",
"classnames": "^2.2.6",
"csvjson": "^5.1.0",
"leaflet": "^1.4.0",
"moment": "^2.22.2",
"promise": "8.0.1",
"promise": "^8.0.1",
"prop-types": "^15.6.2",
"qs": "^6.5.2",
"ramda": "^0.25.0",
"react": "^16.6",
"ramda": "^0.26.1",
"react": "^16.8.0",
"react-autosuggest": "^9.4.0",
"react-chartjs-2": "^2.7.4",
"react-color": "^2.14.1",
"react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "^1.5.0",
"react-dom": "^16.3.2",
"react-datepicker": "~1.5.0",
"react-dom": "^16.8.0",
"react-leaflet": "^2.2.1",
"react-moment": "^0.7.6",
"react-redux": "^5.0.7",
"react-router-dom": "^4.2.2",
@@ -50,63 +57,86 @@
"uuid": "^3.3.2"
},
"devDependencies": {
"adm-zip": "^0.4.11",
"autoprefixer": "7.1.6",
"babel-core": "6.26.0",
"babel-eslint": "7.2.3",
"babel-jest": "20.0.3",
"babel-loader": "^7.1.2",
"babel-preset-react-app": "^3.1.1",
"babel-runtime": "6.26.0",
"case-sensitive-paths-webpack-plugin": "2.1.1",
"chalk": "1.1.3",
"css-loader": "0.28.7",
"dotenv": "4.0.0",
"dotenv-expand": "4.2.0",
"@babel/core": "^7.1.6",
"@svgr/webpack": "^2.4.1",
"adm-zip": "0.4.11",
"autoprefixer": "^7.1.6",
"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-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",
"eslint": "^4.19.0",
"eslint-config-adidas-babel": "^1.0.1",
"eslint-config-adidas-env": "^1.0.1",
"eslint-config-adidas-es6": "^1.0.1",
"eslint-config-adidas-react": "^1.0.1",
"eslint-loader": "1.9.0",
"eslint-plugin-import": "^2.8.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-promise": "^3.0.0",
"eslint-plugin-react": "^7.4.0",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "1.1.5",
"fs-extra": "3.0.1",
"html-webpack-plugin": "2.29.0",
"jest": "20.0.4",
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-react": "^7.11.1",
"file-loader": "^2.0.0",
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
"fs-extra": "^7.0.0",
"html-webpack-plugin": "^4.0.0-alpha.2",
"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",
"object-assign": "4.1.1",
"object-assign": "^4.1.1",
"ocular.js": "^0.1.0",
"postcss-flexbugs-fixes": "3.2.0",
"postcss-loader": "2.0.8",
"raf": "3.4.0",
"react-dev-utils": "^5.0.1",
"resolve": "1.6.0",
"sass-loader": "^7.0.1",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"pnp-webpack-plugin": "^1.1.0",
"postcss": "^7.0.7",
"postcss-flexbugs-fixes": "^4.1.0",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.3.1",
"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.19.0",
"stylelint": "^9.5.0",
"stylelint-config-adidas": "^1.0.1",
"stylelint-config-adidas-bem": "^1.0.1",
"style-loader": "^0.23.0",
"stylelint": "^9.9.0",
"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",
"url-loader": "0.6.2",
"webpack": "3.8.1",
"webpack-dev-server": "2.9.4",
"webpack-manifest-plugin": "1.3.2",
"whatwg-fetch": "2.0.3"
"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"
},
"babel": {
"presets": [
"react-app"
]
}
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}

View File

@@ -1,4 +1,4 @@
/* eslint no-console: "off" */
/* eslint-disable no-console */
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
@@ -18,14 +18,16 @@ const path = require('path');
const chalk = require('chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
const bfj = require('bfj');
const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const AdmZip = require('adm-zip');
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
const paths = require('../config/paths');
const config = require('../config/webpack.config.prod');
const configFactory = require('../config/webpack.config');
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
const useYarn = fs.existsSync(paths.yarnLockFile);
@@ -34,14 +36,27 @@ const useYarn = fs.existsSync(paths.yarnLockFile);
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; // eslint-disable-line
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
process.exit(1);
}
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
measureFileSizesBeforeBuild(paths.appBuild)
// Process CLI arguments
const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.indexOf('--stats') !== -1;
// Generate configuration
const config = configFactory('production');
checkBrowsers(paths.appPath, isInteractive)
.then(() =>
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
measureFileSizesBeforeBuild(paths.appBuild))
.then((previousFileSizes) => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
@@ -85,7 +100,7 @@ measureFileSizesBeforeBuild(paths.appBuild)
const appPackage = require(paths.appPackageJson);
const { publicUrl } = paths;
const { publicPath } = config.output;
const { output: { publicPath } } = config;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
@@ -102,7 +117,14 @@ measureFileSizesBeforeBuild(paths.appBuild)
process.exit(1);
}
)
.then(zipDist);
.then(zipDist)
.catch((err) => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
@@ -112,12 +134,22 @@ function build(previousFileSizes) {
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
let messages;
if (err) {
return reject(err);
if (!err.message) {
return reject(err);
}
messages = formatWebpackMessages({
errors: [ err.message ],
warnings: [],
});
} else {
messages = formatWebpackMessages(
stats.toJson({ all: false, warnings: true, errors: true })
);
}
const messages = formatWebpackMessages(stats.toJson({}, true));
if (messages.errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
@@ -143,11 +175,20 @@ function build(previousFileSizes) {
return reject(new Error(messages.warnings.join('\n\n')));
}
return resolve({
const resolveArgs = {
stats,
previousFileSizes,
warnings: messages.warnings,
});
};
if (writeStatsJson) {
return bfj // eslint-disable-line promise/no-promise-in-callback
.write(`${paths.appBuild}/bundle-stats.json`, stats.toJson())
.then(() => resolve(resolveArgs))
.catch((error) => reject(new Error(error)));
}
return resolve(resolveArgs);
});
});
}

View File

@@ -1,4 +1,4 @@
/* eslint no-console: "off" */
/* eslint-disable no-console */
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'development';
@@ -27,8 +27,9 @@ const {
prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const openBrowser = require('react-dev-utils/openBrowser');
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
const paths = require('../config/paths');
const config = require('../config/webpack.config.dev');
const configFactory = require('../config/webpack.config');
const createDevServerConfig = require('../config/webpackDevServer.config');
const useYarn = fs.existsSync(paths.yarnLockFile);
@@ -40,8 +41,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
}
// Tools like Cloud9 rely on this.
const FALLBACK_PORT = 3000;
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || FALLBACK_PORT;
const DEFAULT_PORT = 3000;
const PORT = parseInt(process.env.PORT) || DEFAULT_PORT;
const HOST = process.env.HOST || '0.0.0.0';
if (process.env.HOST) {
@@ -55,19 +56,25 @@ if (process.env.HOST) {
console.log(
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.'
);
console.log(`Learn more here: ${chalk.yellow('http://bit.ly/2mwWSwH')}`);
console.log(
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`
);
console.log();
}
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
choosePort(HOST, DEFAULT_PORT)
checkBrowsers(paths.appPath, isInteractive)
.then(() =>
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
choosePort(HOST, PORT))
.then((port) => {
if (port === null) {
// We have not found a port.
return;
}
const config = configFactory('development');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name;
@@ -81,7 +88,7 @@ choosePort(HOST, DEFAULT_PORT)
const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
// Serve webpack assets generated by the compiler over a web sever.
// Serve webpack assets generated by the compiler over a web server.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig
@@ -89,19 +96,17 @@ choosePort(HOST, DEFAULT_PORT)
const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer.
devServer.listen(port, HOST, (err) => {
if (err) {
console.log(err);
return;
return console.log(err);
}
if (isInteractive) {
clearConsole();
}
console.log(chalk.cyan('Starting the development server...\n'));
openBrowser(urls.localUrlForBrowser);
return openBrowser(urls.localUrlForBrowser);
});
[ 'SIGINT', 'SIGTERM' ].forEach((sig) => {

View File

@@ -1,23 +1,21 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import './App.scss';
import Home from './common/Home';
import MainHeader from './common/MainHeader';
import MenuLayout from './common/MenuLayout';
import CreateServer from './servers/CreateServer';
import NotFound from './common/NotFound';
export default function App() {
return (
<div className="container-fluid app-container">
<MainHeader />
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
<div className="container-fluid app-container">
<MainHeader />
<div className="app">
<Switch>
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} />
</Switch>
</div>
<div className="app">
<Switch>
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} />
</Switch>
</div>
);
}
</div>
);
export default App;

View File

@@ -1,119 +0,0 @@
import axios from 'axios';
import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1';
const STATUS_UNAUTHORIZED = 401;
export class ShlinkApiClient {
constructor(axios) {
this.axios = axios;
this._baseUrl = '';
this._apiKey = '';
this._token = '';
}
/**
* Sets the base URL to be used on any request
*/
setConfig = ({ url, apiKey }) => {
this._baseUrl = `${url}/rest/v${API_VERSION}`;
this._apiKey = apiKey;
};
listShortUrls = (options = {}) =>
this._performRequest('/short-codes', 'GET', options)
.then((resp) => resp.data.shortUrls)
.catch((e) => this._handleAuthError(e, this.listShortUrls, [ options ]));
createShortUrl = (options) => {
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
return this._performRequest('/short-codes', 'POST', {}, filteredOptions)
.then((resp) => resp.data)
.catch((e) => this._handleAuthError(e, this.createShortUrl, [ filteredOptions ]));
};
getShortUrlVisits = (shortCode, dates) =>
this._performRequest(`/short-codes/${shortCode}/visits`, 'GET', dates)
.then((resp) => resp.data.visits.data)
.catch((e) => this._handleAuthError(e, this.getShortUrlVisits, [ shortCode, dates ]));
getShortUrl = (shortCode) =>
this._performRequest(`/short-codes/${shortCode}`, 'GET')
.then((resp) => resp.data)
.catch((e) => this._handleAuthError(e, this.getShortUrl, [ shortCode ]));
deleteShortUrl = (shortCode) =>
this._performRequest(`/short-codes/${shortCode}`, 'DELETE')
.then(() => ({}))
.catch((e) => this._handleAuthError(e, this.deleteShortUrl, [ shortCode ]));
updateShortUrlTags = (shortCode, tags) =>
this._performRequest(`/short-codes/${shortCode}/tags`, 'PUT', {}, { tags })
.then((resp) => resp.data.tags)
.catch((e) => this._handleAuthError(e, this.updateShortUrlTags, [ shortCode, tags ]));
listTags = () =>
this._performRequest('/tags', 'GET')
.then((resp) => resp.data.tags.data)
.catch((e) => this._handleAuthError(e, this.listTags, []));
deleteTags = (tags) =>
this._performRequest('/tags', 'DELETE', { tags })
.then(() => ({ tags }))
.catch((e) => this._handleAuthError(e, this.deleteTags, [ tags ]));
editTag = (oldName, newName) =>
this._performRequest('/tags', 'PUT', {}, { oldName, newName })
.then(() => ({ oldName, newName }))
.catch((e) => this._handleAuthError(e, this.editTag, [ oldName, newName ]));
_performRequest = async (url, method = 'GET', query = {}, body = {}) => {
if (isEmpty(this._token)) {
this._token = await this._authenticate();
}
return await this.axios({
method,
url: `${this._baseUrl}${url}`,
headers: { Authorization: `Bearer ${this._token}` },
params: query,
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
}).then((resp) => {
// Save new token
const { authorization = '' } = resp.headers;
this._token = authorization.substr('Bearer '.length);
return resp;
});
};
_authenticate = async () => {
const resp = await this.axios({
method: 'POST',
url: `${this._baseUrl}/authenticate`,
data: { apiKey: this._apiKey },
});
return resp.data.token;
};
_handleAuthError = (e, method, args) => {
// If auth failed, reset token to force it to be regenerated, and perform a new request
if (e.response.status === STATUS_UNAUTHORIZED) {
this._token = '';
return method(...args);
}
// Otherwise, let caller handle the rejection
return Promise.reject(e);
};
}
const shlinkApiClient = new ShlinkApiClient(axios);
export default shlinkApiClient;

View File

@@ -1,14 +1,11 @@
import listIcon from '@fortawesome/fontawesome-free-solid/faList';
import createIcon from '@fortawesome/fontawesome-free-solid/faLink';
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faList as listIcon, faLink as createIcon, faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import DeleteServerButton from '../servers/DeleteServerButton';
import './AsideMenu.scss';
import { serverType } from '../servers/prop-types';
import './AsideMenu.scss';
const defaultProps = {
className: '',
@@ -20,51 +17,57 @@ const propTypes = {
showOnMobile: PropTypes.bool,
};
export default function AsideMenu({ selectedServer, className, showOnMobile }) {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
const AsideMenu = (DeleteServerButton) => {
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive}
>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span>
</NavLink>
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive}
>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
<FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
<FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</NavLink>
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
server={selectedServer}
/>
</nav>
</aside>
);
}
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
server={selectedServer}
/>
</nav>
</aside>
);
};
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes;
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes;
return AsideMenu;
};
export default AsideMenu;

View File

@@ -1,5 +1,4 @@
@import '../utils/base';
@import '../utils/mixins/box-shadow';
@import '../utils/mixins/vertical-align';
$asideMenuMobileWidth: 280px;
@@ -26,8 +25,7 @@ $asideMenuMobileWidth: 280px;
width: $asideMenuMobileWidth !important;
transition: left 300ms;
top: $headerHeight - 3px;
@include box-shadow(-10px 0 50px 11px rgba(0, 0, 0, .55));
box-shadow: -10px 0 50px 11px rgba(0, 0, 0, .55);
}
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import * as PropTypes from 'prop-types';
import './ErrorHandler.scss';
import { Button } from 'reactstrap';
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

@@ -1,15 +1,13 @@
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, pick, values } from 'ramda';
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, values } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import PropTypes from 'prop-types';
import { resetSelectedServer } from '../servers/reducers/selectedServer';
import './Home.scss';
export class HomeComponent extends React.Component {
export default class Home extends React.Component {
static propTypes = {
resetSelectedServer: PropTypes.func,
servers: PropTypes.object,
@@ -50,7 +48,3 @@ export class HomeComponent extends React.Component {
);
}
}
const Home = connect(pick([ 'servers' ]), { resetSelectedServer })(HomeComponent);
export default Home;

View File

@@ -1,16 +1,14 @@
import plusIcon from '@fortawesome/fontawesome-free-solid/faPlus';
import arrowIcon from '@fortawesome/fontawesome-free-solid/faChevronDown';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faPlus as plusIcon, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { Link, withRouter } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import ServersDropdown from '../servers/ServersDropdown';
import './MainHeader.scss';
import shlinkLogo from './shlink-logo-white.png';
import './MainHeader.scss';
export class MainHeaderComponent extends React.Component {
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
static propTypes = {
location: PropTypes.object,
};
@@ -62,8 +60,6 @@ export class MainHeaderComponent extends React.Component {
</Navbar>
);
}
}
const MainHeader = withRouter(MainHeaderComponent);
};
export default MainHeader;

View File

@@ -1,113 +1,112 @@
import React from 'react';
import { Route, Switch, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { pick } from 'ramda';
import { Route, Switch } from 'react-router-dom';
import Swipeable from 'react-swipeable';
import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import * as PropTypes from 'prop-types';
import ShortUrlsVisits from '../visits/ShortUrlVisits';
import { selectServer } from '../servers/reducers/selectedServer';
import CreateShortUrl from '../short-urls/CreateShortUrl';
import ShortUrls from '../short-urls/ShortUrls';
import './MenuLayout.scss';
import TagsList from '../tags/TagsList';
import { serverType } from '../servers/prop-types';
import AsideMenu from './AsideMenu';
import NotFound from './NotFound';
import './MenuLayout.scss';
export class MenuLayoutComponent extends React.Component {
static propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
class MenuLayout extends React.Component {
static propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
};
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() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;
selectServer(serverId);
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
}
render() {
const { selectedServer, match } = this.props;
const { params: { serverId } } = match;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
const swipeMenuIfNoModalExists = (showSideBar) => () => {
if (document.querySelector('.modal')) {
return;
}
this.setState({ showSideBar });
};
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(false)}
onSwipedRight={swipeMenuIfNoModalExists(true)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
className="col-lg-2 col-md-3"
selectedServer={selectedServer}
showOnMobile={this.state.showSideBar}
/>
<div
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
onClick={() => this.setState({ showSideBar: false })}
>
<Switch>
<Route
exact
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls}
/>
<Route
exact
path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
/>
</Switch>
</div>
</div>
</Swipeable>
</React.Fragment>
);
}
};
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() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;
selectServer(serverId);
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
}
render() {
const { selectedServer } = this.props;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={() => this.setState({ showSideBar: false })}
onSwipedRight={() => this.setState({ showSideBar: true })}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
className="col-lg-2 col-md-3"
selectedServer={selectedServer}
showOnMobile={this.state.showSideBar}
/>
<div
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
onClick={() => this.setState({ showSideBar: false })}
>
<Switch>
<Route
exact
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls}
/>
<Route
exact
path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlsVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
</Switch>
</div>
</div>
</Swipeable>
</React.Fragment>
);
}
}
const MenuLayout = compose(
connect(pick([ 'selectedServer', 'shortUrlsListParams' ]), { selectServer }),
withRouter
)(MenuLayoutComponent);
export default MenuLayout;

23
src/common/NotFound.js Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Link } from 'react-router-dom';
import * as PropTypes from 'prop-types';
const propTypes = {
to: PropTypes.string,
btnText: PropTypes.string,
};
const NotFound = ({ to = '/', btnText = 'Home' }) => (
<div className="home">
<h2>Oops! We could not find requested route.</h2>
<p>
Use your browser{'\''}s back button to navigate to the page you have previously come from, or just press this button.
</p>
<br />
<Link to={to} className="btn btn-outline-primary btn-lg">{btnText}</Link>
</div>
);
NotFound.propTypes = propTypes;
export default NotFound;

View File

@@ -1,32 +1,23 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
export class ScrollToTopComponent extends React.Component {
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
static propTypes = {
location: PropTypes.object,
window: PropTypes.shape({
scrollTo: PropTypes.func,
}),
children: PropTypes.node,
};
static defaultProps = {
window,
};
componentDidUpdate(prevProps) {
const { location, window } = this.props;
componentDidUpdate({ location: prevLocation }) {
const { location } = this.props;
if (location !== prevProps.location) {
window.scrollTo(0, 0);
if (location !== prevLocation) {
scrollTo(0, 0);
}
}
render() {
return this.props.children;
}
}
const ScrollToTop = withRouter(ScrollToTopComponent);
};
export default ScrollToTop;

View File

@@ -27,12 +27,12 @@
.react-tagsinput-remove {
cursor: pointer;
font-weight: bold;
font-weight: 700;
margin-left: 8px;
}
.react-tagsinput-tag span:before {
content: "\2715";
content: '\2715';
color: #fff;
}

View File

@@ -0,0 +1,38 @@
import ScrollToTop from '../ScrollToTop';
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);
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
bottle.decorator('MainHeader', withRouter);
bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
bottle.serviceFactory(
'MenuLayout',
MenuLayout,
'TagsList',
'ShortUrls',
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits'
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
};
export default provideServices;

38
src/container/index.js Normal file
View File

@@ -0,0 +1,38 @@
import Bottle from 'bottlejs';
import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda';
import App from '../App';
import provideCommonServices from '../common/services/provideServices';
import provideShortUrlsServices from '../short-urls/services/provideServices';
import provideServersServices from '../servers/services/provideServices';
import provideVisitsServices from '../visits/services/provideServices';
import provideTagsServices from '../tags/services/provideServices';
import provideUtilsServices from '../utils/services/provideServices';
const bottle = new Bottle();
const { container } = bottle;
const lazyService = (container, serviceName) => (...args) => container[serviceName](...args);
const mapActionService = (map, actionName) => ({
...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: lazyService(container, actionName),
});
const connect = (propsFromState, actionServiceNames) =>
reduxConnect(
propsFromState ? pick(propsFromState) : null,
actionServiceNames.reduce(mapActionService, {})
);
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer');
provideCommonServices(bottle, connect, withRouter);
provideShortUrlsServices(bottle, connect);
provideServersServices(bottle, connect, withRouter);
provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect);
provideUtilsServices(bottle);
export default container;

13
src/container/store.js Normal file
View File

@@ -0,0 +1,13 @@
import ReduxThunk from 'redux-thunk';
import { applyMiddleware, compose, createStore } from 'redux';
import reducers from '../reducers';
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
: compose;
const store = createStore(reducers, composeEnhancers(
applyMiddleware(ReduxThunk)
));
export default store;

View File

@@ -3,30 +3,29 @@ import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import ReduxThunk from 'redux-thunk';
import { homepage } from '../package.json';
import App from './App';
import './index.scss';
import ScrollToTop from './common/ScrollToTop';
import reducers from './reducers';
import registerServiceWorker from './registerServiceWorker';
import '../node_modules/react-datepicker/dist/react-datepicker.css';
import container from './container';
import store from './container/store';
import { fixLeafletIcons } from './utils/utils';
import 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css';
import './common/react-tagsinput.scss';
import './index.scss';
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
: compose;
const store = createStore(reducers, composeEnhancers(
applyMiddleware(ReduxThunk)
));
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
fixLeafletIcons();
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,11 @@ body,
margin: 0 auto !important;
}
}
.pagination .page-link {
cursor: pointer;
}
.paddingless {
padding: 0;
}

View File

@@ -43,22 +43,22 @@ export default function register() {
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
return navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ'
'worker. To learn more, visit https://goo.gl/SC7cgQ'
);
});
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl);
}
// Is not local host. Just register service worker
return registerValidSW(swUrl);
});
}
}
function registerValidSW(swUrl) {
navigator.serviceWorker
return navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
@@ -99,15 +99,14 @@ function checkValidServiceWorker(swUrl) {
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
return navigator.serviceWorker.ready.then((registration) =>
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}));
}
// Service worker found. Proceed as normal.
return registerValidSW(swUrl);
})
.catch(() => {
console.log(
@@ -120,6 +119,6 @@ export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.unregister();
});
}).catch(() => {});
}
}

View File

@@ -1,17 +1,12 @@
import { assoc, dissoc, pick, pipe } from 'ramda';
import { assoc, dissoc, pipe } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import { stateFlagTimeout } from '../utils/utils';
import { resetSelectedServer } from './reducers/selectedServer';
import { createServer } from './reducers/server';
import './CreateServer.scss';
import ImportServersBtn from './helpers/ImportServersBtn';
const SHOW_IMPORT_MSG_TIME = 4000;
export class CreateServerComponent extends React.Component {
const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer extends React.Component {
static propTypes = {
createServer: PropTypes.func,
history: PropTypes.shape({
@@ -91,11 +86,6 @@ export class CreateServerComponent extends React.Component {
</div>
);
}
}
const CreateServer = connect(
pick([ 'selectedServer' ]),
{ createServer, resetSelectedServer }
)(CreateServerComponent);
};
export default CreateServer;

View File

@@ -5,7 +5,7 @@
}
.create-server__label {
font-weight: bold;
font-weight: 700;
cursor: pointer;
@media (min-width: $mdMin) {

View File

@@ -1,11 +1,10 @@
import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import PropTypes from 'prop-types';
import DeleteServerModal from './DeleteServerModal';
import { serverType } from './prop-types';
export default class DeleteServerButton extends React.Component {
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
static propTypes = {
server: serverType,
className: PropTypes.string,
@@ -36,4 +35,6 @@ export default class DeleteServerButton extends React.Component {
</React.Fragment>
);
}
}
};
export default DeleteServerButton;

View File

@@ -1,10 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { compose } from 'redux';
import { deleteServer } from './reducers/server';
import { serverType } from './prop-types';
const propTypes = {
@@ -17,7 +13,7 @@ const propTypes = {
}),
};
export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServer, history }) => {
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => {
const closeModal = () => {
deleteServer(server);
toggle();
@@ -42,11 +38,6 @@ export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServe
);
};
DeleteServerModalComponent.propTypes = propTypes;
const DeleteServerModal = compose(
withRouter,
connect(null, { deleteServer })
)(DeleteServerModalComponent);
DeleteServerModal.propTypes = propTypes;
export default DeleteServerModal;

View File

@@ -1,30 +1,20 @@
import { isEmpty, pick, values } from 'ramda';
import { isEmpty, values } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import PropTypes from 'prop-types';
import { selectServer } from '../servers/reducers/selectedServer';
import serversExporter from '../servers/services/ServersExporter';
import { listServers } from './reducers/server';
import { serverType } from './prop-types';
export class ServersDropdownComponent extends React.Component {
static defaultProps = {
serversExporter,
};
const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
static propTypes = {
servers: PropTypes.object,
serversExporter: PropTypes.shape({
exportServers: PropTypes.func,
}),
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
};
renderServers = () => {
const { servers, selectedServer, selectServer, serversExporter } = this.props;
const { servers, selectedServer, selectServer } = this.props;
if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
@@ -68,11 +58,6 @@ export class ServersDropdownComponent extends React.Component {
</UncontrolledDropdown>
);
}
}
const ServersDropdown = connect(
pick([ 'servers', 'selectedServer' ]),
{ listServers, selectServer }
)(ServersDropdownComponent);
};
export default ServersDropdown;

View File

@@ -1,20 +1,15 @@
import React from 'react';
import { connect } from 'react-redux';
import { UncontrolledTooltip } from 'reactstrap';
import { assoc } from 'ramda';
import { assoc, map } from 'ramda';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import { createServers } from '../reducers/server';
import serversImporter, { serversImporterType } from '../services/ServersImporter';
export class ImportServersBtnComponent extends React.Component {
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
static defaultProps = {
serversImporter,
onImport: () => ({}),
};
static propTypes = {
onImport: PropTypes.func,
serversImporter: serversImporterType,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
@@ -25,12 +20,18 @@ export class ImportServersBtnComponent extends React.Component {
}
render() {
const { serversImporter: { importServersFromFile }, onImport, createServers } = this.props;
const onChange = (e) =>
importServersFromFile(e.target.files[0])
.then((servers) => servers.map((server) => assoc('id', uuid(), server)))
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(onImport)
.then(() => {
// Reset input after processing file
target.value = null;
});
return (
<React.Fragment>
@@ -56,8 +57,6 @@ export class ImportServersBtnComponent extends React.Component {
</React.Fragment>
);
}
}
const ImportServersBtn = connect(null, { createServers })(ImportServersBtnComponent);
};
export default ImportServersBtn;

View File

@@ -1,6 +1,3 @@
import { curry } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import serversService from '../../servers/services/ServersService';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
/* eslint-disable padding-line-between-statements, newline-after-var */
@@ -23,17 +20,13 @@ export default function reducer(state = defaultState, action) {
export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER });
export const _selectServer = (shlinkApiClient, serversService, serverId) => (dispatch) => {
export const selectServer = (serversService) => (serverId) => (dispatch) => {
dispatch(resetShortUrlParams());
const selectedServer = serversService.findServerById(serverId);
shlinkApiClient.setConfig(selectedServer);
dispatch({
type: SELECT_SERVER,
selectedServer,
});
};
export const selectServer = curry(_selectServer)(shlinkApiClient, serversService);

View File

@@ -1,6 +1,3 @@
import { curry } from 'ramda';
import serversService from '../services/ServersService';
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
export default function reducer(state = {}, action) {
@@ -12,33 +9,25 @@ export default function reducer(state = {}, action) {
}
}
export const _listServers = (serversService) => ({
export const listServers = (serversService) => () => ({
type: FETCH_SERVERS,
servers: serversService.listServers(),
});
export const listServers = () => _listServers(serversService);
export const _createServer = (serversService, server) => {
export const createServer = (serversService, listServers) => (server) => {
serversService.createServer(server);
return _listServers(serversService);
return listServers();
};
export const createServer = curry(_createServer)(serversService);
export const _deleteServer = (serversService, server) => {
export const deleteServer = (serversService, listServers) => (server) => {
serversService.deleteServer(server);
return _listServers(serversService);
return listServers();
};
export const deleteServer = curry(_deleteServer)(serversService);
export const _createServers = (serversService, servers) => {
export const createServers = (serversService, listServers) => (servers) => {
serversService.createServers(servers);
return _listServers(serversService);
return listServers();
};
export const createServers = curry(_createServers)(serversService);

View File

@@ -1,6 +1,4 @@
import { dissoc, head, keys, values } from 'ramda';
import csvjson from 'csvjson';
import serversService from './ServersService';
const saveCsv = (window, csv) => {
const { navigator, document } = window;
@@ -26,7 +24,7 @@ const saveCsv = (window, csv) => {
document.body.removeChild(link);
};
export class ServersExporter {
export default class ServersExporter {
constructor(serversService, window, csvjson) {
this.serversService = serversService;
this.window = window;
@@ -49,7 +47,3 @@ export class ServersExporter {
}
};
}
const serverExporter = new ServersExporter(serversService, global.window, csvjson);
export default serverExporter;

View File

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

View File

@@ -1,9 +1,8 @@
import { assoc, dissoc, reduce } from 'ramda';
import storage from '../../utils/Storage';
const SERVERS_STORAGE_KEY = 'servers';
export class ServersService {
export default class ServersService {
constructor(storage) {
this.storage = storage;
}
@@ -30,7 +29,3 @@ export class ServersService {
dissoc(server.id, this.listServers())
);
}
const serversService = new ServersService(storage);
export default serversService;

View File

@@ -0,0 +1,46 @@
import csvjson from 'csvjson';
import CreateServer from '../CreateServer';
import ServersDropdown from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal';
import DeleteServerButton from '../DeleteServerButton';
import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
import ServersImporter from './ServersImporter';
import ServersService from './ServersService';
import ServersExporter from './ServersExporter';
const provideServices = (bottle, connect, withRouter) => {
// Components
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'stateFlagTimeout');
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', withRouter);
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
// Services
bottle.constant('csvjson', csvjson);
bottle.service('ServersImporter', ServersImporter, 'csvjson');
bottle.service('ServersService', ServersService, 'Storage');
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
// Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService');
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('resetSelectedServer', () => resetSelectedServer);
};
export default provideServices;

View File

@@ -1,20 +1,16 @@
import downIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleDown';
import upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda';
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 React from 'react';
import { connect } from 'react-redux';
import { Collapse } from 'reactstrap';
import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import TagsSelector from '../tags/helpers/TagsSelector';
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
import { createShortUrl, createShortUrlResultType, resetCreateShortUrl } from './reducers/shortUrlCreation';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format();
export class CreateShortUrlComponent extends React.Component {
const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShortUrl extends React.Component {
static propTypes = {
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
@@ -122,11 +118,6 @@ export class CreateShortUrlComponent extends React.Component {
</div>
);
}
}
const CreateShortUrl = connect(pick([ 'shortUrlCreationResult' ]), {
createShortUrl,
resetCreateShortUrl,
})(CreateShortUrlComponent);
};
export default CreateShortUrl;

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,55 +1,56 @@
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { connect } from 'react-redux';
import { isEmpty, pick } from 'ramda';
import { isEmpty } from 'ramda';
import PropTypes from 'prop-types';
import Tag from '../tags/helpers/Tag';
import SearchField from '../utils/SearchField';
import { listShortUrls } from './reducers/shortUrlsList';
import './SearchBar.scss';
import Tag from '../tags/helpers/Tag';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './SearchBar.scss';
const propTypes = {
listShortUrls: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
};
export function SearchBarComponent({ listShortUrls, shortUrlsListParams }) {
const selectedTags = shortUrlsListParams.tags || [];
const SearchBar = (colorGenerator) => {
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
const selectedTags = shortUrlsListParams.tags || [];
return (
<div className="serach-bar-container">
<SearchField onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
}
/>
return (
<div className="serach-bar-container">
<SearchField onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
}
/>
{!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-2">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp;
{selectedTags.map((tag) => (
<Tag
key={tag}
text={tag}
clearable
onClose={() => listShortUrls(
{
...shortUrlsListParams,
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
}
)}
/>
))}
</h4>
)}
</div>
);
}
{!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-2">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp;
{selectedTags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
clearable
onClose={() => listShortUrls(
{
...shortUrlsListParams,
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
}
)}
/>
))}
</h4>
)}
</div>
);
};
SearchBarComponent.propTypes = propTypes;
SearchBar.propTypes = propTypes;
const SearchBar = connect(pick([ 'shortUrlsListParams' ]), { listShortUrls })(SearchBarComponent);
return SearchBar;
};
export default SearchBar;

View File

@@ -1,27 +1,35 @@
import React from 'react';
import { connect } from 'react-redux';
import { assoc } from 'ramda';
import PropTypes from 'prop-types';
import Paginator from './Paginator';
import SearchBar from './SearchBar';
import ShortUrlsList from './ShortUrlsList';
export function ShortUrlsComponent(props) {
const { match: { params } } = props;
const ShortUrls = (SearchBar, ShortUrlsList) => {
const propTypes = {
match: PropTypes.shape({
params: PropTypes.object,
}),
shortUrlsList: PropTypes.object,
};
// Using a key on a component makes react to create a new instance every time the key changes
const urlsListKey = `${params.serverId}_${params.page}`;
const ShortUrlsComponent = (props) => {
const { match: { params }, shortUrlsList } = props;
const { page, serverId } = params;
const { data = [], pagination } = shortUrlsList;
return (
<div className="shlink-container">
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={props.shortUrlsList.data || []} key={urlsListKey} />
<Paginator paginator={props.shortUrlsList.pagination} serverId={props.match.params.serverId} />
</div>
);
}
// Using a key on a component makes react to create a new instance every time the key changes
const urlsListKey = `${serverId}_${page}`;
const ShortUrls = connect(
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
)(ShortUrlsComponent);
return (
<div className="shlink-container">
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
);
};
ShortUrlsComponent.propTypes = propTypes;
return ShortUrlsComponent;
};
export default ShortUrls;

View File

@@ -1,27 +1,24 @@
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown';
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { head, isEmpty, keys, pick, values } from 'ramda';
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { head, isEmpty, keys, values } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import qs from 'qs';
import PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir } from '../utils/utils';
import { ShortUrlsRow } from './helpers/ShortUrlsRow';
import { listShortUrls, shortUrlType } from './reducers/shortUrlsList';
import { resetShortUrlParams, shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import { shortUrlType } from './reducers/shortUrlsList';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss';
const SORTABLE_FIELDS = {
dateCreated: 'Created at',
shortCode: 'Short URL',
originalUrl: 'Long URL',
longUrl: 'Long URL',
visits: 'Visits',
};
export class ShortUrlsListComponent extends React.Component {
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
static propTypes = {
listShortUrls: PropTypes.func,
resetShortUrlParams: PropTypes.func,
@@ -145,9 +142,9 @@ export class ShortUrlsListComponent extends React.Component {
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('originalUrl')}
onClick={this.orderByColumn('longUrl')}
>
{this.renderOrderIcon('originalUrl')}
{this.renderOrderIcon('longUrl')}
Long URL
</th>
<th className="short-urls-list__header-cell">Tags</th>
@@ -167,11 +164,6 @@ export class ShortUrlsListComponent extends React.Component {
</React.Fragment>
);
}
}
const ShortUrlsList = connect(
pick([ 'selectedServer', 'shortUrlsListParams' ]),
{ listShortUrls, resetShortUrlParams }
)(ShortUrlsListComponent);
};
export default ShortUrlsList;

View File

@@ -1,15 +1,14 @@
import copyIcon from '@fortawesome/fontawesome-free-regular/faCopy';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Card, CardBody, Tooltip } from 'reactstrap';
import PropTypes from 'prop-types';
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
import { stateFlagTimeout } from '../../utils/utils';
import './CreateShortUrlResult.scss';
export default class CreateShortUrlResult extends React.Component {
const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult extends React.Component {
static propTypes = {
resetCreateShortUrl: PropTypes.func,
error: PropTypes.bool,
@@ -61,4 +60,6 @@ export default class CreateShortUrlResult extends React.Component {
</Card>
);
}
}
};
export default CreateShortUrlResult;

View File

@@ -1,18 +1,11 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick, identity } from 'ramda';
import { identity } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import {
deleteShortUrl,
resetDeleteShortUrl,
shortUrlDeleted,
shortUrlDeletionType,
} from '../reducers/shortUrlDeletion';
import './QrCodeModal.scss';
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
export class DeleteShortUrlModalComponent extends Component {
export default class DeleteShortUrlModal extends React.Component {
static propTypes = {
shortUrl: shortUrlType,
toggle: PropTypes.func,
@@ -94,10 +87,3 @@ export class DeleteShortUrlModalComponent extends Component {
);
}
}
const DeleteShortUrlModal = connect(
pick([ 'shortUrlDeletion' ]),
{ deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted }
)(DeleteShortUrlModalComponent);
export default DeleteShortUrlModal;

View File

@@ -1,19 +1,11 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
import TagsSelector from '../../tags/helpers/TagsSelector';
import {
editShortUrlTags,
resetShortUrlsTags,
shortUrlTagsType,
shortUrlTagsEdited,
} from '../reducers/shortUrlTags';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlTagsType } from '../reducers/shortUrlTags';
import { shortUrlType } from '../reducers/shortUrlsList';
export class EditTagsModalComponent extends React.Component {
const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
@@ -88,11 +80,6 @@ export class EditTagsModalComponent extends React.Component {
</Modal>
);
}
}
const EditTagsModal = connect(
pick([ 'shortUrlTags' ]),
{ editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited }
)(EditTagsModalComponent);
};
export default EditTagsModal;

View File

@@ -2,16 +2,18 @@ import { isEmpty } from 'ramda';
import React from 'react';
import Moment from 'react-moment';
import PropTypes from 'prop-types';
import Tag from '../../tags/helpers/Tag';
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList';
import { stateFlagTimeout } from '../../utils/utils';
import { ShortUrlsRowMenu } from './ShortUrlsRowMenu';
import Tag from '../../tags/helpers/Tag';
import './ShortUrlsRow.scss';
export class ShortUrlsRow extends React.Component {
const ShortUrlsRow = (
ShortUrlsRowMenu,
colorGenerator,
stateFlagTimeout
) => class ShortUrlsRow extends React.Component {
static propTypes = {
refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
@@ -31,6 +33,7 @@ export class ShortUrlsRow extends React.Component {
return tags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
@@ -40,7 +43,6 @@ export class ShortUrlsRow extends React.Component {
render() {
const { shortUrl, selectedServer } = this.props;
const completeShortUrl = !selectedServer ? shortUrl.shortCode : `${selectedServer.url}/${shortUrl.shortCode}`;
return (
<tr className="short-urls-row">
@@ -48,10 +50,10 @@ export class ShortUrlsRow extends React.Component {
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</td>
<td className="short-urls-row__cell" data-th="Short URL: ">
<ExternalLink href={completeShortUrl}>{completeShortUrl}</ExternalLink>
<ExternalLink href={shortUrl.shortUrl} />
</td>
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
<ExternalLink href={shortUrl.originalUrl}>{shortUrl.originalUrl}</ExternalLink>
<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>
@@ -63,7 +65,6 @@ export class ShortUrlsRow extends React.Component {
Copied short URL!
</small>
<ShortUrlsRowMenu
completeShortUrl={completeShortUrl}
selectedServer={selectedServer}
shortUrl={shortUrl}
onCopyToClipboard={() => stateFlagTimeout(this.setState.bind(this), 'copiedToClipboard')}
@@ -72,4 +73,6 @@ export class ShortUrlsRow extends React.Component {
</tr>
);
}
}
};
export default ShortUrlsRow;

View File

@@ -1,11 +1,12 @@
import copyIcon from '@fortawesome/fontawesome-free-regular/faCopy';
import pictureIcon from '@fortawesome/fontawesome-free-regular/faImage';
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import pieChartIcon from '@fortawesome/fontawesome-free-solid/faChartPie';
import menuIcon from '@fortawesome/fontawesome-free-solid/faEllipsisV';
import qrIcon from '@fortawesome/fontawesome-free-solid/faQrcode';
import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faCopy as copyIcon, faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
import {
faTags as tagsIcon,
faChartPie as pieChartIcon,
faEllipsisV as menuIcon,
faQrcode as qrIcon,
faMinusCircle as deleteIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Link } from 'react-router-dom';
@@ -15,13 +16,10 @@ import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal';
import EditTagsModal from './EditTagsModal';
import DeleteShortUrlModal from './DeleteShortUrlModal';
import './ShortUrlsRowMenu.scss';
export class ShortUrlsRowMenu extends React.Component {
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
static propTypes = {
completeShortUrl: PropTypes.string,
onCopyToClipboard: PropTypes.func,
selectedServer: serverType,
shortUrl: shortUrlType,
@@ -30,28 +28,28 @@ export class ShortUrlsRowMenu extends React.Component {
state = {
isOpen: false,
isQrModalOpen: false,
isPreviewOpen: false,
isPreviewModalOpen: false,
isTagsModalOpen: false,
isDeleteModalOpen: false,
};
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
render() {
const { completeShortUrl, onCopyToClipboard, selectedServer, shortUrl } = this.props;
const serverId = selectedServer ? selectedServer.id : '';
const { onCopyToClipboard, shortUrl, selectedServer: { id } } = this.props;
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewOpen');
const togglePreview = toggleModal('isPreviewModalOpen');
const toggleTags = toggleModal('isTagsModalOpen');
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/${serverId}/short-code/${shortUrl.shortCode}/visits`}>
<DropdownItem tag={Link} to={`/server/${id}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit stats
</DropdownItem>
@@ -68,31 +66,19 @@ export class ShortUrlsRowMenu extends React.Component {
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} /> &nbsp;Delete short URL
</DropdownItem>
<DeleteShortUrlModal
shortUrl={shortUrl}
isOpen={this.state.isDeleteModalOpen}
toggle={toggleDelete}
/>
<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.isPreviewOpen}
toggle={togglePreview}
/>
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} /> &nbsp;QR code
</DropdownItem>
<QrCodeModal
url={completeShortUrl}
isOpen={this.state.isQrModalOpen}
toggle={toggleQrCode}
/>
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
<DropdownItem divider />
@@ -105,4 +91,6 @@ export class ShortUrlsRowMenu extends React.Component {
</ButtonDropdown>
);
}
}
};
export default ShortUrlsRowMenu;

View File

@@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
@@ -50,9 +48,12 @@ export default function reducer(state = defaultState, action) {
}
}
export const _createShortUrl = (shlinkApiClient, data) => async (dispatch) => {
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
dispatch({ type: CREATE_SHORT_URL_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
const result = await shlinkApiClient.createShortUrl(data);
@@ -62,6 +63,4 @@ export const _createShortUrl = (shlinkApiClient, data) => async (dispatch) => {
}
};
export const createShortUrl = curry(_createShortUrl)(shlinkApiClient);
export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL });

View File

@@ -1,12 +1,10 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
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 */
@@ -56,11 +54,14 @@ export default function reducer(state = defaultState, action) {
}
}
export const _deleteShortUrl = (shlinkApiClient, shortCode) => async (dispatch) => {
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START });
const { selectedServer } = getState();
const { deleteShortUrl } = buildShlinkApiClient(selectedServer);
try {
await shlinkApiClient.deleteShortUrl(shortCode);
await deleteShortUrl(shortCode);
dispatch({ type: DELETE_SHORT_URL, shortCode });
} catch (e) {
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
@@ -69,8 +70,6 @@ export const _deleteShortUrl = (shlinkApiClient, shortCode) => async (dispatch)
}
};
export const deleteShortUrl = curry(_deleteShortUrl)(shlinkApiClient);
export const resetDeleteShortUrl = () => ({ type: RESET_DELETE_SHORT_URL });
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View File

@@ -1,6 +1,5 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { pick } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
@@ -40,8 +39,7 @@ export default function reducer(state = defaultState, action) {
};
case EDIT_SHORT_URL_TAGS:
return {
shortCode: action.shortCode,
tags: action.tags,
...pick([ 'shortCode', 'tags' ], action),
saving: false,
error: false,
};
@@ -52,8 +50,10 @@ export default function reducer(state = defaultState, action) {
}
}
export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (dispatch) => {
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
const normalizedTags = await shlinkApiClient.updateShortUrlTags(shortCode, tags);
@@ -66,8 +66,6 @@ export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (di
}
};
export const editShortUrlTags = curry(_editShortUrlTags)(shlinkApiClient);
export const resetShortUrlsTags = () => ({ type: RESET_EDIT_SHORT_URL_TAGS });
export const shortUrlTagsEdited = (shortCode, tags) => ({

View File

@@ -1,24 +1,25 @@
import { assoc, assocPath, reject } from 'ramda';
import { assoc, assocPath, propEq, reject } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
/* eslint-disable padding-line-between-statements, newline-after-var */
const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
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 */
export const shortUrlType = PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
shortCode: PropTypes.string,
originalUrl: PropTypes.string,
shortUrl: PropTypes.string,
longUrl: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
});
const initialState = {
shortUrls: {},
loading: true,
error: false,
};
export default function reducer(state = initialState, action) {
@@ -35,7 +36,7 @@ export default function reducer(state = initialState, action) {
return {
loading: false,
error: true,
shortUrls: [],
shortUrls: {},
};
case SHORT_URL_TAGS_EDITED:
const { data } = state.shortUrls;
@@ -47,7 +48,7 @@ export default function reducer(state = initialState, action) {
case SHORT_URL_DELETED:
return assocPath(
[ 'shortUrls', 'data' ],
reject((shortUrl) => shortUrl.shortCode === action.shortCode, state.shortUrls.data),
reject(propEq('shortCode', action.shortCode), state.shortUrls.data),
state,
);
default:
@@ -55,16 +56,17 @@ export default function reducer(state = initialState, action) {
}
}
export const _listShortUrls = (shlinkApiClient, params = {}) => async (dispatch) => {
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
dispatch({ type: LIST_SHORT_URLS_START });
const { selectedServer = {} } = getState();
const { listShortUrls } = buildShlinkApiClient(selectedServer);
try {
const shortUrls = await shlinkApiClient.listShortUrls(params);
const shortUrls = await listShortUrls(params);
dispatch({ type: LIST_SHORT_URLS, shortUrls, params });
} catch (e) {
dispatch({ type: LIST_SHORT_URLS_ERROR, params });
}
};
export const listShortUrls = (params = {}) => _listShortUrls(shlinkApiClient, params);

View File

@@ -0,0 +1,73 @@
import { connect as reduxConnect } from 'react-redux';
import { assoc } from 'ramda';
import ShortUrls from '../ShortUrls';
import SearchBar from '../SearchBar';
import ShortUrlsList from '../ShortUrlsList';
import ShortUrlsRow from '../helpers/ShortUrlsRow';
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
import CreateShortUrl from '../CreateShortUrl';
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
import EditTagsModal from '../helpers/EditTagsModal';
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 { resetShortUrlParams } from '../reducers/shortUrlsListParams';
const provideServices = (bottle, connect) => {
// Components
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
bottle.decorator('ShortUrls', reduxConnect(
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
));
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.decorator('ShortUrlsList', connect(
[ 'selectedServer', 'shortUrlsListParams' ],
[ 'listShortUrls', 'resetShortUrlParams' ]
));
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
bottle.decorator(
'CreateShortUrl',
connect([ 'shortUrlCreationResult' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
);
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect(
[ 'shortUrlDeletion' ],
[ 'deleteShortUrl', 'resetDeleteShortUrl', 'shortUrlDeleted' ]
));
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
bottle.decorator('EditTagsModal', connect(
[ 'shortUrlTags' ],
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
));
// Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
bottle.serviceFactory('shortUrlTagsEdited', () => shortUrlTagsEdited);
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl);
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
};
export default provideServices;

View File

@@ -1,16 +1,13 @@
import { Card, CardBody } from 'reactstrap';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import deleteIcon from '@fortawesome/fontawesome-free-solid/faTrash';
import editIcon from '@fortawesome/fontawesome-free-solid/faPencilAlt';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types';
import React from 'react';
import { Link } from 'react-router-dom';
import TagBullet from './helpers/TagBullet';
import './TagCard.scss';
import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal';
import EditTagModal from './helpers/EditTagModal';
export default class TagCard extends React.Component {
const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
static propTypes = {
tag: PropTypes.string,
currentServerId: PropTypes.string,
@@ -35,7 +32,7 @@ export default class TagCard extends React.Component {
<FontAwesomeIcon icon={editIcon} />
</button>
<h5 className="tag-card__tag-title">
<TagBullet tag={tag} />
<TagBullet tag={tag} colorGenerator={colorGenerator} />
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
</h5>
</CardBody>
@@ -45,4 +42,6 @@ export default class TagCard extends React.Component {
</Card>
);
}
}
};
export default TagCard;

View File

@@ -1,16 +1,13 @@
import React from 'react';
import { connect } from 'react-redux';
import { pick, splitEvery } from 'ramda';
import { splitEvery } from 'ramda';
import PropTypes from 'prop-types';
import MuttedMessage from '../utils/MuttedMessage';
import SearchField from '../utils/SearchField';
import { filterTags, forceListTags } from './reducers/tagsList';
import TagCard from './TagCard';
const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4;
export class TagsListComponent extends React.Component {
const TagsList = (TagCard) => class TagsList extends React.Component {
static propTypes = {
filterTags: PropTypes.func,
forceListTags: PropTypes.func,
@@ -82,8 +79,6 @@ export class TagsListComponent extends React.Component {
</div>
);
}
}
const TagsList = connect(pick([ 'tagsList' ]), { forceListTags, filterTags })(TagsListComponent);
};
export default TagsList;

View File

@@ -1,11 +1,9 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
import { deleteTag, tagDeleted, tagDeleteType } from '../reducers/tagDelete';
import { tagDeleteType } from '../reducers/tagDelete';
export class DeleteTagConfirmModalComponent extends React.Component {
export default class DeleteTagConfirmModal extends React.Component {
static propTypes = {
tag: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
@@ -15,13 +13,12 @@ export class DeleteTagConfirmModalComponent extends React.Component {
tagDeleted: PropTypes.func,
};
doDelete = () => {
doDelete = async () => {
const { tag, toggle, deleteTag } = this.props;
deleteTag(tag).then(() => {
this.tagWasDeleted = true;
toggle();
});
await deleteTag(tag);
this.tagWasDeleted = true;
toggle();
};
handleOnClosed = () => {
if (!this.tagWasDeleted) {
@@ -67,10 +64,3 @@ export class DeleteTagConfirmModalComponent extends React.Component {
);
}
}
const DeleteTagConfirmModal = connect(
pick([ 'tagDelete' ]),
{ deleteTag, tagDeleted }
)(DeleteTagConfirmModalComponent);
export default DeleteTagConfirmModal;

View File

@@ -1,31 +1,23 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { pick } from 'ramda';
import { ChromePicker } from 'react-color';
import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import { editTag, tagEdited } from '../reducers/tagEdit';
import './EditTagModal.scss';
export class EditTagModalComponent extends React.Component {
const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Component {
static propTypes = {
tag: PropTypes.string,
editTag: PropTypes.func,
toggle: PropTypes.func,
tagEdited: PropTypes.func,
colorGenerator: colorGeneratorType,
isOpen: PropTypes.bool,
tagEdit: PropTypes.shape({
error: PropTypes.bool,
editing: PropTypes.bool,
}),
};
static defaultProps = {
colorGenerator,
};
saveTag = (e) => {
e.preventDefault();
@@ -53,12 +45,12 @@ export class EditTagModalComponent extends React.Component {
constructor(props) {
super(props);
const { colorGenerator, tag } = props;
const { tag } = props;
this.state = {
showColorPicker: false,
tag,
color: colorGenerator.getColorForKey(tag),
color: getColorForKey(tag),
};
}
@@ -131,8 +123,6 @@ export class EditTagModalComponent extends React.Component {
</Modal>
);
}
}
const EditTagModal = connect(pick([ 'tagEdit' ]), { editTag, tagEdited })(EditTagModalComponent);
};
export default EditTagModal;

View File

@@ -1,41 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import './Tag.scss';
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
const propTypes = {
colorGenerator: colorGeneratorType,
text: PropTypes.string,
children: PropTypes.node,
clearable: PropTypes.bool,
colorGenerator: colorGeneratorType,
onClick: PropTypes.func,
onClose: PropTypes.func,
};
const defaultProps = {
const Tag = ({
text,
children,
clearable,
colorGenerator,
};
onClick = () => {},
onClose = () => {},
}) => (
<span
className="badge tag"
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
onClick={onClick}
>
{children || text}
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>&times;</span>}
</span>
);
export default function Tag(
{
colorGenerator,
text,
children,
clearable,
onClick = () => ({}),
onClose = () => ({}),
}
) {
return (
<span
className="badge tag"
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
onClick={onClick}
>
{children || text}
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>&times;</span>}
</span>
);
}
Tag.defaultProps = defaultProps;
Tag.propTypes = propTypes;
export default Tag;

View File

@@ -1,24 +1,20 @@
import React from 'react';
import * as PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
import './TagBullet.scss';
const propTypes = {
tag: PropTypes.string.isRequired,
colorGenerator: colorGeneratorType,
};
const defaultProps = {
colorGenerator,
};
export default function TagBullet({ tag, colorGenerator }) {
return (
<div
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
className="tag-bullet"
/>
);
}
const TagBullet = ({ tag, colorGenerator }) => (
<div
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
className="tag-bullet"
/>
);
TagBullet.propTypes = propTypes;
TagBullet.defaultProps = defaultProps;
export default TagBullet;

View File

@@ -1,26 +1,22 @@
import React from 'react';
import { connect } from 'react-redux';
import TagsInput from 'react-tagsinput';
import PropTypes from 'prop-types';
import Autosuggest from 'react-autosuggest';
import { pick, identity } from 'ramda';
import { listTags } from '../reducers/tagsList';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import './TagsSelector.scss';
import { identity } from 'ramda';
import TagBullet from './TagBullet';
import './TagsSelector.scss';
export class TagsSelectorComponent extends React.Component {
const TagsSelector = (colorGenerator) => class TagsSelector extends React.Component {
static propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
listTags: PropTypes.func,
placeholder: PropTypes.string,
colorGenerator: colorGeneratorType,
tagsList: PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
}),
};
static defaultProps = {
colorGenerator,
placeholder: 'Add tags to the URL',
};
@@ -31,7 +27,7 @@ export class TagsSelectorComponent extends React.Component {
}
render() {
const { tags, onChange, placeholder, colorGenerator, tagsList } = this.props;
const { tags, onChange, placeholder, tagsList } = this.props;
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
{getTagDisplayValue(tag)}
@@ -59,7 +55,7 @@ export class TagsSelectorComponent extends React.Component {
getSuggestionValue={(suggestion) => suggestion}
renderSuggestion={(suggestion) => (
<React.Fragment>
<TagBullet tag={suggestion} />
<TagBullet tag={suggestion} colorGenerator={colorGenerator} />
{suggestion}
</React.Fragment>
)}
@@ -86,8 +82,6 @@ export class TagsSelectorComponent extends React.Component {
/>
);
}
}
const TagsSelector = connect(pick([ 'tagsList' ]), { listTags })(TagsSelectorComponent);
};
export default TagsSelector;

View File

@@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
@@ -41,9 +39,12 @@ export default function reducer(state = defaultState, action) {
}
}
export const _deleteTag = (shlinkApiClient, tag) => async (dispatch) => {
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
dispatch({ type: DELETE_TAG_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
await shlinkApiClient.deleteTags([ tag ]);
dispatch({ type: DELETE_TAG });
@@ -54,6 +55,4 @@ export const _deleteTag = (shlinkApiClient, tag) => async (dispatch) => {
}
};
export const deleteTag = curry(_deleteTag)(shlinkApiClient);
export const tagDeleted = (tag) => ({ type: TAG_DELETED, tag });

View File

@@ -1,6 +1,4 @@
import { curry, pick } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import colorGenerator from '../../utils/ColorGenerator';
import { pick } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
@@ -42,9 +40,15 @@ export default function reducer(state = defaultState, action) {
}
}
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) => async (dispatch) => {
export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newName, color) => async (
dispatch,
getState
) => {
dispatch({ type: EDIT_TAG_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
await shlinkApiClient.editTag(oldName, newName);
colorGenerator.setColorForKey(newName, color);
@@ -56,8 +60,6 @@ export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, colo
}
};
export const editTag = curry(_editTag)(shlinkApiClient, colorGenerator);
export const tagEdited = (oldName, newName, color) => ({
type: TAG_EDITED,
oldName,

View File

@@ -1,5 +1,5 @@
import { isEmpty, reject } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { buildShlinkApiClientWithAxios as buildShlinkApiClient } from '../../utils/services/ShlinkApiClientBuilder';
import { TAG_DELETED } from './tagDelete';
import { TAG_EDITED } from './tagEdit';
@@ -66,8 +66,8 @@ export default function reducer(state = defaultState, action) {
}
}
export const _listTags = (shlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList } = getState();
export const _listTags = (buildShlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList, selectedServer } = getState();
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
return;
@@ -76,6 +76,7 @@ export const _listTags = (shlinkApiClient, force = false) => async (dispatch, ge
dispatch({ type: LIST_TAGS_START });
try {
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const tags = await shlinkApiClient.listTags();
dispatch({ tags, type: LIST_TAGS });
@@ -84,9 +85,9 @@ export const _listTags = (shlinkApiClient, force = false) => async (dispatch, ge
}
};
export const listTags = () => _listTags(shlinkApiClient);
export const listTags = () => _listTags(buildShlinkApiClient);
export const forceListTags = () => _listTags(shlinkApiClient, true);
export const forceListTags = () => _listTags(buildShlinkApiClient, true);
export const filterTags = (searchTerm) => ({
type: FILTER_TAGS,

View File

@@ -0,0 +1,37 @@
import TagsSelector from '../helpers/TagsSelector';
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 { deleteTag, tagDeleted } from '../reducers/tagDelete';
import { editTag, tagEdited } from '../reducers/tagEdit';
const provideServices = (bottle, connect) => {
// Components
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
bottle.decorator('TagsList', connect([ 'tagsList' ], [ 'forceListTags', 'filterTags' ]));
// Actions
bottle.serviceFactory('filterTags', () => filterTags);
bottle.serviceFactory('forceListTags', () => forceListTags);
bottle.serviceFactory('listTags', () => listTags);
bottle.serviceFactory('tagDeleted', () => tagDeleted);
bottle.serviceFactory('tagEdited', () => tagEdited);
bottle.serviceFactory('deleteTag', deleteTag, 'buildShlinkApiClient');
bottle.serviceFactory('editTag', editTag, 'buildShlinkApiClient', 'ColorGenerator');
};
export default provideServices;

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { isNil } from 'ramda';
import DatePicker from 'react-datepicker';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
import * as PropTypes from 'prop-types';
import './DateInput.scss';

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import * as PropTypes from 'prop-types';
const propTypes = {
toggleClassName: PropTypes.string,
ranges: PropTypes.arrayOf(PropTypes.number).isRequired,
value: PropTypes.number.isRequired,
setValue: PropTypes.func.isRequired,
};
const PaginationDropdown = ({ toggleClassName, ranges, value, setValue }) => (
<UncontrolledDropdown>
<DropdownToggle caret color="link" className={toggleClassName}>
Paginate
</DropdownToggle>
<DropdownMenu right>
{ranges.map((itemsPerPage) => (
<DropdownItem key={itemsPerPage} active={itemsPerPage === value} onClick={() => setValue(itemsPerPage)}>
<b>{itemsPerPage}</b> items per page
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem disabled={value === Infinity} onClick={() => setValue(Infinity)}>
<i>Clear pagination</i>
</DropdownItem>
</DropdownMenu>
</UncontrolledDropdown>
);
PaginationDropdown.propTypes = propTypes;
export default PaginationDropdown;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import searchIcon from '@fortawesome/fontawesome-free-solid/faSearch';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import './SearchField.scss';

View File

@@ -20,4 +20,5 @@
@include vertical-align();
right: 15px;
cursor: pointer;
}

View File

@@ -2,11 +2,10 @@ import React from 'react';
import { UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
import { toPairs } from 'ramda';
import PropTypes from 'prop-types';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import sortAscIcon from '@fortawesome/fontawesome-free-solid/faSortAmountUp';
import sortDescIcon from '@fortawesome/fontawesome-free-solid/faSortAmountDown';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
import classNames from 'classnames';
import { determineOrderDir } from '../utils/utils';
import { determineOrderDir } from './utils';
import './SortingDropdown.scss';
const propTypes = {
@@ -34,7 +33,7 @@ const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, righ
<DropdownToggle
caret
color={isButton ? 'secondary' : 'link'}
className={classNames({ 'btn-block': isButton, 'btn-sm sorting-dropdown__paddingless': !isButton })}
className={classNames({ 'btn-block': isButton, 'btn-sm paddingless': !isButton })}
>
Order by
</DropdownToggle>

View File

@@ -10,7 +10,3 @@
margin: 3.5px 0 0;
float: right;
}
.sorting-dropdown__paddingless.sorting-dropdown__paddingless {
padding: 0;
}

View File

@@ -1,4 +1,3 @@
// Breakpoints
$xsMax: 575px;
$smMin: 576px;

View File

@@ -1,4 +0,0 @@
@mixin border-radius($radius) {
border-radius: $radius;
-webkit-border-radius: $radius;
}

View File

@@ -1,4 +0,0 @@
@mixin box-shadow($shadow) {
-webkit-box-shadow: $shadow;
box-shadow: $shadow;
}

View File

@@ -0,0 +1,8 @@
@mixin fit-with-margin($margin) {
$offset: $margin * 2;
width: calc(100% - #{$offset});
max-width: calc(100% - #{$offset});
height: calc(100% - #{$offset});
margin: $margin;
}

View File

@@ -1,19 +1,14 @@
import { range } from 'ramda';
import PropTypes from 'prop-types';
import storage from './Storage';
import { rangeOf } from '../utils';
const HEX_COLOR_LENGTH = 6;
const { floor, random } = Math;
const letters = '0123456789ABCDEF';
const buildRandomColor = () =>
`#${
range(0, HEX_COLOR_LENGTH)
.map(() => letters[floor(random() * letters.length)])
.join('')
}`;
`#${rangeOf(HEX_COLOR_LENGTH, () => letters[floor(random() * letters.length)]).join('')}`;
const normalizeKey = (key) => key.toLowerCase().trim();
export class ColorGenerator {
export default class ColorGenerator {
constructor(storage) {
this.storage = storage;
this.colors = this.storage.get('colors') || {};
@@ -45,7 +40,3 @@ export const colorGeneratorType = PropTypes.shape({
getColorForKey: PropTypes.func,
setColorForKey: PropTypes.func,
});
const colorGenerator = new ColorGenerator(storage);
export default colorGenerator;

View File

@@ -0,0 +1,62 @@
import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1';
const buildRestUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) {
this.axios = axios;
this._baseUrl = buildRestUrl(baseUrl);
this._apiKey = apiKey || '';
}
listShortUrls = (options = {}) =>
this._performRequest('/short-urls', 'GET', options)
.then((resp) => resp.data.shortUrls);
createShortUrl = (options) => {
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
return this._performRequest('/short-urls', 'POST', {}, filteredOptions)
.then((resp) => resp.data);
};
getShortUrlVisits = (shortCode, query) =>
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
.then((resp) => resp.data.visits);
getShortUrl = (shortCode) =>
this._performRequest(`/short-urls/${shortCode}`, 'GET')
.then((resp) => resp.data);
deleteShortUrl = (shortCode) =>
this._performRequest(`/short-urls/${shortCode}`, 'DELETE')
.then(() => ({}));
updateShortUrlTags = (shortCode, tags) =>
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags })
.then((resp) => resp.data.tags);
listTags = () =>
this._performRequest('/tags', 'GET')
.then((resp) => resp.data.tags.data);
deleteTags = (tags) =>
this._performRequest('/tags', 'DELETE', { tags })
.then(() => ({ tags }));
editTag = (oldName, newName) =>
this._performRequest('/tags', 'PUT', {}, { oldName, newName })
.then(() => ({ oldName, newName }));
_performRequest = async (url, method = 'GET', query = {}, body = {}) =>
await this.axios({
method,
url: `${this._baseUrl}${url}`,
headers: { 'X-Api-Key': this._apiKey },
params: query,
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
});
}

View File

@@ -0,0 +1,18 @@
import * as axios from 'axios';
import ShlinkApiClient from './ShlinkApiClient';
const apiClients = {};
const buildShlinkApiClient = (axios) => ({ url, apiKey }) => {
const clientKey = `${url}_${apiKey}`;
if (!apiClients[clientKey]) {
apiClients[clientKey] = new ShlinkApiClient(axios, url, apiKey);
}
return apiClients[clientKey];
};
export default buildShlinkApiClient;
export const buildShlinkApiClientWithAxios = buildShlinkApiClient(axios);

View File

@@ -1,7 +1,7 @@
const PREFIX = 'shlink';
const buildPath = (path) => `${PREFIX}.${path}`;
export class Storage {
export default class Storage {
constructor(localStorage) {
this.localStorage = localStorage;
}
@@ -14,15 +14,3 @@ export class Storage {
set = (key, value) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));
}
const browserStorage = global.localStorage || {
getItem() {
return '';
},
setItem() {
return '';
},
};
const storage = new Storage(browserStorage);
export default storage;

View File

@@ -0,0 +1,19 @@
import axios from 'axios';
import { stateFlagTimeout } from '../utils';
import Storage from './Storage';
import ColorGenerator from './ColorGenerator';
import buildShlinkApiClient from './ShlinkApiClientBuilder';
const provideServices = (bottle) => {
bottle.constant('localStorage', global.localStorage);
bottle.service('Storage', Storage, 'localStorage');
bottle.service('ColorGenerator', ColorGenerator, 'Storage');
bottle.constant('axios', axios);
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
bottle.constant('setTimeout', global.setTimeout);
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
};
export default provideServices;

View File

@@ -1,6 +1,19 @@
const DEFAULT_TIMEOUT_DELAY = 2000;
import L from 'leaflet';
import marker2x from 'leaflet/dist/images/marker-icon-2x.png';
import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda';
export const stateFlagTimeout = (setState, flagName, initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => {
const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000;
const { ceil } = Math;
export const stateFlagTimeout = (setTimeout) => (
setState,
flagName,
initialValue = true,
delay = DEFAULT_TIMEOUT_DELAY
) => {
setState({ [flagName]: initialValue });
setTimeout(() => setState({ [flagName]: !initialValue }), delay);
};
@@ -17,3 +30,17 @@ export const determineOrderDir = (clickedField, currentOrderField, currentOrderD
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
};
export const fixLeafletIcons = () => {
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: marker2x,
iconUrl: marker,
shadowUrl: markerShadow,
});
};
export const rangeOf = (size, mappingFn, startAt = 1) => range(startAt, size + 1).map(mappingFn);
export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;

View File

@@ -1,18 +1,16 @@
import { Card, CardHeader, CardBody } from 'reactstrap';
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import PropTypes from 'prop-types';
import React from 'react';
import { keys, values } from 'ramda';
import './GraphCard.scss';
const propTypes = {
title: PropTypes.string,
children: PropTypes.node,
title: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]),
footer: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]),
isBarChart: PropTypes.bool,
stats: PropTypes.object,
matchMedia: PropTypes.func,
};
const defaultProps = {
matchMedia: global.window ? global.window.matchMedia : () => {},
max: PropTypes.number,
};
const generateGraphData = (title, isBarChart, labels, data) => ({
@@ -36,62 +34,43 @@ const generateGraphData = (title, isBarChart, labels, data) => ({
],
});
const determineGraphAspectRatio = (barsCount, isBarChart, matchMedia) => {
const determineAspectRationModifier = () => {
switch (true) {
case matchMedia('(max-width: 1200px)').matches:
return 1.5; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 992px)').matches:
return 1.75; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 768px)').matches:
return 2; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 576px)').matches:
return 2.25; // eslint-disable-line no-magic-numbers
default:
return 1;
}
};
const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;
const MAX_BARS_WITHOUT_HEIGHT = 20;
const DEFAULT_ASPECT_RATION = 2;
const shouldCalculateAspectRatio = isBarChart && barsCount > MAX_BARS_WITHOUT_HEIGHT;
return shouldCalculateAspectRatio
? MAX_BARS_WITHOUT_HEIGHT / determineAspectRationModifier() * DEFAULT_ASPECT_RATION / barsCount
: DEFAULT_ASPECT_RATION;
};
const renderGraph = (title, isBarChart, stats, matchMedia) => {
const renderGraph = (title, isBarChart, stats, max) => {
const Component = isBarChart ? HorizontalBar : Doughnut;
const labels = keys(stats);
const labels = keys(stats).map(dropLabelIfHidden);
const data = values(stats);
const aspectRatio = determineGraphAspectRatio(labels.length, isBarChart, matchMedia);
const options = {
aspectRatio,
legend: isBarChart ? { display: false } : { position: 'right' },
scales: isBarChart ? {
scales: isBarChart && {
xAxes: [
{
ticks: { beginAtZero: true },
ticks: { beginAtZero: true, max },
},
],
} : null,
},
tooltips: {
intersect: !isBarChart,
// Do not show tooltip on items with empty label when in a bar chart
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
},
};
const graphData = generateGraphData(title, isBarChart, labels, data);
const height = isBarChart && labels.length > 20 ? labels.length * 8 : null;
return <Component data={generateGraphData(title, isBarChart, labels, data)} options={options} height={null} />;
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
return <Component key={height} data={graphData} options={options} height={height} />;
};
const GraphCard = ({ title, children, isBarChart, stats, matchMedia }) => (
const GraphCard = ({ title, footer, isBarChart, stats, max }) => (
<Card className="mt-4">
<CardHeader className="graph-card__header">{children || title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, matchMedia)}</CardBody>
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, max)}</CardBody>
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
</Card>
);
GraphCard.propTypes = propTypes;
GraphCard.defaultProps = defaultProps;
export default GraphCard;

View File

@@ -0,0 +1,4 @@
.graph-card__footer--sticky {
position: sticky;
bottom: 0;
}

View File

@@ -1,31 +1,23 @@
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, pick } from 'ramda';
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, values } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import MutedMessage from '../utils/MuttedMessage';
import SortableBarGraph from './SortableBarGraph';
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
import {
processBrowserStats,
processCountriesStats,
processOsStats,
processReferrersStats,
} from './services/VisitsParser';
import { VisitsHeader } from './VisitsHeader';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader';
import GraphCard from './GraphCard';
import { getShortUrlDetail, shortUrlDetailType } from './reducers/shortUrlDetail';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import './ShortUrlVisits.scss';
export class ShortUrlsVisitsComponent extends React.Component {
const ShortUrlVisits = (
{ processStatsFromVisits },
OpenMapModalBtn
) => class ShortUrlVisits extends React.PureComponent {
static propTypes = {
processOsStats: PropTypes.func,
processBrowserStats: PropTypes.func,
processCountriesStats: PropTypes.func,
processReferrersStats: PropTypes.func,
match: PropTypes.shape({
params: PropTypes.object,
}),
@@ -33,46 +25,47 @@ export class ShortUrlsVisitsComponent extends React.Component {
shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType,
};
static defaultProps = {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
cancelGetShortUrlVisits: PropTypes.func,
};
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
const { match: { params }, getShortUrlVisits } = this.props;
getShortUrlVisits(params.shortCode, mapObjIndexed(
const { shortCode } = params;
const dates = mapObjIndexed(
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
this.state
));
);
const { startDate, endDate } = dates;
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs
this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`;
getShortUrlVisits(shortCode, dates);
};
componentDidMount() {
const { match: { params }, getShortUrlDetail } = this.props;
const { shortCode } = params;
this.timeWhenMounted = new Date().getTime();
this.loadVisits();
getShortUrlDetail(params.shortCode);
getShortUrlDetail(shortCode);
}
componentWillUnmount() {
this.props.cancelGetShortUrlVisits();
}
render() {
const {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
shortUrlVisits,
shortUrlDetail,
} = this.props;
const { shortUrlVisits, shortUrlDetail } = this.props;
const renderVisitsContent = () => {
const { visits, loading, error } = shortUrlVisits;
const { visits, loading, loadingLarge, error } = shortUrlVisits;
if (loading) {
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> {message}</MutedMessage>;
}
if (error) {
@@ -87,17 +80,33 @@ export class ShortUrlsVisitsComponent extends React.Component {
return <MutedMessage>There are no visits matching current filter :(</MutedMessage>;
}
const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits(
{ id: this.memoizationId, visits }
);
const mapLocations = values(citiesForMap);
return (
<div className="row">
<div className="col-md-6">
<GraphCard title="Operating systems" stats={processOsStats(visits)} />
<div className="col-xl-4 col-lg-6">
<GraphCard title="Operating systems" stats={os} />
</div>
<div className="col-md-6">
<GraphCard title="Browsers" stats={processBrowserStats(visits)} />
<div className="col-xl-4 col-lg-6">
<GraphCard title="Browsers" stats={browsers} />
</div>
<div className="col-md-6">
<div className="col-xl-4">
<SortableBarGraph
stats={processCountriesStats(visits)}
stats={referrers}
withPagination={false}
title="Referrers"
sortingItems={{
name: 'Referrer name',
amount: 'Visits amount',
}}
/>
</div>
<div className="col-lg-6">
<SortableBarGraph
stats={countries}
title="Countries"
sortingItems={{
name: 'Country name',
@@ -105,12 +114,16 @@ export class ShortUrlsVisitsComponent extends React.Component {
}}
/>
</div>
<div className="col-md-6">
<div className="col-lg-6">
<SortableBarGraph
stats={processReferrersStats(visits)}
title="Referrers"
stats={cities}
title="Cities"
extraHeaderContent={(activeCities) =>
mapLocations.length > 0 &&
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
}
sortingItems={{
name: 'Referrer name',
name: 'City name',
amount: 'Visits amount',
}}
/>
@@ -131,7 +144,7 @@ export class ShortUrlsVisitsComponent extends React.Component {
placeholderText="Since"
isClearable
maxDate={this.state.endDate}
onChange={(date) => this.setState({ startDate: date }, () => this.loadVisits())}
onChange={(date) => this.setState({ startDate: date }, this.loadVisits)}
/>
</div>
<div className="col-xl-3 col-lg-4 col-md-6">
@@ -141,7 +154,7 @@ export class ShortUrlsVisitsComponent extends React.Component {
placeholderText="Until"
isClearable
minDate={this.state.startDate}
onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())}
onChange={(date) => this.setState({ endDate: date }, this.loadVisits)}
/>
</div>
</div>
@@ -153,11 +166,6 @@ export class ShortUrlsVisitsComponent extends React.Component {
</div>
);
}
}
};
const ShortUrlsVisits = connect(
pick([ 'shortUrlVisits', 'shortUrlDetail' ]),
{ getShortUrlVisits, getShortUrlDetail }
)(ShortUrlsVisitsComponent);
export default ShortUrlsVisits;
export default ShortUrlVisits;

View File

@@ -1,47 +1,124 @@
import React from 'react';
import PropTypes from 'prop-types';
import { fromPairs, head, keys, prop, reverse, sortBy, toPairs } from 'ramda';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type } from 'ramda';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import SortingDropdown from '../utils/SortingDropdown';
import PaginationDropdown from '../utils/PaginationDropdown';
import { rangeOf, roundTen } from '../utils/utils';
import GraphCard from './GraphCard';
const { max } = Math;
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
const pickValueFromPair = ([ , value ]) => value;
export default class SortableBarGraph extends React.Component {
static propTypes = {
stats: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.func,
withPagination: PropTypes.bool,
};
state = {
orderField: undefined,
orderDir: undefined,
currentPage: 1,
itemsPerPage: Infinity,
};
render() {
const { stats, sortingItems, title } = this.props;
const sortStats = () => {
if (!this.state.orderField) {
return stats;
}
determineStats(stats, sortingItems) {
const pairs = toPairs(stats);
const sortedPairs = !this.state.orderField ? pairs : sortBy(
pipe(
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
toLowerIfString
),
pairs
);
const directionalPairs = !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
const sortedPairs = sortBy(prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1), toPairs(stats));
if (directionalPairs.length <= this.state.itemsPerPage) {
return { currentPageStats: fromPairs(directionalPairs) };
}
return fromPairs(this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs));
const pages = splitEvery(this.state.itemsPerPage, directionalPairs);
return {
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
pagination: this.renderPagination(pages.length),
max: roundTen(max(...directionalPairs.map(pickValueFromPair))),
};
}
determineCurrentPagePairs(pages) {
const page = pages[this.state.currentPage - 1];
if (this.state.currentPage < pages.length) {
return page;
}
const firstPageLength = pages[0].length;
// Using the "hidden" key, the chart will just replace the label by an empty string
return [ ...page, ...rangeOf(firstPageLength - page.length, (i) => [ `hidden_${i}`, 0 ]) ];
}
renderPagination(pagesCount) {
const { currentPage } = this.state;
return (
<GraphCard stats={sortStats()} isBarChart>
<Pagination listClassName="flex-wrap mb-0">
<PaginationItem disabled={currentPage === 1}>
<PaginationLink previous tag="span" onClick={() => this.setState({ currentPage: currentPage - 1 })} />
</PaginationItem>
{rangeOf(pagesCount, (page) => (
<PaginationItem key={page} active={page === currentPage}>
<PaginationLink tag="span" onClick={() => this.setState({ currentPage: page })}>{page}</PaginationLink>
</PaginationItem>
))}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink next tag="span" onClick={() => this.setState({ currentPage: currentPage + 1 })} />
</PaginationItem>
</Pagination>
);
}
render() {
const { stats, sortingItems, title, extraHeaderContent, withPagination = true } = this.props;
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems);
const activeCities = keys(currentPageStats);
const computeTitle = () => (
<React.Fragment>
{title}
<div className="float-right">
<SortingDropdown
isButton={false}
right
items={sortingItems}
orderField={this.state.orderField}
orderDir={this.state.orderDir}
items={sortingItems}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir })}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir, currentPage: 1 })}
/>
</div>
</GraphCard>
{withPagination && keys(stats).length > 50 && (
<div className="float-right">
<PaginationDropdown
toggleClassName="btn-sm paddingless mr-3"
ranges={[ 50, 100, 200, 500 ]}
value={this.state.itemsPerPage}
setValue={(itemsPerPage) => this.setState({ itemsPerPage, currentPage: 1 })}
/>
</div>
)}
{extraHeaderContent && (
<div className="float-right">
{extraHeaderContent(pagination ? activeCities : undefined)}
</div>
)}
</React.Fragment>
);
return <GraphCard isBarChart title={computeTitle} stats={currentPageStats} footer={pagination} max={max} />;
}
}

View File

@@ -11,7 +11,7 @@ const propTypes = {
shortUrlVisits: shortUrlVisitsType.isRequired,
};
export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
const { shortUrl, loading } = shortUrlDetail;
const { visits } = shortUrlVisits;
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
@@ -34,13 +34,7 @@ export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
Visit stats for <ExternalLink href={shortLink} />
</h2>
<hr />
{shortUrl.dateCreated && (
<div>
Created:
&nbsp;
{renderDate()}
</div>
)}
<div>Created: {renderDate()}</div>
<div>
Long URL:
&nbsp;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { Modal, ModalBody } from 'reactstrap';
import { Map, TileLayer, Marker, Popup } from 'react-leaflet';
import { prop } from 'ramda';
import * as PropTypes from 'prop-types';
import './MapModal.scss';
const propTypes = {
toggle: PropTypes.func,
isOpen: PropTypes.bool,
title: PropTypes.string,
locations: PropTypes.arrayOf(PropTypes.shape({
cityName: PropTypes.string.isRequired,
latLong: PropTypes.arrayOf(PropTypes.number).isRequired,
count: PropTypes.number.isRequired,
})),
};
const defaultProps = {
locations: [],
};
const OpenStreetMapTile = () => (
<TileLayer
attribution='&amp;copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
);
const calculateMapProps = (locations) => {
if (locations.length === 0) {
return {};
}
if (locations.length > 1) {
return { bounds: locations.map(prop('latLong')) };
}
// When there's only one location, an error is thrown if trying to calculate the bounds.
// When that happens, we use zoom and center as a workaround
const [{ latLong: center }] = locations;
return { zoom: '10', center };
};
const MapModal = ({ toggle, isOpen, title, locations }) => (
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
<ModalBody className="map-modal__modal-body">
<h3 className="map-modal__modal-title">
{title}
<button type="button" className="close" onClick={toggle}>&times;</button>
</h3>
<Map {...calculateMapProps(locations)}>
<OpenStreetMapTile />
{locations.map(({ cityName, latLong, count }, index) => (
<Marker key={index} position={latLong}>
<Popup><b>{count}</b> visit{count > 1 ? 's' : ''} from <b>{cityName}</b></Popup>
</Marker>
))}
</Map>
</ModalBody>
</Modal>
);
MapModal.propTypes = propTypes;
MapModal.defaultProps = defaultProps;
export default MapModal;

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