Compare commits

...

131 Commits

Author SHA1 Message Date
Alejandro Celaya
e9cef8a029 Merge pull request #375 from acelaya-forks/feature/servers-import-error
Feature/servers import error
2020-12-30 21:04:59 +01:00
Alejandro Celaya
e577eb48d6 Changed env for github workflows from ubuntu-latest to ubuntu-20.04 2020-12-30 20:58:36 +01:00
Alejandro Celaya
d08a69954a Updated changelog 2020-12-30 20:53:14 +01:00
Alejandro Celaya
fe81bfccef Fixed importing servers in android due to wrong mime type 2020-12-30 20:52:05 +01:00
Alejandro Celaya
4869435aca Changed linting order 2020-12-30 20:10:37 +01:00
Alejandro Celaya
0822cebb10 Merge pull request #374 from acelaya-forks/feature/responsive-table
Feature/responsive table
2020-12-30 20:09:42 +01:00
Alejandro Celaya
01a18f2342 Updated changelog 2020-12-30 20:05:53 +01:00
Alejandro Celaya
a22274f382 Increased breakpoint in which short URLs table collapses 2020-12-30 20:05:04 +01:00
Alejandro Celaya
c0098ac7fd Merge pull request #373 from acelaya-forks/feature/ui-fixes
Fixed minor UI glitches in visits section
2020-12-30 19:52:24 +01:00
Alejandro Celaya
ba5a99dc2a Fixed minor UI glitches in visits section 2020-12-30 19:48:02 +01:00
Alejandro Celaya
1927ad2d3a Merge pull request #370 from acelaya-forks/feature/stryker-updates
Updated stryker
2020-12-25 19:13:57 +01:00
Alejandro Celaya
0356a0204d Updated stryker 2020-12-25 19:10:35 +01:00
Alejandro Celaya
3bf64bee1e Merge pull request #369 from acelaya-forks/feature/consistent-dropdowns
Feature/consistent dropdowns
2020-12-25 11:25:48 +01:00
Alejandro Celaya
da484374a1 Renamed Dropdnown.scss to DropdownBtn.scss for consistency with component 2020-12-25 11:21:39 +01:00
Alejandro Celaya
7b9447b717 Updated changelog 2020-12-25 11:17:57 +01:00
Alejandro Celaya
e583eb2759 Ensured sorting dropdown for short URLs is not enclosed inside card 2020-12-25 11:15:49 +01:00
Alejandro Celaya
93b4de60f6 Improved sorting dropdown to display order field and order dir 2020-12-25 11:06:10 +01:00
Alejandro Celaya
16f4f7eac8 Reused dropdown-btn styles in sorting dropdown 2020-12-25 10:54:49 +01:00
Alejandro Celaya
90d4fe72db Renamed Dropdown component to DropdownBtn 2020-12-25 10:43:36 +01:00
Alejandro Celaya
e1298cfa81 Created Dropdown test 2020-12-25 10:39:54 +01:00
Alejandro Celaya
6be3a1223f Created common Dropdown component for style consistency 2020-12-25 10:29:25 +01:00
Alejandro Celaya
81d24432a9 Updated app gif 2020-12-24 10:58:59 +01:00
Alejandro Celaya
1d193f1187 Updated nginx version in docker image 2020-12-22 10:23:27 +01:00
Alejandro Celaya
c56994c813 Merge pull request #363 from acelaya-forks/feature/refactorings
Feature/refactorings
2020-12-22 10:17:59 +01:00
Alejandro Celaya
44862073bb Added v3 to changelog 2020-12-22 10:06:24 +01:00
Alejandro Celaya
9eb9182c21 Created ShlinkApiError test 2020-12-22 10:05:32 +01:00
Alejandro Celaya
b2abfd543e Moved Shlink API services to api module 2020-12-22 09:57:09 +01:00
Alejandro Celaya
8c6eaf2f1d Moved API types and type helpers to api module 2020-12-22 09:49:18 +01:00
Alejandro Celaya
811544d7df Moved api utils to subfolder 2020-12-22 09:24:33 +01:00
Alejandro Celaya
9fdfdf865e Merge pull request #361 from acelaya-forks/feature/errors-improvements
Feature/errors improvements
2020-12-22 00:00:04 +01:00
Alejandro Celaya
6a354c277c Set API response as per Shlink v2 2020-12-21 23:55:54 +01:00
Alejandro Celaya
89f6c6c283 Updated changelog 2020-12-21 23:53:15 +01:00
Alejandro Celaya
d534a4e441 Moved logic to parse API errors to a helper function 2020-12-21 23:51:49 +01:00
Alejandro Celaya
4c3772d5c8 Added meaningful error messages for the rest of API calls 2020-12-21 23:41:50 +01:00
Alejandro Celaya
ee95d5a1b7 Improved handling of errors in several API interactions 2020-12-21 21:26:45 +01:00
Alejandro Celaya
51379eb2a0 Created component holding the logic to render Shlink API errors 2020-12-21 21:19:02 +01:00
Alejandro Celaya
f69f791790 Improved handling of short URL deletion errors 2020-12-21 21:02:30 +01:00
Alejandro Celaya
54b1ab12cd Passed API error while creating URLs to display proper error messages 2020-12-21 20:55:52 +01:00
Alejandro Celaya
18d417e78c Merge pull request #359 from acelaya-forks/feature/message-improvements
Feature/message improvements
2020-12-21 18:31:21 +01:00
Alejandro Celaya
7a48a06442 Normalized import 2020-12-21 18:20:59 +01:00
Alejandro Celaya
195aaa8be6 Updated changelog 2020-12-21 18:15:09 +01:00
Alejandro Celaya
94d2f3167b Created Result test 2020-12-21 18:14:11 +01:00
Alejandro Celaya
344f5e9b0d Updated Result component so that it has the text centered by default 2020-12-21 17:58:46 +01:00
Alejandro Celaya
b211a29fc5 Created new Result component to display operation result messages consistently 2020-12-21 17:54:20 +01:00
Alejandro Celaya
c25355c531 Added Message test 2020-12-21 09:57:46 +01:00
Alejandro Celaya
5cf0c86a14 Normalized Message component, making it autocontained 2020-12-21 09:22:13 +01:00
Alejandro Celaya
852e791c80 Merge pull request #357 from acelaya-forks/feature/routable-visits-sections
Feature/routable visits sections
2020-12-20 20:01:04 +01:00
Alejandro Celaya
f5d03ed3a2 Created query helper test 2020-12-20 19:51:43 +01:00
Alejandro Celaya
4642e07fd3 Reduced duplication when defining routes in visits section 2020-12-20 19:42:37 +01:00
Alejandro Celaya
83221c1066 Added routes to subsections in visits 2020-12-20 19:28:14 +01:00
Alejandro Celaya
214b952e84 Merge pull request #356 from acelaya-forks/feature/welcome-ui
Feature/welcome UI
2020-12-20 12:39:18 +01:00
Alejandro Celaya
42adbb3739 Updated changelog 2020-12-20 12:32:54 +01:00
Alejandro Celaya
9e63c463ca Styled scroll in servers list for home page 2020-12-20 12:25:17 +01:00
Alejandro Celaya
260a6c4940 Improved welcome screen 2020-12-20 12:17:12 +01:00
Alejandro Celaya
fa949cde12 Simplified onTagClick handling in ShortUrlsTable 2020-12-20 09:09:22 +01:00
Alejandro Celaya
23da0328ec Added Shlink logo as react component 2020-12-20 08:56:46 +01:00
Alejandro Celaya
7da634e772 Fixed tags filtering from overview page 2020-12-19 22:49:11 +01:00
Alejandro Celaya
79f7459d77 Merge pull request #354 from acelaya-forks/feature/ci-migration
Replaced scrutinizer with codecov
2020-12-19 12:58:43 +01:00
Alejandro Celaya
4002392b12 Replaced scrutinizer with codecov 2020-12-19 12:55:30 +01:00
Alejandro Celaya
e9e53bb69b Added border on top of overview section cards 2020-12-17 18:42:43 +01:00
Alejandro Celaya
623deec973 Merge pull request #353 from acelaya-forks/feature/more-ui-improvements
Feature/more UI improvements
2020-12-15 19:04:04 +01:00
Alejandro Celaya
3453d4ffd5 Fixed coding styles 2020-12-15 18:59:14 +01:00
Alejandro Celaya
f9ef7eccf8 Updated changelog 2020-12-15 18:53:41 +01:00
Alejandro Celaya
3cdcffaac3 Fixed mutation checks step in ci workflow 2020-12-15 18:45:15 +01:00
Alejandro Celaya
0f23cdcd21 Updated initial interval for visits to be last 30 days 2020-12-15 18:40:36 +01:00
Alejandro Celaya
9dc6c756f2 Fixed rendering of cards in overview page 2020-12-15 18:12:15 +01:00
Alejandro Celaya
0491694839 Set fixed width to aside menu 2020-12-15 17:57:24 +01:00
Alejandro Celaya
f1f3c3f98b Merge pull request #350 from acelaya-forks/feature/ui-improvements
Feature/UI improvements
2020-12-15 10:05:16 +01:00
Alejandro Celaya
ec3ad8412c Fixed mutation tests in ci workflow 2020-12-15 10:01:15 +01:00
Alejandro Celaya
d39512732a Fixed DateRangeSelector focus state 2020-12-15 09:54:45 +01:00
Alejandro Celaya
95abf4f898 Updated changelog 2020-12-14 23:36:58 +01:00
Alejandro Celaya
61a1087d91 Added date range selector to short URLs list 2020-12-14 23:35:31 +01:00
Alejandro Celaya
3f245a757e Created DateRangeSelector test 2020-12-14 23:15:06 +01:00
Alejandro Celaya
4e236a80de Created new dropdown component to select relative or absolute date ranges 2020-12-14 22:58:15 +01:00
Alejandro Celaya
288f6e2cf8 Fixed rendering of ShlinkVersions component to match current layout 2020-12-14 19:05:25 +01:00
Alejandro Celaya
9b6d4a4d97 Added max-width to internal container 2020-12-14 18:39:19 +01:00
Alejandro Celaya
f2a8865679 Added new card styles to error pages 2020-12-13 20:57:00 +01:00
Alejandro Celaya
017db18e70 Removed unneeded step in ci workflow 2020-12-13 10:38:03 +01:00
Alejandro Celaya
19c4a61524 Added github and docker logos to badges 2020-12-13 09:32:37 +01:00
Alejandro Celaya
f01c9bd5c8 Fixed build badge 2020-12-13 06:47:46 +01:00
Alejandro Celaya
2a5fa54ae1 Merge pull request #348 from acelaya-forks/feature/github-actions
Created workflow for ci in github actions
2020-12-12 21:48:23 +01:00
Alejandro Celaya
7a1b6367a8 Changed build badges to point to github action 2020-12-12 21:43:16 +01:00
Alejandro Celaya
058860737e Removed travis-specific env vars from github action 2020-12-12 21:39:51 +01:00
Alejandro Celaya
20f2fd1080 Created workflow for ci in github actions 2020-12-12 21:29:25 +01:00
Alejandro Celaya
16ce1d24af Merge pull request #347 from acelaya-forks/feature/visits-improvements
Feature/visits improvements
2020-12-12 21:15:16 +01:00
Alejandro Celaya
a51db38749 Updated changelog 2020-12-12 21:07:32 +01:00
Alejandro Celaya
6090f97347 Updated tabs in visits section to be sticky 2020-12-12 21:05:54 +01:00
Alejandro Celaya
c74355e363 Improved visits section so that charts are grouped in sub tabs 2020-12-12 20:45:23 +01:00
Alejandro Celaya
a013d40bf1 More standardization color changes 2020-12-12 16:55:01 +01:00
Alejandro Celaya
7f7473c348 Merge pull request #346 from acelaya-forks/feature/drop-shlink-1-support
Dropped support for Shlink 1
2020-12-12 13:50:38 +01:00
Alejandro Celaya
df6f1b984f Dropped support for Shlink 1 2020-12-12 13:43:16 +01:00
Alejandro Celaya
b9905c8bf4 Ensured visits amount card displays warning for old shlink versions 2020-12-12 13:22:11 +01:00
Alejandro Celaya
32957835b3 Merge pull request #345 from acelaya-forks/feature/restyle
Feature/restyle
2020-12-12 12:13:52 +01:00
Alejandro Celaya
2efc5feb3f Updated changelog 2020-12-12 12:07:51 +01:00
Alejandro Celaya
526fa14dce Improved NoMenuLayout, using a container-xl style 2020-12-12 12:04:20 +01:00
Alejandro Celaya
4d969b994e Improved server form 2020-12-12 11:43:16 +01:00
Alejandro Celaya
d62edb2249 Moved 'add server' button inside servers dropdown 2020-12-12 11:29:15 +01:00
Alejandro Celaya
bc82e7e7fd Fixed colors in visits table 2020-12-12 11:11:36 +01:00
Alejandro Celaya
1e460d3ef7 Updated edit URL modal to be large 2020-12-12 11:07:05 +01:00
Alejandro Celaya
143a05cab1 Restyled cards, background and shadows 2020-12-12 10:56:10 +01:00
Alejandro Celaya
bf1b59c0d8 Merge pull request #343 from acelaya-forks/feature/overview-page
Feature/overview page
2020-12-08 19:44:25 +01:00
Alejandro Celaya
5ab38027bf Updated changelog 2020-12-08 19:38:35 +01:00
Alejandro Celaya
3e6aee47e5 Fixed TS compilation in tests 2020-12-08 19:36:47 +01:00
Alejandro Celaya
60282281a3 Grouped basic components in 'create' form in its own card 2020-12-08 19:21:31 +01:00
Alejandro Celaya
2017ee7456 Created SimpleCard component to reduce duplicated code when rendering cards 2020-12-08 19:10:29 +01:00
Alejandro Celaya
e60d241fcf Changed 'create' page, grouping components and adding more explanations 2020-12-08 18:52:18 +01:00
Alejandro Celaya
43af6fdaba Added redirect from server base path to overview page, to ease changing default page 2020-12-08 18:27:36 +01:00
Alejandro Celaya
f359a16004 Ensured tags input looks as a large input 2020-12-08 18:18:16 +01:00
Alejandro Celaya
1b413fb0b7 Created Overview component test 2020-12-08 17:51:49 +01:00
Alejandro Celaya
20a9259109 Minor style improvements in overview page 2020-12-08 11:39:16 +01:00
Alejandro Celaya
8d5f7e942d Implemented reducers for actions affecting short URLs list 2020-12-08 10:57:27 +01:00
Alejandro Celaya
17d5c4327b Added form to create short URLs to overview page 2020-12-07 20:37:03 +01:00
Alejandro Celaya
9b30a82a79 Created visitsOverview reducer test 2020-12-07 19:19:37 +01:00
Alejandro Celaya
a0ec3c0293 Improved wording 2020-12-07 13:03:47 +01:00
Alejandro Celaya
d9e39eee2b Added new reducer for visits overview, and added it to overview page 2020-12-07 12:12:39 +01:00
Alejandro Celaya
032e9c53f3 Extracted short URLs table into reusable component to use both on list section and overview section 2020-12-07 11:17:19 +01:00
Alejandro Celaya
dba0ac6442 Created Overview page as default page after connecting to a server 2020-12-06 18:37:22 +01:00
Alejandro Celaya
920effb4c6 Merge pull request #341 from acelaya-forks/feature/validate-flag
Feature/validate flag
2020-12-06 13:21:08 +01:00
Alejandro Celaya
bd6e455cd6 Fixed import 2020-12-06 13:20:16 +01:00
Alejandro Celaya
b9fc906537 Fixed alignment and margins for checkboxes in create form 2020-12-06 13:14:43 +01:00
Alejandro Celaya
1415f196bb Updated changelog 2020-12-06 13:09:06 +01:00
Alejandro Celaya
8f7e356e54 Added support to enable/disable validating the URL while it is created 2020-12-06 13:07:44 +01:00
Alejandro Celaya
0ed88079ad Skip install step when building docker image in travis 2020-12-06 11:57:14 +01:00
Alejandro Celaya
5182f9d147 Merge pull request #339 from acelaya-forks/feature/domains-dropdown
Feature/domains dropdown
2020-11-28 12:48:20 +01:00
Alejandro Celaya
4e1579832e Updated changelog 2020-11-28 12:38:16 +01:00
Alejandro Celaya
ff48c0cd45 Added DomainSelector test 2020-11-28 12:36:40 +01:00
Alejandro Celaya
02c7125236 Created domainsList reducer test 2020-11-28 12:22:52 +01:00
Alejandro Celaya
dc397d4b82 Improved existing tests 2020-11-28 11:45:04 +01:00
Alejandro Celaya
2a206f11b9 Renamed DomainsDropdown to DomainSelector 2020-11-28 09:58:05 +01:00
Alejandro Celaya
369fcf2f6a Improved design on domains dropdown 2020-11-28 09:34:41 +01:00
Alejandro Celaya
983e4db3b1 Created component to allow selecting from existing domains list 2020-11-25 21:05:27 +01:00
193 changed files with 3268 additions and 1477 deletions

59
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Continuous integration
on:
pull_request: null
push:
branches:
- main
jobs:
lint:
continue-on-error: true
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use node.js 14.15
uses: actions/setup-node@v1
with:
node-version: 14.15
- run: npm ci
- run: npm run lint
unit-tests:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use node.js 14.15
uses: actions/setup-node@v1
with:
node-version: 14.15
- run: npm ci
- run: npm run test:ci
- name: Publish coverage
uses: codecov/codecov-action@v1
with:
file: ./coverage/clover.xml
mutation-tests:
continue-on-error: true
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
fetch-depth: 0 # needed so that the main branch is also fetched
- name: Use node.js 14.15
uses: actions/setup-node@v1
with:
node-version: 14.15
- run: npm ci
- run: npm run mutate -- --mutate=$(git diff origin/main --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
build-docker-image:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- run: docker build -t shlink-web-client:test .

View File

@@ -9,7 +9,7 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2

View File

@@ -7,7 +7,7 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2

View File

@@ -1,3 +0,0 @@
tools:
external_code_coverage:
timeout: 1200

View File

@@ -1,43 +0,0 @@
dist: bionic
language: node_js
branches:
only:
- /.*/
cache:
directories:
- node_modules
node_js:
- '14.15.0'
jobs:
fast_finish: true
allow_failures:
- name: 'Lint'
- name: 'Mutation tests'
include:
- name: 'Lint'
install: npm ci
script: npm run lint
- name: 'Unit tests'
install: npm ci
script: npm run test:ci
after_success:
- node_modules/.bin/ocular coverage/clover.xml
- name: 'Mutation tests'
install: npm ci
before_script:
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
script: npm run mutate:ci
- name: 'Build docker image'
services:
- docker
script: docker build -t shlink-web-client:test .

View File

@@ -4,6 +4,60 @@ 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).
## [3.0.1] - 2020-12-30
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#364](https://github.com/shlinkio/shlink-web-client/issues/364) Fixed all dropdowns so that they are consistently styled.
* [#366](https://github.com/shlinkio/shlink-web-client/issues/366) Fixed text in visits menu jumping to next line in some tablet resolutions.
* [#367](https://github.com/shlinkio/shlink-web-client/issues/367) Removed conflicting overflow in visits table for mobile devices.
* [#365](https://github.com/shlinkio/shlink-web-client/issues/365) Fixed weird rendering of short URLs list in tablets.
* [#372](https://github.com/shlinkio/shlink-web-client/issues/372) Fixed importing servers in Android devices.
## [3.0.0] - 2020-12-22
### Added
* [#340](https://github.com/shlinkio/shlink-web-client/issues/340) Added new "overview" page, showing basic information of the active server.
As a side effect, it also introduces improvements in the "create short URL" page, grouping components by context and explaining what they are for.
* [#309](https://github.com/shlinkio/shlink-web-client/issues/309) Added new domain selector component in create URL form which allows selecting from previously used domains or set a new one.
* [#315](https://github.com/shlinkio/shlink-web-client/issues/315) Now you can tell if you want to validate the long URL when using Shlink >=2.4.
* [#285](https://github.com/shlinkio/shlink-web-client/issues/285) Improved visits section:
* Charts are now grouped in tabs, so that only one part of the components is rendered at a time.
* Amount of highlighted visits is now displayed.
* Date filtering can be now selected through relative times (last 7 days, last 30 days, etc) or absolute dates using date pickers.
* Only the visits for last 30 days are loaded by default. You can change that at any moment if required.
* [#355](https://github.com/shlinkio/shlink-web-client/issues/355) Improved home page, fixing also its scrolling behavior for mobile devices.
### Changed
* [#267](https://github.com/shlinkio/shlink-web-client/issues/267) Added some subtle but important improvements on UI/UX.
* [#352](https://github.com/shlinkio/shlink-web-client/issues/352) Moved from Scrutinizer to Codecov as the code coverage backend.
* [#217](https://github.com/shlinkio/shlink-web-client/issues/217) Improved how messages are displayed, by centralizing it in the `Message` and `Result` components.
* [#219](https://github.com/shlinkio/shlink-web-client/issues/219) Improved error messages when something fails while interacting with Shlink's API.
### Deprecated
* *Nothing*
### Removed
* [#344](https://github.com/shlinkio/shlink-web-client/issues/344) Dropped support for Shlink v1.
### Fixed
* *Nothing*
## [2.6.2] - 2020-11-14
### Added
* *Nothing*

View File

@@ -1,11 +1,11 @@
FROM node:14.15.0-alpine as node
FROM node:14.15-alpine as node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \
npm install && npm run build -- ${VERSION} --no-dist
FROM nginx:1.19.3-alpine
FROM nginx:1.19.6-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -1,10 +1,9 @@
# shlink-web-client
[![Build Status](https://img.shields.io/travis/com/shlinkio/shlink-web-client.svg?style=flat-square)](https://travis-ci.com/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/)
[![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/)
[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink-web-client/Continuous%20integration/main?logo=github&style=flat-square)](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink-web-client/main?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink-web-client)
[![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)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)

View File

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

407
package-lock.json generated
View File

@@ -3810,12 +3810,12 @@
}
},
"@stryker-mutator/api": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-4.1.2.tgz",
"integrity": "sha512-Oq4RO+FEhcaJciCwM2HuYJaKfnyijmQEGkxCrNK2y5NVTF7lIfTZblLuxEh6+QmTnxuagRxpA+kfgj8j4tmpcw==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-4.3.1.tgz",
"integrity": "sha512-2eXVXh7yhRML4a6tymx5WU8KmA1BNemIqx7aWnh8Mqx58WSTBfbyipm4JsS7/mzrDLQAguQmqTqsdNJlMkr1eQ==",
"dev": true,
"requires": {
"mutation-testing-report-schema": "~1.4.3",
"mutation-testing-report-schema": "~1.5.2",
"surrial": "~2.0.2",
"tslib": "~2.0.0"
},
@@ -3829,18 +3829,18 @@
}
},
"@stryker-mutator/core": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-4.1.2.tgz",
"integrity": "sha512-3PgOHHy4IQNP4Gd1yBrLXJQ7CY73Xepzb+LAg4Wdp8pMcjouzTKE+PL+Wjq5AkhF6DjgwF5deCk94GpbikKoPQ==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-4.3.1.tgz",
"integrity": "sha512-QjLaO7tvIOlKou6D1iOys46fmbJM42Ps2Wn5hP7G2cBZHB3DgjfYtCSgwF/fgoFybHPukI/8O/2gm302N/jgow==",
"dev": true,
"requires": {
"@stryker-mutator/api": "4.1.2",
"@stryker-mutator/instrumenter": "4.1.2",
"@stryker-mutator/util": "4.1.2",
"@stryker-mutator/api": "4.3.1",
"@stryker-mutator/instrumenter": "4.3.1",
"@stryker-mutator/util": "4.3.1",
"ajv": "~6.12.0",
"chalk": "~4.1.0",
"commander": "~6.2.0",
"execa": "~4.0.2",
"execa": "~5.0.0",
"file-url": "~3.0.0",
"get-port": "~5.0.0",
"glob": "~7.1.2",
@@ -3850,8 +3850,8 @@
"log4js": "~6.2.1",
"minimatch": "~3.0.4",
"mkdirp": "~1.0.3",
"mutation-testing-elements": "~1.4.3",
"mutation-testing-metrics": "~1.4.3",
"mutation-testing-elements": "~1.5.2",
"mutation-testing-metrics": "~1.5.2",
"npm-run-path": "~4.0.1",
"progress": "~2.0.0",
"rimraf": "~3.0.0",
@@ -3861,70 +3861,9 @@
"tree-kill": "~1.2.2",
"tslib": "~2.0.0",
"typed-inject": "~3.0.0",
"typed-rest-client": "~1.7.1"
"typed-rest-client": "~1.8.0"
},
"dependencies": {
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
"dev": true,
"requires": {
"restore-cursor": "^3.1.0"
}
},
"cli-width": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
"integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==",
"dev": true
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -3937,77 +3876,32 @@
}
},
"execa": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/execa/-/execa-4.0.3.tgz",
"integrity": "sha512-WFDXGHckXPWZX19t1kCsXzOpqX9LWYNqn4C+HqZlk/V0imTkzJZqf87ZBhvpHaftERYknpk0fjSylnXVlVgI0A==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz",
"integrity": "sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==",
"dev": true,
"requires": {
"cross-spawn": "^7.0.0",
"get-stream": "^5.0.0",
"human-signals": "^1.1.1",
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.0",
"onetime": "^5.1.0",
"signal-exit": "^3.0.2",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
}
},
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
"dev": true,
"requires": {
"escape-string-regexp": "^1.0.5"
}
},
"get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"dev": true,
"requires": {
"pump": "^3.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz",
"integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==",
"dev": true
},
"inquirer": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz",
"integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==",
"dev": true,
"requires": {
"ansi-escapes": "^4.2.1",
"chalk": "^4.1.0",
"cli-cursor": "^3.1.0",
"cli-width": "^3.0.0",
"external-editor": "^3.0.3",
"figures": "^3.0.0",
"lodash": "^4.17.19",
"mute-stream": "0.0.8",
"run-async": "^2.4.0",
"rxjs": "^6.6.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0",
"through": "^2.3.6"
}
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true
},
"is-stream": {
@@ -4016,12 +3910,6 @@
"integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==",
"dev": true
},
"lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
"dev": true
},
"mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
@@ -4034,12 +3922,6 @@
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"dev": true
},
"mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"dev": true
},
"npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -4064,16 +3946,6 @@
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true
},
"restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
"dev": true,
"requires": {
"onetime": "^5.1.0",
"signal-exit": "^3.0.2"
}
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -4083,12 +3955,6 @@
"glob": "^7.1.3"
}
},
"run-async": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
"integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
"dev": true
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4104,41 +3970,18 @@
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true
},
"signal-exit": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
"dev": true
},
"source-map": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
"dev": true
},
"string-width": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
"integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
"dev": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.0"
}
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.0"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"tslib": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz",
@@ -4157,9 +4000,9 @@
}
},
"@stryker-mutator/instrumenter": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-4.1.2.tgz",
"integrity": "sha512-uPmEPiQ2iMOy/ekxMBOSTqcu3bCjOaD0cCi/VaUqgkuCHUftfFUjk0TP+6whL26QKGfi4Zm9WzNKvswOgmB9yw==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-4.3.1.tgz",
"integrity": "sha512-NaYopLzD7VrwsryeNDrDEZ3YLXNZXmzQn6A4PukX7t1ETc+4YW0cj0q57IN0UydnYLCEhj6MHmxZAuUZmvTL4Q==",
"dev": true,
"requires": {
"@babel/core": "~7.12.3",
@@ -4169,62 +4012,21 @@
"@babel/plugin-proposal-decorators": "~7.12.1 ",
"@babel/plugin-proposal-private-methods": "^7.12.1",
"@babel/preset-typescript": "~7.12.1 ",
"@stryker-mutator/api": "4.1.2",
"@stryker-mutator/util": "4.1.2",
"@stryker-mutator/api": "4.3.1",
"@stryker-mutator/util": "4.3.1",
"angular-html-parser": "~1.7.0"
},
"dependencies": {
"@babel/generator": {
"version": "7.12.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.12.5.tgz",
"integrity": "sha512-m16TQQJ8hPt7E+OS/XVQg/7U184MLXtvuGbCdA7na61vha+ImkyyNM/9DDA0unYCVZn3ZOhng+qz48/KBOT96A==",
"dev": true,
"requires": {
"@babel/types": "^7.12.5",
"jsesc": "^2.5.1",
"source-map": "^0.5.0"
}
},
"@babel/parser": {
"version": "7.12.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.5.tgz",
"integrity": "sha512-FVM6RZQ0mn2KCf1VUED7KepYeUWoVShczewOCfm3nzoBybaih51h+sYVVGthW9M6lPByEPTQf+xm27PBdlpwmQ==",
"dev": true
},
"@babel/types": {
"version": "7.12.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.6.tgz",
"integrity": "sha512-hwyjw6GvjBLiyy3W0YQf0Z5Zf4NpYejUnKFcfcUhZCSffoBBp30w6wP2Wn6pk31jMYZvcOrB/1b7cGXvEoKogA==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.10.4",
"lodash": "^4.17.19",
"to-fast-properties": "^2.0.0"
}
},
"lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
"dev": true
},
"source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
"dev": true
}
}
},
"@stryker-mutator/jest-runner": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@stryker-mutator/jest-runner/-/jest-runner-4.1.2.tgz",
"integrity": "sha512-vxj3hUGjAqtCbZTsh+I99Q4xjmD/hXxLU0N7Ey7LXucqfvJErANbVvv+6md0YgpVbQnR9BTl59A09gZFTiEe3g==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@stryker-mutator/jest-runner/-/jest-runner-4.3.1.tgz",
"integrity": "sha512-19jj9mUR92DDBHveT1xOL5i8H2TynJZ5zNR0oFuoco2p8woXNH8CfjMh42NomawD60r4KqupiKAlMAW2f7IsjQ==",
"dev": true,
"requires": {
"@stryker-mutator/api": "4.1.2",
"@stryker-mutator/util": "4.1.2",
"semver": "~6.3.0"
"@stryker-mutator/api": "4.3.1",
"@stryker-mutator/util": "4.3.1",
"semver": "~6.3.0",
"tslib": "~2.0.3"
},
"dependencies": {
"semver": {
@@ -4232,32 +4034,56 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"dev": true
},
"tslib": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz",
"integrity": "sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==",
"dev": true
}
}
},
"@stryker-mutator/typescript-checker": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@stryker-mutator/typescript-checker/-/typescript-checker-4.1.2.tgz",
"integrity": "sha512-id9mS08OWj89h2lLqXJPCHAPmjoshnrqO8SO3P9k+b+NZ6J5XZf8mLT/xxWOQd1cct6zFBAItEerHpmMeQKL6Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@stryker-mutator/typescript-checker/-/typescript-checker-4.3.1.tgz",
"integrity": "sha512-M0VAd9/TNjXwCrL3n4ofihZJN7w1Kd5eEFm4DDs6xrl21gfFspE2sPsLuD7hxToEXN5mJg8jMYXVCZWwakDmXw==",
"dev": true,
"requires": {
"@stryker-mutator/api": "4.1.2",
"@stryker-mutator/util": "4.1.2",
"@stryker-mutator/api": "4.3.1",
"@stryker-mutator/util": "4.3.1",
"semver": "~7.3.2"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"requires": {
"yallist": "^4.0.0"
}
},
"semver": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
"integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
"version": "7.3.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
"integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
"dev": true,
"requires": {
"lru-cache": "^6.0.0"
}
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
}
}
},
"@stryker-mutator/util": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-4.1.2.tgz",
"integrity": "sha512-b0RuhIMPghNoZqP81aAYsqa0cL/eEkaxEdrrUyZwECIss1Ofg5+KU0SNdjw/VJWNvGsh0tE6rEyjFW+gZJ/TSg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-4.3.1.tgz",
"integrity": "sha512-H5XHukO22BLnn0uIl39AUIabTqNTC7rVtrz8Hb1CeGxYF5d5fn5dUGqjUprW9jsxRzt8N3o/j2ysW1ikfB24iA==",
"dev": true,
"requires": {
"lodash.flatmap": "~4.5.0"
@@ -9199,9 +9025,9 @@
}
},
"commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
"integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
"dev": true
},
"commondir": {
@@ -16554,9 +16380,9 @@
},
"dependencies": {
"debug": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz",
"integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"dev": true,
"requires": {
"ms": "2.1.2"
@@ -17241,24 +17067,24 @@
"dev": true
},
"mutation-testing-elements": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-1.4.3.tgz",
"integrity": "sha512-TxggiswcnNDYh7MI3yZtoAUZocbLDaz95SKHm2YjddUcgglwvc0xv/1GDQ/6UwweRhAISxXi7HpEUFBkSUJQhg==",
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-1.5.2.tgz",
"integrity": "sha512-ugngX+MB6tnwFxirDVSFiCQdbGMLCUQ7oPqltk5QJ/pye8aCyuA90C3Gw8klHk4aRL1JR91FEupacR9CgGXC7w==",
"dev": true
},
"mutation-testing-metrics": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-1.4.3.tgz",
"integrity": "sha512-b+5upZuuEDyY5oWb/3Jvo8kMxo9VHVP8t7ryZproBbp9bclDbS3c6kFScR6qJPFQ90NK2S7ab/cGxRHMXnNbJQ==",
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-1.5.2.tgz",
"integrity": "sha512-KRMBf1tRNh1snwt+5rZu4Le+dgam+GSX+39WfzJG9k55f/+isRn4hv3dhC4Vl/XdlJ29/Z0dTSe7ZFsWBTABUA==",
"dev": true,
"requires": {
"mutation-testing-report-schema": "^1.4.3"
"mutation-testing-report-schema": "^1.5.2"
}
},
"mutation-testing-report-schema": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-1.4.3.tgz",
"integrity": "sha512-+TLMYFGFL8MtcbIF0g9VG+OS5qkPPaZtvyT0v/XWrKgrbAhpnJYNsZV9/xMWuO/V8WC02mLksn/HrDnKnIVqXw==",
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-1.5.2.tgz",
"integrity": "sha512-ad90c42vMHa0S4ZZ3e5oZOzGAWg4G8JWto9MrmDkrwInf/Dq+Q8FupCOOTqed0V9FTWqv4sl5arRlYEbedW6Ww==",
"dev": true
},
"mute-stream": {
@@ -18002,23 +17828,6 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"dev": true
},
"ocular.js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/ocular.js/-/ocular.js-0.1.0.tgz",
"integrity": "sha1-OhRqtZhk6X/7Evg+HY8Duav5qGY=",
"dev": true,
"requires": {
"commander": "^2.9.0"
},
"dependencies": {
"commander": {
"version": "2.19.0",
"resolved": "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz",
"integrity": "sha1-9hmKqE5bg8RgVLlN3tv+1e6f8So=",
"dev": true
}
}
},
"on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -23092,9 +22901,9 @@
"dev": true
},
"react-external-link": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.1.1.tgz",
"integrity": "sha512-e2WnTWkg81cuqxmDfjOalliAE20+Y/uD+lserN4uuwkwu+ciGLB3BMz4m7GnXh2+TowIi4sLtCL7zr7aDnIaqA=="
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.2.0.tgz",
"integrity": "sha512-6Lm0pD6rxrvZGVrWIWda809cAtrIlM3fDsKh9y5bKEVpOI0FTO2nTnxaqEood8+rw3svHJgpJGN6lZHO69ZTAQ=="
},
"react-is": {
"version": "16.7.0",
@@ -25091,9 +24900,9 @@
"dev": true
},
"debug": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz",
"integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"dev": true,
"requires": {
"ms": "2.1.2"
@@ -27324,9 +27133,9 @@
"dev": true
},
"typed-rest-client": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.7.3.tgz",
"integrity": "sha512-CwTpx/TkRHGZoHkJhBcp4X8K3/WtlzSHVQR0OIFnt10j4tgy4ypgq/SrrgVpA1s6tAL49Q6J3R5C0Cgfh2ddqA==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.0.tgz",
"integrity": "sha512-Nu1MrdH6ECrRW5gHoRAdubgCs4oH6q5/J76jsEC8bVDfvVoVPkigukPalhMHPwb7ZvpsZqPptd5zpt/QdtrdBw==",
"dev": true,
"requires": {
"qs": "^6.9.1",

View File

@@ -6,7 +6,7 @@
"repository": "https://github.com/shlinkio/shlink-web-client",
"license": "MIT",
"scripts": {
"lint": "npm run lint:js && npm run lint:css",
"lint": "npm run lint:css && npm run lint:js",
"lint:js": "eslint --ext .js,.ts,.tsx src test scripts config",
"lint:js:fix": "npm run lint:js -- --fix",
"lint:css": "stylelint src/*.scss src/**/*.scss",
@@ -17,8 +17,7 @@
"test": "node scripts/test.js --env=jsdom --colors",
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
"mutate": "./node_modules/.bin/stryker run --concurrency 4",
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.1",
@@ -47,7 +46,7 @@
"react-copy-to-clipboard": "^5.0.2",
"react-datepicker": "^3.3.0",
"react-dom": "^17.0.1",
"react-external-link": "^1.1.1",
"react-external-link": "^1.2.0",
"react-leaflet": "^3.0.2",
"react-moment": "^1.0.0",
"react-redux": "^7.2.2",
@@ -65,9 +64,9 @@
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
"@babel/plugin-proposal-optional-chaining": "^7.12.1",
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
"@stryker-mutator/core": "^4.1.2",
"@stryker-mutator/jest-runner": "^4.1.2",
"@stryker-mutator/typescript-checker": "^4.1.2",
"@stryker-mutator/core": "^4.3.1",
"@stryker-mutator/jest-runner": "^4.3.1",
"@stryker-mutator/typescript-checker": "^4.3.1",
"@svgr/webpack": "^5.4.0",
"@types/chart.js": "^2.9.27",
"@types/classnames": "^2.2.11",
@@ -118,7 +117,6 @@
"jest-resolve": "^26.6.2",
"mini-css-extract-plugin": "^1.3.1",
"object-assign": "^4.1.1",
"ocular.js": "^0.1.0",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"pnp-webpack-plugin": "^1.6.4",
"postcss": "^8.1.7",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -16,7 +16,7 @@ const App = (
CreateServer: FC,
EditServer: FC,
Settings: FC,
ShlinkVersions: FC,
ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers }: AppProps) => {
// On first load, try to fetch the remote servers if the list is empty
useEffect(() => {
@@ -41,8 +41,8 @@ const App = (
</Switch>
</div>
<div className="shlink-footer text-center text-md-right">
<ShlinkVersions />
<div className="shlink-footer">
<ShlinkVersionsContainer />
</div>
</div>
</div>

View File

@@ -0,0 +1,16 @@
import { ProblemDetailsError } from './types';
import { isInvalidArgumentError } from './utils';
export interface ShlinkApiErrorProps {
errorData?: ProblemDetailsError;
fallbackMessage?: string;
}
export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => (
<>
{errorData?.detail ?? fallbackMessage}
{isInvalidArgumentError(errorData) &&
<p className="mb-0">Invalid elements: [{errorData.invalidElements.join(', ')}]</p>
}
</>
);

View File

@@ -3,7 +3,7 @@ import { isEmpty, isNil, reject } from 'ramda';
import { AxiosInstance, AxiosResponse, Method } from 'axios';
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
import { OptionalString } from '../utils';
import { OptionalString } from '../../utils/utils';
import {
ShlinkHealth,
ShlinkMercureInfo,
@@ -13,7 +13,10 @@ import {
ShlinkVisits,
ShlinkVisitsParams,
ShlinkShortUrlMeta,
} from './types';
ShlinkDomain,
ShlinkDomainsResponse,
ShlinkVisitsOverview,
} from '../types';
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
const rejectNilProps = reject(isNil);
@@ -48,6 +51,10 @@ export default class ShlinkApiClient {
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
.then(({ data }) => data.visits);
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
.then(({ data }) => data.visits);
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
.then(({ data }) => data);
@@ -93,6 +100,9 @@ export default class ShlinkApiClient {
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
.then((resp) => resp.data);
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
try {
return await this.axios({
@@ -115,7 +125,7 @@ export default class ShlinkApiClient {
// When the request is not invalid or we have already tried both API versions, throw the error and let the
// caller handle it
if (!apiVersionIsNotSupported || this.apiVersion === 1) {
if (!apiVersionIsNotSupported || this.apiVersion === 2) {
throw e;
}

View File

@@ -0,0 +1,8 @@
import Bottle from 'bottlejs';
import buildShlinkApiClient from './ShlinkApiClientBuilder';
const provideServices = (bottle: Bottle) => {
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
};
export default provideServices;

View File

@@ -1,6 +1,6 @@
import { Visit } from '../../visits/types'; // FIXME Should be defined as part of this module
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; // FIXME Should be defined as part of this module
import { OptionalString } from '../utils';
import { Visit } from '../../visits/types';
import { OptionalString } from '../../utils/utils';
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
export interface ShlinkShortUrlsResponse {
data: ShortUrl[];
@@ -25,22 +25,27 @@ interface ShlinkTagsStats {
export interface ShlinkTags {
tags: string[];
stats?: ShlinkTagsStats[]; // Is only optional in old Shlink versions
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
}
export interface ShlinkTagsResponse {
data: string[];
stats?: ShlinkTagsStats[]; // Is only optional in old Shlink versions
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
}
export interface ShlinkPaginator {
currentPage: number;
pagesCount: number;
totalItems: number;
}
export interface ShlinkVisits {
data: Visit[];
pagination?: ShlinkPaginator; // Is only optional in old Shlink versions
pagination: ShlinkPaginator;
}
export interface ShlinkVisitsOverview {
visitsCount: number;
}
export interface ShlinkVisitsParams {
@@ -55,12 +60,30 @@ export interface ShlinkShortUrlMeta extends ShortUrlMeta {
longUrl?: string;
}
export interface ShlinkDomain {
domain: string;
isDefault: boolean;
}
export interface ShlinkDomainsResponse {
data: ShlinkDomain[];
}
export interface ProblemDetailsError {
type: string;
detail: string;
title: string;
status: number;
error?: string; // Deprecated
message?: string; // Deprecated
[extraProps: string]: any;
}
export interface InvalidArgumentError extends ProblemDetailsError {
type: 'INVALID_ARGUMENT';
invalidElements: string[];
}
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
type: 'INVALID_SHORTCODE_DELETION';
threshold: number;
}

10
src/api/utils/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { AxiosError } from 'axios';
import { InvalidArgumentError, InvalidShortUrlDeletion, ProblemDetailsError } from '../types';
export const parseApiError = (e: AxiosError<ProblemDetailsError>) => e.response?.data;
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
error?.type === 'INVALID_ARGUMENT';
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
error?.type === 'INVALID_SHORTCODE_DELETION';

View File

@@ -1,10 +1,10 @@
@import '../utils/base';
@import '../utils/mixins/vertical-align';
$asideMenuMobileWidth: 280px;
.aside-menu {
background-color: #f7f7f7;
width: $asideMenuWidth;
background-color: white;
box-shadow: rgba(0, 0, 0, .05) 0 8px 15px;
position: fixed !important;
padding-top: 13px;
padding-bottom: 10px;
@@ -22,7 +22,6 @@ $asideMenuMobileWidth: 280px;
}
@media (max-width: $smMax) {
width: $asideMenuMobileWidth !important;
transition: left 300ms;
top: $headerHeight - 3px;
box-shadow: -10px 0 50px 11px rgba(0, 0, 0, .55);
@@ -31,7 +30,7 @@ $asideMenuMobileWidth: 280px;
.aside-menu--hidden {
@media (max-width: $smMax) {
left: -($asideMenuMobileWidth + 35px);
left: -($asideMenuWidth + 35px);
}
}
@@ -44,10 +43,14 @@ $asideMenuMobileWidth: 280px;
margin: 0 -15px;
text-decoration: none !important;
cursor: pointer;
@media (max-width: $smMax) {
margin: 0;
}
}
.aside-menu__item:hover {
background-color: $lightHoverColor;
background-color: $lightColor;
}
.aside-menu__item--selected {

View File

@@ -3,6 +3,7 @@ import {
faLink as createIcon,
faTags as tagsIcon,
faPen as editIcon,
faHome as overviewIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
@@ -21,7 +22,6 @@ export interface AsideMenuProps {
interface AsideMenuItemProps extends NavLinkProps {
to: string;
className?: string;
}
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
@@ -36,10 +36,10 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
);
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
{ selectedServer, className, showOnMobile = false }: AsideMenuProps,
{ selectedServer, showOnMobile = false }: AsideMenuProps,
) => {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classNames('aside-menu', className, {
const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
@@ -48,6 +48,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<AsideMenuItem to={buildPath('/overview')}>
<FontAwesomeIcon icon={overviewIcon} />
<span className="aside-menu__item-text">Overview</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>

View File

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

View File

@@ -1,6 +1,6 @@
import { Component, ReactNode } from 'react';
import { Button } from 'reactstrap';
import './ErrorHandler.scss';
import { SimpleCard } from '../utils/SimpleCard';
interface ErrorHandlerState {
hasError: boolean;
@@ -25,14 +25,16 @@ const ErrorHandler = (
}
}
public render(): ReactNode | undefined {
public render(): ReactNode {
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 className="home">
<SimpleCard className="p-4">
<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>
</SimpleCard>
</div>
);
}

View File

@@ -1,18 +1,41 @@
@import '../utils/base';
@import '../utils/mixins/vertical-align';
.home {
text-align: center;
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
position: relative;
padding-top: 15px;
@media (min-width: $mdMin) {
padding-top: 0;
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
}
}
.home__logo {
@include vertical-align();
}
.home__main-card {
margin: 0 auto;
max-width: 720px;
@media (min-width: $mdMin) {
@include vertical-align();
}
}
.home__title {
text-align: center;
font-size: 1.75rem;
margin: 0;
@media (min-width: $mdMin) {
font-size: 2.2rem;
}
}
.home__servers-container {
@media (min-width: $mdMin) {
border-left: 1px solid rgba(0, 0, 0, .125);
}
}

View File

@@ -1,8 +1,11 @@
import { isEmpty, values } from 'ramda';
import { Link } from 'react-router-dom';
import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import ServersListGroup from '../servers/ServersListGroup';
import './Home.scss';
import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo';
export interface HomeProps {
servers: ServersMap;
@@ -14,11 +17,32 @@ const Home = ({ servers }: HomeProps) => {
return (
<div className="home">
<h1 className="home__title">Welcome to Shlink</h1>
<ServersListGroup servers={serversList}>
{hasServers && <span>Please, select a server.</span>}
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
</ServersListGroup>
<Card className="home__main-card">
<Row noGutters>
<div className="col-md-5 d-none d-md-block">
<div className="p-4">
<ShlinkLogo />
</div>
</div>
<div className="col-md-7 home__servers-container">
<div className="p-4">
<h1 className="home__title">Welcome!</h1>
</div>
<ServersListGroup embedded servers={serversList}>
{!hasServers && (
<div className="p-4">
<p>This application will help you to manage your Shlink servers.</p>
<p>To start, please, <Link to="/server/create">add your first server</Link>.</p>
<p className="m-0">
You still don&lsquo;t have a Shlink server?
Learn how to <ExternalLink href="https://shlink.io/documentation">get started</ExternalLink>.
</p>
</div>
)}
</ServersListGroup>
</div>
</Row>
</Card>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC, useEffect } from 'react';
import { Link } from 'react-router-dom';
@@ -6,7 +6,7 @@ import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } f
import classNames from 'classnames';
import { RouteComponentProps } from 'react-router';
import { useToggle } from '../utils/helpers/hooks';
import shlinkLogo from './shlink-logo-white.png';
import { ShlinkLogo } from './img/ShlinkLogo';
import './MainHeader.scss';
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
@@ -15,14 +15,13 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
useEffect(close, [ location ]);
const createServerPath = '/server/create';
const settingsPath = '/settings';
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
return (
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
<NavbarBrand tag={Link} to="/">
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
</NavbarBrand>
<NavbarToggler onClick={toggleOpen}>
@@ -32,15 +31,10 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
<Collapse navbar isOpen={isOpen}>
<Nav navbar className="ml-auto">
<NavItem>
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>
<NavItem>
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
<FontAwesomeIcon icon={plusIcon} />&nbsp; Add server
</NavLink>
</NavItem>
<ServersDropdown />
</Nav>
</Collapse>

View File

@@ -33,11 +33,11 @@
color: white;
}
.menu-layout__container {
.menu-layout__container.menu-layout__container {
padding: 20px 0 0;
min-height: 100%;
@media (min-width: $mdMin) {
padding: 30px 15px 0;
padding: 30px 0 0 $asideMenuWidth;
}
}

View File

@@ -1,5 +1,5 @@
import { FC, useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Redirect, Route, Switch } from 'react-router-dom';
import { useSwipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -20,6 +20,7 @@ const MenuLayout = (
ShortUrlVisits: FC,
TagVisits: FC,
ServerError: FC,
Overview: FC,
) => withSelectedServer(({ location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
@@ -55,15 +56,17 @@ const MenuLayout = (
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
<div {...swipeableProps} className="menu-layout__swipeable">
<div className="row menu-layout__swipeable-inner">
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
<div className="menu-layout__container">
<div className="menu-layout__swipeable-inner">
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="menu-layout__container" onClick={() => hideSidebar()}>
<div className="container-xl">
<Switch>
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
<Route exact path="/server/:serverId/overview" component={Overview} />
<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} />
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}

View File

@@ -1,3 +1,9 @@
@import '../utils/base';
.no-menu-wrapper {
padding: 40px 20px 20px;
padding: 15px 0 0;
@media (min-width: $mdMin) {
padding: 30px 20px 20px;
}
}

View File

@@ -1,6 +1,6 @@
import { FC } from 'react';
import './NoMenuLayout.scss';
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
export default NoMenuLayout;

View File

@@ -1,5 +1,6 @@
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { SimpleCard } from '../utils/SimpleCard';
interface NotFoundProps {
to?: string;
@@ -7,13 +8,15 @@ interface NotFoundProps {
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
<div className="home">
<h2>Oops! We could not find requested route.</h2>
<p>
Use your browser&apos;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">{children}</Link>
<SimpleCard className="p-4">
<h2>Oops! We could not find requested route.</h2>
<p>
Use your browser&apos;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">{children}</Link>
</SimpleCard>
</div>
);

View File

@@ -1,16 +1,14 @@
import classNames from 'classnames';
import { pipe } from 'ramda';
import { ExternalLink } from 'react-external-link';
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
import { isReachableServer, SelectedServer } from '../servers/data';
import { isReachableServer } from '../servers/data';
import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer';
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
export interface ShlinkVersionsProps {
selectedServer: SelectedServer;
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
clientVersion?: string;
className?: string;
}
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
@@ -19,13 +17,11 @@ const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-cli
</ExternalLink>
);
const ShlinkVersions = (
{ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
) => {
const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => {
const normalizedClientVersion = normalizeVersion(clientVersion);
return (
<small className={classNames('text-muted', className)}>
<small className="text-muted">
{isReachableServer(selectedServer) &&
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
}

View File

@@ -0,0 +1,9 @@
@import '../utils/base';
.shlink-versions-container--with-server {
margin-left: 0;
@media (min-width: $mdMin) {
margin-left: $asideMenuWidth;
}
}

View File

@@ -0,0 +1,22 @@
import classNames from 'classnames';
import { isReachableServer, SelectedServer } from '../servers/data';
import ShlinkVersions from './ShlinkVersions';
import './ShlinkVersionsContainer.scss';
export interface ShlinkVersionsContainerProps {
selectedServer: SelectedServer;
}
const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
const classes = classNames('text-center', {
'shlink-versions-container--with-server': isReachableServer(selectedServer),
});
return (
<div className={classes}>
<ShlinkVersions selectedServer={selectedServer} />
</div>
);
};
export default ShlinkVersionsContainer;

View File

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

View File

@@ -4,7 +4,7 @@
border-radius: .25rem;
overflow: hidden;
min-height: 2.6rem;
padding: 6px 0 0 6px;
padding: .5rem 0 0 1rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
@@ -40,7 +40,9 @@
background: transparent;
border: 0;
outline: none;
padding: 3px 5px;
padding: 1px 0;
width: 100%;
margin-bottom: 6px;
font-size: 1.25rem;
color: #495057;
}

View File

@@ -1,3 +1,4 @@
import axios from 'axios';
import Bottle, { Decorator } from 'bottlejs';
import ScrollToTop from '../ScrollToTop';
import MainHeader from '../MainHeader';
@@ -5,13 +6,14 @@ import Home from '../Home';
import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler';
import ShlinkVersions from '../ShlinkVersions';
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
bottle.constant('window', (global as any).window);
bottle.constant('console', global.console);
bottle.constant('axios', axios);
bottle.serviceFactory('ScrollToTop', ScrollToTop);
bottle.decorator('ScrollToTop', withRouter);
@@ -33,14 +35,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'ShortUrlVisits',
'TagVisits',
'ServerError',
'Overview',
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -3,6 +3,7 @@ import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda';
import App from '../App';
import provideApiServices from '../api/services/provideServices';
import provideCommonServices from '../common/services/provideServices';
import provideShortUrlsServices from '../short-urls/services/provideServices';
import provideServersServices from '../servers/services/provideServices';
@@ -11,6 +12,7 @@ import provideTagsServices from '../tags/services/provideServices';
import provideUtilsServices from '../utils/services/provideServices';
import provideMercureServices from '../mercure/services/provideServices';
import provideSettingsServices from '../settings/services/provideServices';
import provideDomainsServices from '../domains/services/provideServices';
import { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>;
@@ -30,10 +32,21 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
actionServiceNames.reduce(mapActionService, {}),
);
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings', 'ShlinkVersions');
bottle.serviceFactory(
'App',
App,
'MainHeader',
'Home',
'MenuLayout',
'CreateServer',
'EditServer',
'Settings',
'ShlinkVersionsContainer',
);
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
provideCommonServices(bottle, connect, withRouter);
provideApiServices(bottle);
provideShortUrlsServices(bottle, connect);
provideServersServices(bottle, connect, withRouter);
provideTagsServices(bottle, connect);
@@ -41,5 +54,6 @@ provideVisitsServices(bottle, connect);
provideUtilsServices(bottle);
provideMercureServices(bottle);
provideSettingsServices(bottle, connect);
provideDomainsServices(bottle, connect);
export default container;

View File

@@ -14,6 +14,8 @@ import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
export interface ShlinkState {
servers: ServersMap;
@@ -33,6 +35,8 @@ export interface ShlinkState {
tagEdit: TagEdition;
mercureInfo: MercureInfo;
settings: Settings;
domainsList: DomainsList;
visitsOverview: VisitsOverview;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;

View File

@@ -0,0 +1,12 @@
@import '../utils/mixins/vertical-align';
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active,
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active {
color: #495057 !important;
}
.domains-dropdown__back-btn.domains-dropdown__back-btn,
.domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
border-color: #ced4da;
}

View File

@@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap';
import { InputProps } from 'reactstrap/lib/Input';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUndo } from '@fortawesome/free-solid-svg-icons';
import { isEmpty, pipe } from 'ramda';
import { DropdownBtn } from '../utils/DropdownBtn';
import { useToggle } from '../utils/helpers/hooks';
import { DomainsList } from './reducers/domainsList';
import './DomainSelector.scss';
export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> {
value?: string;
onChange: (domain: string) => void;
}
interface DomainSelectorConnectProps extends DomainSelectorProps {
listDomains: Function;
domainsList: DomainsList;
}
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
const [ inputDisplayed,, showInput, hideInput ] = useToggle();
const { domains } = domainsList;
const valueIsEmpty = isEmpty(value);
const unselectDomain = () => onChange('');
useEffect(() => {
listDomains();
}, []);
return inputDisplayed ? (
<InputGroup>
<Input
value={value}
placeholder="Domain"
onChange={(e) => onChange(e.target.value)}
/>
<InputGroupAddon addonType="append">
<Button
id="backToDropdown"
outline
type="button"
className="domains-dropdown__back-btn"
onClick={pipe(unselectDomain, hideInput)}
>
<FontAwesomeIcon icon={faUndo} />
</Button>
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
Existing domains
</UncontrolledTooltip>
</InputGroupAddon>
</InputGroup>
) : (
<DropdownBtn
text={valueIsEmpty ? 'Domain' : `Domain: ${value}`}
className={!valueIsEmpty ? 'domains-dropdown__toggle-btn--active' : ''}
>
{domains.map(({ domain, isDefault }) => (
<DropdownItem
key={domain}
active={value === domain || isDefault && valueIsEmpty}
onClick={() => onChange(domain)}
>
{domain}
{isDefault && <span className="float-right text-muted">default</span>}
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem onClick={pipe(unselectDomain, showInput)}>
<i>New domain</i>
</DropdownItem>
</DropdownBtn>
);
};

View File

@@ -0,0 +1,49 @@
import { Action, Dispatch } from 'redux';
import { ShlinkDomain } from '../../api/types';
import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types';
/* eslint-disable padding-line-between-statements */
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
/* eslint-enable padding-line-between-statements */
export interface DomainsList {
domains: ShlinkDomain[];
loading: boolean;
error: boolean;
}
export interface ListDomainsAction extends Action<string> {
domains: ShlinkDomain[];
}
const initialState: DomainsList = {
domains: [],
loading: false,
error: false,
};
export default buildReducer<DomainsList, ListDomainsAction>({
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
}, initialState);
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
dispatch: Dispatch,
getState: GetState,
) => {
dispatch({ type: LIST_DOMAINS_START });
const { listDomains } = buildShlinkApiClient(getState);
try {
const domains = await listDomains();
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
} catch (e) {
dispatch({ type: LIST_DOMAINS_ERROR });
}
};

View File

@@ -0,0 +1,15 @@
import Bottle from 'bottlejs';
import { ConnectDecorator } from '../../container/types';
import { listDomains } from '../reducers/domainsList';
import { DomainSelector } from '../DomainSelector';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('DomainSelector', () => DomainSelector);
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
// Actions
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
};
export default provideServices;

View File

@@ -1,9 +1,12 @@
@import './utils/base';
@import 'node_modules/bootstrap/scss/bootstrap.scss';
@import './common/react-tagsinput.scss';
html,
body,
#root {
height: 100%;
background: $lightColor;
}
* {
@@ -14,6 +17,29 @@ body,
background-color: $mainColor !important;
}
.card {
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
}
.card-header {
background-color: white;
}
.card-footer {
background-color: rgba(255, 255, 255, .5);
}
.container-xl {
@media (min-width: $xlgMin) {
max-width: 1320px;
}
@media (max-width: $smMax) {
padding-right: 0;
padding-left: 0;
}
}
.dropdown-item:not(:disabled) {
cursor: pointer;
}
@@ -29,6 +55,10 @@ body,
background-color: $mainColor;
}
.table-hover tbody tr:hover {
background-color: $lightColor;
}
.react-datepicker__input-container,
.react-datepicker-wrapper {
display: block !important;
@@ -52,6 +82,10 @@ body,
white-space: nowrap;
}
.pointer {
cursor: pointer;
}
.text-ellipsis {
text-overflow: ellipsis;
overflow: hidden;
@@ -69,3 +103,17 @@ body,
.progress-bar {
background-color: $mainColor;
}
.btn-xs-block {
@media (max-width: $xsMax) {
width: 100%;
display: block;
}
}
.btn-md-block {
@media (max-width: $mdMax) {
width: 100%;
display: block;
}
}

View File

@@ -5,10 +5,8 @@ import { homepage } from '../package.json';
import container from './container';
import store from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css';
import './common/react-tagsinput.scss';
import './index.scss';
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS

View File

@@ -1,8 +1,8 @@
import { Action, Dispatch } from 'redux';
import { ShlinkMercureInfo } from '../../utils/services/types';
import { ShlinkMercureInfo } from '../../api/types';
import { GetState } from '../../container/types';
import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
/* eslint-disable padding-line-between-statements */
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';

View File

@@ -16,6 +16,8 @@ import tagDeleteReducer from '../tags/reducers/tagDelete';
import tagEditReducer from '../tags/reducers/tagEdit';
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({
@@ -36,4 +38,6 @@ export default combineReducers<ShlinkState>({
tagEdit: tagEditReducer,
mercureInfo: mercureInfoReducer,
settings: settingsReducer,
domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
});

View File

@@ -1,7 +1,7 @@
import { FC } from 'react';
import { v4 as uuid } from 'uuid';
import { RouterProps } from 'react-router';
import classNames from 'classnames';
import { Result } from '../utils/Result';
import NoMenuLayout from '../common/NoMenuLayout';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm';
@@ -15,19 +15,11 @@ interface CreateServerProps extends RouterProps {
createServer: (server: ServerWithId) => void;
}
const Result: FC<{ type: 'success' | 'error' }> = ({ children, type }) => (
<div className="row">
<div className="col-md-10 offset-md-1">
<div
className={classNames('p-2 mt-3 text-white text-center', {
'bg-main': type === 'success',
'bg-danger': type === 'error',
})}
>
{children}
</div>
</div>
</div>
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
<Result type={type}>
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
</Result>
);
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
@@ -39,18 +31,22 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
const id = uuid();
createServer({ ...serverData, id });
push(`/server/${id}/list-short-urls/1`);
push(`/server/${id}`);
};
return (
<NoMenuLayout>
<ServerForm onSubmit={handleSubmit}>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
<button className="btn btn-outline-primary">Create server</button>
</ServerForm>
{serversImported && <Result type="success">Servers properly imported. You can now select one from the list :)</Result>}
{errorImporting && <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>}
{(serversImported || errorImporting) && (
<div className="mt-4">
{serversImported && <ImportResult type="success" />}
{errorImporting && <ImportResult type="error" />}
</div>
)}
</NoMenuLayout>
);
};

View File

@@ -26,7 +26,7 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: De
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
<i>
No data will be deleted, only the access to this server will be removed from this host.
No data will be deleted, only the access to this server will be removed from this device.
You can create it again at any moment.
</i>
</p>

View File

@@ -18,12 +18,16 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData);
push(`/server/${selectedServer.id}/list-short-urls/1`);
push(`/server/${selectedServer.id}`);
};
return (
<NoMenuLayout>
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
<ServerForm
title={<h5 className="mb-0">Edit &quot;{selectedServer.name}&quot;</h5>}
initialValues={selectedServer}
onSubmit={handleSubmit}
>
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
<Button outline color="primary">Save</Button>
</ServerForm>

11
src/servers/Overview.scss Normal file
View File

@@ -0,0 +1,11 @@
@import '../utils/base';
.overview__card.overview__card {
text-align: center;
border-top: 3px solid $mainColor;
}
.overview__card-title {
text-transform: uppercase;
color: #6c757d;
}

109
src/servers/Overview.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { FC, useEffect } from 'react';
import { Card, CardBody, CardHeader, CardText, CardTitle } from 'reactstrap';
import { Link, useHistory } from 'react-router-dom';
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { Versions } from '../utils/helpers/version';
import { isServerWithId, SelectedServer } from './data';
import './Overview.scss';
interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void;
listTags: Function;
tagsList: TagsList;
selectedServer: SelectedServer;
visitsOverview: VisitsOverview;
loadVisitsOverview: Function;
}
export const Overview = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
CreateShortUrl: FC<CreateShortUrlProps>,
ForServerVersion: FC<Versions>,
) => boundToMercureHub(({
shortUrlsList,
listShortUrls,
listTags,
tagsList,
selectedServer,
loadVisitsOverview,
visitsOverview,
}: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount } = visitsOverview;
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
const history = useHistory();
useEffect(() => {
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
listTags();
loadVisitsOverview();
}, []);
return (
<>
<div className="row mb-3">
<div className="col-md-6 col-lg-4">
<Card className="overview__card mb-2" body>
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
<CardText tag="h2">
<ForServerVersion minVersion="2.2.0">
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
</ForServerVersion>
<ForServerVersion maxVersion="2.1.*">
<small className="text-muted"><i>Shlink 2.2 is needed</i></small>
</ForServerVersion>
</CardText>
</Card>
</div>
<div className="col-md-6 col-lg-4">
<Card className="overview__card mb-2" body>
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
<CardText tag="h2">
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</CardText>
</Card>
</div>
<div className="col-md-12 col-lg-4">
<Card className="overview__card mb-2" body>
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
</Card>
</div>
</div>
<Card className="mb-4">
<CardHeader>
<span className="d-sm-none">Create a short URL</span>
<h5 className="d-none d-sm-inline">Create a short URL</h5>
<Link className="float-right" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link>
</CardHeader>
<CardBody>
<CreateShortUrl basicMode />
</CardBody>
</Card>
<Card>
<CardHeader>
<span className="d-sm-none">Recently created URLs</span>
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
<Link className="float-right" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link>
</CardHeader>
<CardBody>
<ShortUrlsTable
shortUrlsList={shortUrlsList}
selectedServer={selectedServer}
className="mb-0"
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tag=${tag}`)}
/>
</CardBody>
</Card>
</>
);
}, () => 'https://shlink.io/new-visit');

View File

@@ -1,6 +1,8 @@
import { isEmpty, values } from 'ramda';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import { Link } from 'react-router-dom';
import { faPlus as plusIcon, faFileDownload as exportIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ServersExporter from './services/ServersExporter';
import { isServerWithId, SelectedServer, ServersMap } from './data';
@@ -11,10 +13,15 @@ export interface ServersDropdownProps {
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
const serversList = values(servers);
const createServerItem = (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add server</span>
</DropdownItem>
);
const renderServers = () => {
if (isEmpty(serversList)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
return createServerItem;
}
return (
@@ -23,15 +30,16 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
<DropdownItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
to={`/server/${id}`}
active={isServerWithId(selectedServer) && selectedServer.id === id}
>
{name}
</DropdownItem>
))}
<DropdownItem divider />
{createServerItem}
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
Export servers
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
</DropdownItem>
</>
);
@@ -39,7 +47,9 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Servers</span>
</DropdownToggle>
<DropdownMenu right>{renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);

View File

@@ -1,8 +1,14 @@
@import '../utils/base';
@import '../utils/mixins/vertical-align';
@import '../utils/mixins/thin-scroll';
.servers-list__list-group {
.servers-list__list-group.servers-list__list-group {
width: 100%;
}
.servers-list__list-group:not(.servers-list__list-group--embedded) {
max-width: 400px;
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
}
.servers-list__server-item.servers-list__server-item {
@@ -11,8 +17,29 @@
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item:hover {
background-color: $lightColor;
}
.servers-list__server-item-icon {
@include vertical-align();
right: 1rem;
}
.servers-list__list-group--embedded.servers-list__list-group--embedded {
border-radius: 0;
border-top: 1px solid rgba(0, 0, 0, .125);
@media (min-width: $mdMin) {
max-height: 220px;
overflow-x: auto;
@include thin-scroll();
}
.servers-list__server-item {
border: none;
border-bottom: 1px solid rgba(0, 0, 0, .125);
}
}

View File

@@ -1,6 +1,7 @@
import { FC } from 'react';
import { ListGroup, ListGroupItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { ServerWithId } from './data';
@@ -8,22 +9,23 @@ import './ServersListGroup.scss';
interface ServersListGroup {
servers: ServerWithId[];
embedded?: boolean;
}
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
{name}
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
</ListGroupItem>
);
const ServersListGroup: FC<ServersListGroup> = ({ servers, children }) => (
const ServersListGroup: FC<ServersListGroup> = ({ servers, children, embedded = false }) => (
<>
<div className="container">
<h5>{children}</h5>
</div>
{children && <h5 className="mb-md-3">{children}</h5>}
{servers.length > 0 && (
<ListGroup className="servers-list__list-group mt-md-3">
<ListGroup
className={classNames('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
>
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</ListGroup>
)}

View File

@@ -4,6 +4,7 @@ import Message from '../../utils/Message';
import ServersListGroup from '../ServersListGroup';
import { DeleteServerButtonProps } from '../DeleteServerButton';
import { isServerWithId, SelectedServer, ServersMap } from '../data';
import NoMenuLayout from '../../common/NoMenuLayout';
import './ServerError.scss';
interface ServerErrorProps {
@@ -14,9 +15,9 @@ interface ServerErrorProps {
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
{ servers, selectedServer },
) => (
<div className="server-error__container flex-column">
<div className="row w-100 mb-3 mb-md-5">
<Message type="error">
<NoMenuLayout>
<div className="server-error__container flex-column">
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && (
<>
@@ -25,21 +26,21 @@ export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC
</>
)}
</Message>
<ServersListGroup servers={Object.values(servers)}>
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</ServersListGroup>
{isServerWithId(selectedServer) && (
<div className="container mt-3 mt-md-5">
<h5>
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
</h5>
</div>
)}
</div>
<ServersListGroup servers={Object.values(servers)}>
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</ServersListGroup>
{isServerWithId(selectedServer) && (
<div className="container mt-3 mt-md-5">
<h5>
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
</h5>
</div>
)}
</div>
</NoMenuLayout>
);

View File

@@ -0,0 +1,3 @@
.server-form .form-group:last-child {
margin-bottom: 0;
}

View File

@@ -1,14 +1,17 @@
import { FC, useEffect, useState } from 'react';
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
import { FC, ReactNode, useEffect, useState } from 'react';
import { FormGroupContainer } from '../../utils/FormGroupContainer';
import { handleEventPreventingDefault } from '../../utils/utils';
import { ServerData } from '../data';
import { SimpleCard } from '../../utils/SimpleCard';
import './ServerForm.scss';
interface ServerFormProps {
onSubmit: (server: ServerData) => void;
initialValues?: ServerData;
title?: ReactNode;
}
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children }) => {
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
const [ name, setName ] = useState('');
const [ url, setUrl ] = useState('');
const [ apiKey, setApiKey ] = useState('');
@@ -21,10 +24,12 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
}, [ initialValues ]);
return (
<form onSubmit={handleSubmit}>
<HorizontalFormGroup value={name} onChange={setName}>Name</HorizontalFormGroup>
<HorizontalFormGroup type="url" value={url} onChange={setUrl}>URL</HorizontalFormGroup>
<HorizontalFormGroup value={apiKey} onChange={setApiKey}>API key</HorizontalFormGroup>
<form className="server-form" onSubmit={handleSubmit}>
<SimpleCard className="mb-4" title={title}>
<FormGroupContainer value={name} onChange={setName}>Name</FormGroupContainer>
<FormGroupContainer type="url" value={url} onChange={setUrl}>URL</FormGroupContainer>
<FormGroupContainer value={apiKey} onChange={setApiKey}>API key</FormGroupContainer>
</SimpleCard>
<div className="text-right">{children}</div>
</form>

View File

@@ -2,6 +2,7 @@ import { FC, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
import Message from '../../utils/Message';
import { isNotFoundServer, SelectedServer } from '../data';
import NoMenuLayout from '../../common/NoMenuLayout';
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
selectServer: (serverId: string) => void;
@@ -18,9 +19,9 @@ export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServ
if (!selectedServer) {
return (
<div className="row">
<NoMenuLayout>
<Message loading />
</div>
</NoMenuLayout>
);
}

View File

@@ -4,9 +4,9 @@ import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListPara
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
import { SelectedServer } from '../data';
import { GetState } from '../../container/types';
import { ShlinkHealth } from '../../utils/services/types';
import { ShlinkHealth } from '../../api/types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';

View File

@@ -1,13 +1,19 @@
import { CsvJson } from 'csvjson';
import { ServerData } from '../data';
const CSV_MIME_TYPE = 'text/csv';
interface CsvFile extends File {
type: 'text/csv' | 'text/comma-separated-values' | 'application/csv';
}
const CSV_MIME_TYPES = [ 'text/csv', 'text/comma-separated-values', 'application/csv' ];
const isCsv = (file?: File | null): file is CsvFile => !!file && CSV_MIME_TYPES.includes(file.type);
export default class ServersImporter {
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
if (!file || file.type !== CSV_MIME_TYPE) {
if (!isCsv(file)) {
throw new Error('No file provided or file is not a CSV');
}

View File

@@ -13,6 +13,7 @@ import ForServerVersion from '../helpers/ForServerVersion';
import { ServerError } from '../helpers/ServerError';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { Overview } from '../Overview';
import ServersImporter from './ServersImporter';
import ServersExporter from './ServersExporter';
@@ -43,6 +44,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl', 'ForServerVersion');
bottle.decorator('Overview', connect(
[ 'shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview' ],
[ 'listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview' ],
));
// Services
bottle.constant('csvjson', csvjson);
bottle.constant('fileReaderFactory', () => new FileReader());

View File

@@ -1,6 +1,7 @@
import { Card, CardBody, CardHeader, FormGroup, Input } from 'reactstrap';
import { FormGroup, Input } from 'reactstrap';
import classNames from 'classnames';
import ToggleSwitch from '../utils/ToggleSwitch';
import { SimpleCard } from '../utils/SimpleCard';
import { Settings } from './reducers/settings';
interface RealTimeUpdatesProps {
@@ -14,39 +15,36 @@ const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
const RealTimeUpdates = (
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
) => (
<Card>
<CardHeader>Real-time updates</CardHeader>
<CardBody>
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
Real-time updates frequency (in minutes):
</label>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</small>
)}
</FormGroup>
</CardBody>
</Card>
<SimpleCard title="Real-time updates">
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
Real-time updates frequency (in minutes):
</label>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</small>
)}
</FormGroup>
</SimpleCard>
);
export default RealTimeUpdates;

View File

@@ -0,0 +1,6 @@
@import '../utils/base';
.create-short-url .form-group:last-child,
.create-short-url p:last-child {
margin-bottom: 0;
}

View File

@@ -1,32 +1,36 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe, replace, trim } from 'ramda';
import { FC, useState } from 'react';
import { Collapse, FormGroup, Input } from 'reactstrap';
import { Button, FormGroup, Input } from 'reactstrap';
import { InputType } from 'reactstrap/lib/Input';
import * as m from 'moment';
import DateInput, { DateInputProps } from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
import { versionMatch, Versions } from '../utils/helpers/version';
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import { useToggle } from '../utils/helpers/hooks';
import { isReachableServer, SelectedServer } from '../servers/data';
import { formatIsoDate } from '../utils/helpers/date';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { DomainSelectorProps } from '../domains/DomainSelector';
import { SimpleCard } from '../utils/SimpleCard';
import { ShortUrlData } from './data';
import { ShortUrlCreation } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
import './CreateShortUrl.scss';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
export interface CreateShortUrlProps {
basicMode?: boolean;
}
interface CreateShortUrlProps {
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
shortUrlCreationResult: ShortUrlCreation;
selectedServer: SelectedServer;
createShortUrl: Function;
createShortUrl: (data: ShortUrlData) => Promise<void>;
resetCreateShortUrl: () => void;
}
export const normalizeTag = pipe(trim, replace(/ /g, '-'));
const initialState: ShortUrlData = {
longUrl: '',
tags: [],
@@ -37,6 +41,7 @@ const initialState: ShortUrlData = {
validUntil: undefined,
maxVisits: undefined,
findIfExists: false,
validateUrl: true,
};
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits';
@@ -46,17 +51,23 @@ const CreateShortUrl = (
TagsSelector: FC<TagsSelectorProps>,
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
ForServerVersion: FC<Versions>,
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
DomainSelector: FC<DomainSelectorProps>,
) => ({
createShortUrl,
shortUrlCreationResult,
resetCreateShortUrl,
selectedServer,
basicMode = false,
}: CreateShortUrlConnectProps) => {
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlCreation(initialState);
const save = handleEventPreventingDefault(() => {
const shortUrlData = {
...shortUrlCreation,
validSince: formatIsoDate(shortUrlCreation.validSince),
validUntil: formatIsoDate(shortUrlCreation.validUntil),
validSince: formatIsoDate(shortUrlCreation.validSince) ?? undefined,
validUntil: formatIsoDate(shortUrlCreation.validUntil) ?? undefined,
};
createShortUrl(shortUrlData).then(reset).catch(() => {});
@@ -84,93 +95,119 @@ const CreateShortUrl = (
/>
</div>
);
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
return (
<form onSubmit={save}>
<div className="form-group">
<input
className="form-control form-control-lg"
const basicComponents = (
<>
<FormGroup>
<Input
bsSize="lg"
type="url"
placeholder="Insert the URL to be shortened"
placeholder="URL to be shortened"
required
value={shortUrlCreation.longUrl}
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
/>
</div>
</FormGroup>
<Collapse isOpen={moreOptionsVisible}>
<div className="form-group">
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
</div>
<FormGroup>
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
</FormGroup>
</>
);
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
disabled: hasValue(shortUrlCreation.shortCodeLength),
})}
</div>
<div className="col-sm-4">
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
...disableShortCodeLength && {
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
},
})}
</div>
<div className="col-sm-4">
{renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
</div>
</div>
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
const showDomainSelector = versionMatch(currentServerVersion, { minVersion: '2.4.0' });
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-4">
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
</div>
<div className="col-sm-4">
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
</div>
</div>
return (
<form className="create-short-url" onSubmit={save}>
{basicMode && basicComponents}
{!basicMode && (
<>
<SimpleCard title="Basic options" className="mb-3">
{basicComponents}
</SimpleCard>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForServerVersion>
</Collapse>
<div className="row">
<div className="col-sm-6 mb-3">
<SimpleCard title="Customize the short URL">
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
disabled: hasValue(shortUrlCreation.shortCodeLength),
})}
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
...disableShortCodeLength && {
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
},
})}
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
{showDomainSelector && (
<FormGroup>
<DomainSelector
value={shortUrlCreation.domain}
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
/>
</FormGroup>
)}
</SimpleCard>
</div>
<div>
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary float-right"
<div className="col-sm-6 mb-3">
<SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
</SimpleCard>
</div>
</div>
<SimpleCard title="Extra validations" className="mb-3">
<p>
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
provided data.
</p>
<ForServerVersion minVersion="2.4.0">
<p>
<Checkbox
inline
checked={shortUrlCreation.validateUrl}
onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })}
>
Validate URL
</Checkbox>
</p>
</ForServerVersion>
<p>
<Checkbox
inline
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</p>
</SimpleCard>
</>
)}
<div className="text-center">
<Button
outline
color="primary"
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
className="btn-xs-block"
>
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
</button>
</Button>
</div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
<CreateShortUrlResult
{...shortUrlCreationResult}
resetCreateShortUrl={resetCreateShortUrl}
canBeClosed={basicMode}
/>
</form>
);
};

View File

@@ -1,7 +1,7 @@
.short-urls-paginator {
position: sticky;
bottom: 0;
background-color: rgba(white, .8);
background-color: rgba(255, 255, 255, .5);
padding: .75rem 0;
border-top: 1px solid rgba(black, .125);
}

View File

@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
import { ShlinkPaginator } from '../utils/services/types';
import { ShlinkPaginator } from '../api/types';
import './Paginator.scss';
interface PaginatorProps {

View File

@@ -1,14 +1,13 @@
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
import { isEmpty, pipe } from 'ramda';
import moment from 'moment';
import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import DateRangeRow from '../utils/DateRangeRow';
import { formatDate } from '../utils/helpers/date';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date';
import ColorGenerator from '../utils/services/ColorGenerator';
import { Versions } from '../utils/helpers/version';
import { DateRange } from '../utils/dates/types';
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
import './SearchBar.scss';
@@ -19,13 +18,14 @@ interface SearchBarProps {
const dateOrNull = (date?: string) => date ? moment(date) : null;
const SearchBar = (colorGenerator: ColorGenerator, ForServerVersion: FC<Versions>) => (
{ listShortUrls, shortUrlsListParams }: SearchBarProps,
) => {
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
const selectedTags = shortUrlsListParams.tags ?? [];
const setDate = (dateName: 'startDate' | 'endDate') => pipe(
formatDate(),
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date }),
const setDates = pipe(
({ startDate, endDate }: DateRange) => ({
startDate: formatIsoDate(startDate) ?? undefined,
endDate: formatIsoDate(endDate) ?? undefined,
}),
(dates) => listShortUrls({ ...shortUrlsListParams, ...dates }),
);
return (
@@ -36,20 +36,20 @@ const SearchBar = (colorGenerator: ColorGenerator, ForServerVersion: FC<Versions
}
/>
<ForServerVersion minVersion="1.21.0">
<div className="mt-3">
<div className="row">
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
<DateRangeRow
startDate={dateOrNull(shortUrlsListParams.startDate)}
endDate={dateOrNull(shortUrlsListParams.endDate)}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
</div>
<div className="mt-3">
<div className="row">
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={{
startDate: dateOrNull(shortUrlsListParams.startDate),
endDate: dateOrNull(shortUrlsListParams.endDate),
}}
onDatesChange={setDates}
/>
</div>
</div>
</ForServerVersion>
</div>
{!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-3">

View File

@@ -1,16 +1,9 @@
import { FC, useEffect, useState } from 'react';
import { ShlinkShortUrlsResponse } from '../utils/services/types';
import Paginator from './Paginator';
import { ShortUrlsListProps, WithList } from './ShortUrlsList';
import { ShortUrlsListProps } from './ShortUrlsList';
interface ShortUrlsProps extends ShortUrlsListProps {
shortUrlsList?: ShlinkShortUrlsResponse;
}
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithList>) => (props: ShortUrlsProps) => {
const { match, shortUrlsList } = props;
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps>) => (props: ShortUrlsListProps) => {
const { match } = props;
const { page = '1', serverId = '' } = match?.params ?? {};
const { data = [], pagination } = shortUrlsList ?? {};
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
// Using a key on a component makes react to create a new instance every time the key changes
@@ -22,10 +15,7 @@ const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithLis
return (
<>
<div className="form-group"><SearchBar /></div>
<div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
<ShortUrlsList {...props} key={urlsListKey} />
</>
);
};

View File

@@ -1,19 +1,3 @@
@import '../utils/base';
.short-urls-list__header {
@media (max-width: $smMax) {
display: none;
}
}
.short-urls-list__header--with-action {
cursor: pointer;
}
.short-urls-list__header-icon {
margin-right: 5px;
}
.short-urls-list__header-cell--with-action {
cursor: pointer;
}

View File

@@ -1,17 +1,18 @@
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 { head, keys, values } from 'ramda';
import { FC, useEffect, useState } from 'react';
import qs from 'qs';
import { RouteComponentProps } from 'react-router';
import { Card } from 'reactstrap';
import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir, OrderDir } from '../utils/utils';
import { SelectedServer } from '../servers/data';
import { isReachableServer, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { parseQuery } from '../utils/helpers/query';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
import { ShortUrl } from './data';
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
import { ShortUrlsTableProps } from './ShortUrlsTable';
import Paginator from './Paginator';
import './ShortUrlsList.scss';
interface RouteParams {
@@ -19,33 +20,29 @@ interface RouteParams {
serverId: string;
}
export interface WithList {
shortUrlsList: ShortUrl[];
}
export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps<RouteParams> {
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
selectedServer: SelectedServer;
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void;
shortUrlsListParams: ShortUrlsListParams;
resetShortUrlParams: () => void;
}
const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub(({
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
listShortUrls,
resetShortUrlParams,
shortUrlsListParams,
match,
location,
loading,
error,
shortUrlsList,
selectedServer,
}: ShortUrlsListProps & WithList) => {
}: ShortUrlsListProps) => {
const { orderBy } = shortUrlsListParams;
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
orderDir: orderBy && head(values(orderBy)),
});
const { pagination } = shortUrlsList?.shortUrls ?? {};
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => {
setOrder({ orderField, orderDir });
@@ -69,46 +66,19 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub
/>
);
};
const renderShortUrls = () => {
if (error) {
return (
<tr>
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
</tr>
);
}
if (loading) {
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
}
if (!loading && isEmpty(shortUrlsList)) {
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
}
return shortUrlsList.map((shortUrl) => (
<ShortUrlsRow
key={shortUrl.shortUrl}
shortUrl={shortUrl}
selectedServer={selectedServer}
refreshList={refreshList}
shortUrlsListParams={shortUrlsListParams}
/>
));
};
useEffect(() => {
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
const tags = query.tag ? [ query.tag as string ] : shortUrlsListParams.tags;
const { tag } = parseQuery<{ tag?: string }>(location.search);
const tags = tag ? [ tag ] : shortUrlsListParams.tags;
refreshList({ page: match.params.page, tags });
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
return resetShortUrlParams;
}, []);
return (
<>
<div className="d-block d-md-none mb-3">
<div className="d-block d-lg-none mb-3">
<SortingDropdown
items={SORTABLE_FIELDS}
orderField={order.orderField}
@@ -116,44 +86,16 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub
onChange={handleOrderBy}
/>
</div>
<table className="table table-striped table-hover">
<thead className="short-urls-list__header">
<tr>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={orderByColumn('dateCreated')}
>
{renderOrderIcon('dateCreated')}
Created at
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={orderByColumn('shortCode')}
>
{renderOrderIcon('shortCode')}
Short URL
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={orderByColumn('longUrl')}
>
{renderOrderIcon('longUrl')}
Long URL
</th>
<th className="short-urls-list__header-cell">Tags</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={orderByColumn('visits')}
>
<span className="indivisible">{renderOrderIcon('visits')} Visits</span>
</th>
<th className="short-urls-list__header-cell">&nbsp;</th>
</tr>
</thead>
<tbody>
{renderShortUrls()}
</tbody>
</table>
<Card body className="pb-1">
<ShortUrlsTable
orderByColumn={orderByColumn}
renderOrderIcon={renderOrderIcon}
selectedServer={selectedServer}
shortUrlsList={shortUrlsList}
onTagClick={(tag) => refreshList({ tags: [ ...shortUrlsListParams.tags ?? [], tag ] })}
/>
<Paginator paginator={pagination} serverId={isReachableServer(selectedServer) ? selectedServer.id : ''} />
</Card>
</>
);
}, () => 'https://shlink.io/new-visit');

View File

@@ -0,0 +1,11 @@
@import '../utils/base';
.short-urls-table__header {
@media (max-width: $responsiveTableBreakpoint) {
display: none;
}
}
.short-urls-table__header-cell--with-action {
cursor: pointer;
}

View File

@@ -0,0 +1,88 @@
import { FC, ReactNode } from 'react';
import { isEmpty } from 'ramda';
import classNames from 'classnames';
import { SelectedServer } from '../servers/data';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
import { OrderableFields } from './reducers/shortUrlsListParams';
import './ShortUrlsTable.scss';
export interface ShortUrlsTableProps {
orderByColumn?: (column: OrderableFields) => () => void;
renderOrderIcon?: (column: OrderableFields) => ReactNode;
shortUrlsList: ShortUrlsListState;
selectedServer: SelectedServer;
onTagClick?: (tag: string) => void;
className?: string;
}
export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
orderByColumn,
renderOrderIcon,
shortUrlsList,
onTagClick,
selectedServer,
className,
}: ShortUrlsTableProps) => {
const { error, loading, shortUrls } = shortUrlsList;
const orderableColumnsClasses = classNames('short-urls-table__header-cell', {
'short-urls-table__header-cell--with-action': !!orderByColumn,
});
const tableClasses = classNames('table table-hover', className);
const renderShortUrls = () => {
if (error) {
return (
<tr>
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
</tr>
);
}
if (loading) {
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
}
if (!loading && isEmpty(shortUrls?.data)) {
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
}
return shortUrls?.data.map((shortUrl) => (
<ShortUrlsRow
key={shortUrl.shortUrl}
shortUrl={shortUrl}
selectedServer={selectedServer}
onTagClick={onTagClick}
/>
));
};
return (
<table className={tableClasses}>
<thead className="short-urls-table__header">
<tr>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
{renderOrderIcon?.('dateCreated')}
Created at
</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
{renderOrderIcon?.('shortCode')}
Short URL
</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
{renderOrderIcon?.('longUrl')}
Long URL
</th>
<th className="short-urls-table__header-cell">Tags</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
<span className="indivisible">{renderOrderIcon?.('visits')} Visits</span>
</th>
<th className="short-urls-table__header-cell">&nbsp;</th>
</tr>
</thead>
<tbody>
{renderShortUrls()}
</tbody>
</table>
);
};

View File

@@ -11,6 +11,7 @@ export interface ShortUrlData {
validUntil?: m.Moment | string;
maxVisits?: number;
findIfExists?: boolean;
validateUrl?: boolean;
}
export interface ShortUrl {

View File

@@ -1,19 +1,23 @@
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda';
import { useEffect } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { Card, CardBody, Tooltip } from 'reactstrap';
import { Tooltip } from 'reactstrap';
import { ShortUrlCreation } from '../reducers/shortUrlCreation';
import { StateFlagTimeout } from '../../utils/helpers/hooks';
import { Result } from '../../utils/Result';
import './CreateShortUrlResult.scss';
import { ShlinkApiError } from '../../api/ShlinkApiError';
export interface CreateShortUrlResultProps extends ShortUrlCreation {
resetCreateShortUrl: () => void;
canBeClosed?: boolean;
}
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
{ error, result, resetCreateShortUrl }: CreateShortUrlResultProps,
{ error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
) => {
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
@@ -23,9 +27,10 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
if (error) {
return (
<Card body color="danger" inverse className="bg-danger mt-3">
An error occurred while creating the URL :(
</Card>
<Result type="error" className="mt-3">
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" />
</Result>
);
}
@@ -36,25 +41,24 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
const { shortUrl } = result;
return (
<Card inverse className="bg-main mt-3">
<CardBody>
<b>Great!</b> The short URL is <b>{shortUrl}</b>
<Result type="success" className="mt-3">
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
<b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
<button
className="btn btn-light btn-sm create-short-url-result__copy-btn"
id="copyBtn"
type="button"
>
<FontAwesomeIcon icon={copyIcon} /> Copy
</button>
</CopyToClipboard>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
<button
className="btn btn-light btn-sm create-short-url-result__copy-btn"
id="copyBtn"
type="button"
>
<FontAwesomeIcon icon={copyIcon} /> Copy
</button>
</CopyToClipboard>
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
Copied!
</Tooltip>
</CardBody>
</Card>
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
Copied!
</Tooltip>
</Result>
);
};

View File

@@ -4,8 +4,9 @@ import { identity, pipe } from 'ramda';
import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
import { ShortUrlModalProps } from '../data';
import { handleEventPreventingDefault, OptionalString } from '../../utils/utils';
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
import { Result } from '../../utils/Result';
import { isInvalidDeletionError } from '../../api/utils';
import { ShlinkApiError } from '../../api/ShlinkApiError';
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
shortUrlDeletion: ShortUrlDeletion;
@@ -21,9 +22,6 @@ const DeleteShortUrlModal = (
useEffect(() => resetDeleteShortUrl, []);
const { error, errorData } = shortUrlDeletion;
const errorCode = error && (errorData?.type || errorData?.error);
const hasThresholdError = errorCode === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
const close = pipe(resetDeleteShortUrl, toggle);
const handleDeleteUrl = handleEventPreventingDefault(() => {
const { shortCode, domain } = shortUrl;
@@ -42,25 +40,20 @@ const DeleteShortUrlModal = (
<ModalBody>
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
<p>Write <b>{shortUrl.shortCode}</b> to confirm deletion.</p>
<input
type="text"
className="form-control"
placeholder="Insert the short code of the URL"
placeholder={`Insert the short code (${shortUrl.shortCode})`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
{hasThresholdError && (
<div className="p-2 mt-2 bg-warning text-center">
{errorData?.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
{!errorData?.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
</div>
)}
{hasErrorOtherThanThreshold && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the URL :(
</div>
{error && (
<Result type={isInvalidDeletionError(errorData) ? 'warning' : 'error'} small className="mt-2">
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while deleting the URL :(" />
</Result>
)}
</ModalBody>
<ModalFooter>

View File

@@ -10,6 +10,8 @@ import DateInput from '../../utils/DateInput';
import { formatIsoDate } from '../../utils/helpers/date';
import { ShortUrl, ShortUrlMeta, ShortUrlModalProps } from '../data';
import { handleEventPreventingDefault, Nullable, OptionalString } from '../../utils/utils';
import { Result } from '../../utils/Result';
import { ShlinkApiError } from '../../api/ShlinkApiError';
interface EditMetaModalConnectProps extends ShortUrlModalProps {
shortUrlMeta: ShortUrlMetaEdition;
@@ -26,7 +28,7 @@ const dateOrNull = (shortUrl: ShortUrl | undefined, dateName: 'validSince' | 'va
const EditMetaModal = (
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }: EditMetaModalConnectProps,
) => {
const { saving, error } = shortUrlMeta;
const { saving, error, errorData } = shortUrlMeta;
const url = shortUrl && (shortUrl.shortUrl || '');
const [ validSince, setValidSince ] = useState(dateOrNull(shortUrl, 'validSince'));
const [ validUntil, setValidUntil ] = useState(dateOrNull(shortUrl, 'validUntil'));
@@ -77,10 +79,14 @@ const EditMetaModal = (
onChange={(e: ChangeEvent<HTMLInputElement>) => setMaxVisits(Number(e.target.value))}
/>
</FormGroup>
{error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the metadata :(
</div>
<Result type="error" small className="mt-2">
<ShlinkApiError
errorData={errorData}
fallbackMessage="Something went wrong while saving the metadata :("
/>
</Result>
)}
</ModalBody>
<ModalFooter>

View File

@@ -4,6 +4,8 @@ import { ExternalLink } from 'react-external-link';
import { ShortUrlEdition } from '../reducers/shortUrlEdition';
import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils';
import { ShortUrlModalProps } from '../data';
import { Result } from '../../utils/Result';
import { ShlinkApiError } from '../../api/ShlinkApiError';
interface EditShortUrlModalProps extends ShortUrlModalProps {
shortUrlEdition: ShortUrlEdition;
@@ -11,14 +13,14 @@ interface EditShortUrlModalProps extends ShortUrlModalProps {
}
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => {
const { saving, error } = shortUrlEdition;
const { saving, error, errorData } = shortUrlEdition;
const url = shortUrl?.shortUrl ?? '';
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
<ModalHeader toggle={toggle}>
Edit long URL for <ExternalLink href={url} />
</ModalHeader>
@@ -34,9 +36,12 @@ const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShor
/>
</FormGroup>
{error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the long URL :(
</div>
<Result type="error" small className="mt-2">
<ShlinkApiError
errorData={errorData}
fallbackMessage="Something went wrong while saving the long URL :("
/>
</Result>
)}
</ModalBody>
<ModalFooter>

View File

@@ -5,6 +5,8 @@ import { ShortUrlTags } from '../reducers/shortUrlTags';
import { ShortUrlModalProps } from '../data';
import { OptionalString } from '../../utils/utils';
import { TagsSelectorProps } from '../../tags/helpers/TagsSelector';
import { Result } from '../../utils/Result';
import { ShlinkApiError } from '../../api/ShlinkApiError';
interface EditTagsModalProps extends ShortUrlModalProps {
shortUrlTags: ShortUrlTags;
@@ -19,6 +21,7 @@ const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
useEffect(() => resetShortUrlsTags, []);
const { saving, error, errorData } = shortUrlTags;
const url = shortUrl?.shortUrl ?? '';
const saveTags = async () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
.then(toggle)
@@ -31,16 +34,16 @@ const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
</ModalHeader>
<ModalBody>
<TagsSelector tags={selectedTags} onChange={setSelectedTags} />
{shortUrlTags.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the tags :(
</div>
{error && (
<Result type="error" small className="mt-2">
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while saving the tags :(" />
</Result>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-primary" type="button" disabled={shortUrlTags.saving} onClick={saveTags}>
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
<button className="btn btn-primary" type="button" disabled={saving} onClick={saveTags}>
{saving ? 'Saving tags...' : 'Save tags'}
</button>
</ModalFooter>
</Modal>

View File

@@ -1,13 +0,0 @@
@import '../../utils/mixins/horizontal-align';
.preview-modal__img {
max-width: 100%;
position: relative;
z-index: 2;
}
.preview-modal__loader {
@include horizontal-align();
z-index: 1;
top: 1rem;
}

View File

@@ -1,20 +0,0 @@
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { ShortUrlModalProps } from '../data';
import './PreviewModal.scss';
const PreviewModal = ({ shortUrl: { shortUrl }, toggle, isOpen }: ShortUrlModalProps) => (
<Modal isOpen={isOpen} toggle={toggle} size="lg">
<ModalHeader toggle={toggle}>
Preview for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
</ModalHeader>
<ModalBody>
<div className="text-center">
<p className="preview-modal__loader">Loading...</p>
<img src={`${shortUrl}/preview`} className="preview-modal__img" alt="Preview" />
</div>
</ModalBody>
</Modal>
);
export default PreviewModal;

View File

@@ -2,7 +2,7 @@
@import '../../utils/mixins/vertical-align';
.short-urls-row {
@media (max-width: $smMax) {
@media (max-width: $responsiveTableBreakpoint) {
display: block;
margin-bottom: 10px;
border-bottom: 1px solid $lightGrey;
@@ -13,7 +13,7 @@
.short-urls-row__cell.short-urls-row__cell {
vertical-align: middle !important;
@media (max-width: $smMax) {
@media (max-width: $responsiveTableBreakpoint) {
display: block;
width: 100%;
position: relative;
@@ -22,7 +22,7 @@
&:before {
content: attr(data-th);
font-weight: bold;
font-weight: 700;
}
&:last-child {
@@ -55,9 +55,10 @@
.short-urls-row__copy-hint {
@include vertical-align(translateX(10px));
box-shadow: 0 3px 15px rgba(0, 0, 0, .25);
@media (max-width: $smMax) {
@media (max-width: $responsiveTableBreakpoint) {
@include vertical-align(translateX(calc(-100% - 20px)));
}
}

View File

@@ -5,7 +5,6 @@ import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import CopyToClipboard from 'react-copy-to-clipboard';
import { ShortUrlsListParams } from '../reducers/shortUrlsListParams';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { StateFlagTimeout } from '../../utils/helpers/hooks';
import Tag from '../../tags/helpers/Tag';
@@ -16,8 +15,7 @@ import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
import './ShortUrlsRow.scss';
export interface ShortUrlsRowProps {
refreshList: Function;
shortUrlsListParams: ShortUrlsListParams;
onTagClick?: (tag: string) => void;
selectedServer: SelectedServer;
shortUrl: ShortUrl;
}
@@ -26,7 +24,7 @@ const ShortUrlsRow = (
ShortUrlsRowMenu: FC<ShortUrlsRowMenuProps>,
colorGenerator: ColorGenerator,
useStateFlagTimeout: StateFlagTimeout,
) => ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }: ShortUrlsRowProps) => {
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => {
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout();
const [ active, setActive ] = useStateFlagTimeout(false, 500);
const isFirstRun = useRef(true);
@@ -36,14 +34,12 @@ const ShortUrlsRow = (
return <i className="indivisible"><small>No tags</small></i>;
}
const selectedTags = shortUrlsListParams.tags ?? [];
return tags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
onClick={() => onTagClick?.(tag)}
/>
));
};

View File

@@ -1,4 +1,3 @@
import { faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
import {
faTags as tagsIcon,
faChartPie as pieChartIcon,
@@ -15,7 +14,6 @@ import { useToggle } from '../../utils/helpers/hooks';
import { ShortUrl, ShortUrlModalProps } from '../data';
import { Versions } from '../../utils/helpers/version';
import { SelectedServer } from '../../servers/data';
import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal';
import VisitStatsLink from './VisitStatsLink';
import './ShortUrlsRowMenu.scss';
@@ -35,7 +33,6 @@ const ShortUrlsRowMenu = (
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
const [ isOpen, toggle ] = useToggle();
const [ isQrModalOpen, toggleQrCode ] = useToggle();
const [ isPreviewModalOpen, togglePreview ] = useToggle();
const [ isTagsModalOpen, toggleTags ] = useToggle();
const [ isMetaModalOpen, toggleMeta ] = useToggle();
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
@@ -56,12 +53,10 @@ const ShortUrlsRowMenu = (
</DropdownItem>
<EditTagsModal shortUrl={shortUrl} isOpen={isTagsModalOpen} toggle={toggleTags} />
<ForServerVersion minVersion="1.18.0">
<DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={isMetaModalOpen} toggle={toggleMeta} />
</ForServerVersion>
<DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={isMetaModalOpen} toggle={toggleMeta} />
<ForServerVersion minVersion="2.1.0">
<DropdownItem onClick={toggleEdit}>
@@ -75,13 +70,6 @@ const ShortUrlsRowMenu = (
</DropdownItem>
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
<ForServerVersion maxVersion="1.x">
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
</DropdownItem>
<PreviewModal shortUrl={shortUrl} isOpen={isPreviewModalOpen} toggle={togglePreview} />
</ForServerVersion>
<DropdownItem divider />
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>

View File

@@ -2,7 +2,9 @@ import { Action, Dispatch } from 'redux';
import { GetState } from '../../container/types';
import { ShortUrl, ShortUrlData } from '../data';
import { buildReducer, buildActionCreator } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
@@ -15,21 +17,26 @@ export interface ShortUrlCreation {
result: ShortUrl | null;
saving: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface CreateShortUrlAction extends Action<string> {
result: ShortUrl;
}
export interface CreateShortUrlFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlCreation = {
result: null,
saving: false,
error: false,
};
export default buildReducer<ShortUrlCreation, CreateShortUrlAction>({
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & CreateShortUrlFailedAction>({
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
[CREATE_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
[RESET_CREATE_SHORT_URL]: () => initialState,
}, initialState);
@@ -46,7 +53,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
} catch (e) {
dispatch({ type: CREATE_SHORT_URL_ERROR });
dispatch<CreateShortUrlFailedAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -1,8 +1,9 @@
import { Action, Dispatch } from 'redux';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ProblemDetailsError } from '../../utils/services/types';
import { ProblemDetailsError } from '../../api/types';
import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
@@ -18,13 +19,13 @@ export interface ShortUrlDeletion {
errorData?: ProblemDetailsError;
}
interface DeleteShortUrlAction extends Action<string> {
export interface DeleteShortUrlAction extends Action<string> {
shortCode: string;
domain?: string | null;
}
interface DeleteShortUrlErrorAction extends Action<string> {
errorData: ProblemDetailsError;
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlDeletion = {
@@ -51,7 +52,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
await deleteShortUrl(shortCode, domain);
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
} catch (e) {
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -3,7 +3,9 @@ import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types';
import { OptionalString } from '../../utils/utils';
import { ShortUrlIdentifier } from '../data';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
@@ -16,12 +18,17 @@ export interface ShortUrlEdition {
longUrl: string | null;
saving: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface ShortUrlEditedAction extends Action<string>, ShortUrlIdentifier {
longUrl: string;
}
export interface ShortUrlEditionFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlEdition = {
shortCode: null,
longUrl: null,
@@ -29,9 +36,9 @@ const initialState: ShortUrlEdition = {
error: false,
};
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction>({
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ShortUrlEditionFailedAction>({
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
[SHORT_URL_EDITED]: (_, { shortCode, longUrl }) => ({ shortCode, longUrl, saving: false, error: false }),
}, initialState);
@@ -47,7 +54,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
await updateShortUrlMeta(shortCode, domain, { longUrl });
dispatch<ShortUrlEditedAction>({ shortCode, longUrl, domain, type: SHORT_URL_EDITED });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_ERROR });
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -3,7 +3,9 @@ import { ShortUrlIdentifier, ShortUrlMeta } from '../data';
import { GetState } from '../../container/types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { OptionalString } from '../../utils/utils';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START';
@@ -17,12 +19,17 @@ export interface ShortUrlMetaEdition {
meta: ShortUrlMeta;
saving: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface ShortUrlMetaEditedAction extends Action<string>, ShortUrlIdentifier {
meta: ShortUrlMeta;
}
export interface ShortUrlMetaEditionFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlMetaEdition = {
shortCode: null,
meta: {},
@@ -30,9 +37,9 @@ const initialState: ShortUrlMetaEdition = {
error: false,
};
export default buildReducer<ShortUrlMetaEdition, ShortUrlMetaEditedAction>({
export default buildReducer<ShortUrlMetaEdition, ShortUrlMetaEditedAction & ShortUrlMetaEditionFailedAction>({
[EDIT_SHORT_URL_META_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_META_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[EDIT_SHORT_URL_META_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
[SHORT_URL_META_EDITED]: (_, { shortCode, meta }) => ({ shortCode, meta, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_META]: () => initialState,
}, initialState);
@@ -49,7 +56,7 @@ export const editShortUrlMeta = (buildShlinkApiClient: ShlinkApiClientBuilder) =
await updateShortUrlMeta(shortCode, domain, meta);
dispatch<ShortUrlMetaEditedAction>({ shortCode, meta, domain, type: SHORT_URL_META_EDITED });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_META_ERROR });
dispatch<ShortUrlMetaEditionFailedAction>({ type: EDIT_SHORT_URL_META_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -3,7 +3,9 @@ import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types';
import { OptionalString } from '../../utils/utils';
import { ShortUrlIdentifier } from '../data';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
@@ -17,12 +19,17 @@ export interface ShortUrlTags {
tags: string[];
saving: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface EditShortUrlTagsAction extends Action<string>, ShortUrlIdentifier {
tags: string[];
}
export interface EditShortUrlTagsFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlTags = {
shortCode: null,
tags: [],
@@ -30,9 +37,9 @@ const initialState: ShortUrlTags = {
error: false,
};
export default buildReducer<ShortUrlTags, EditShortUrlTagsAction>({
export default buildReducer<ShortUrlTags, EditShortUrlTagsAction & EditShortUrlTagsFailedAction>({
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[EDIT_SHORT_URL_TAGS_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
[SHORT_URL_TAGS_EDITED]: (_, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
}, initialState);
@@ -50,7 +57,7 @@ export const editShortUrlTags = (buildShlinkApiClient: ShlinkApiClientBuilder) =
dispatch<EditShortUrlTagsAction>({ tags: normalizedTags, shortCode, domain, type: SHORT_URL_TAGS_EDITED });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
dispatch<EditShortUrlTagsFailedAction>({ type: EDIT_SHORT_URL_TAGS_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -1,17 +1,18 @@
import { assoc, assocPath, last, reject } from 'ramda';
import { assoc, assocPath, init, last, pipe, reject } from 'ramda';
import { Action, Dispatch } from 'redux';
import { shortUrlMatches } from '../helpers';
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
import { ShortUrl, ShortUrlIdentifier } from '../data';
import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkShortUrlsResponse } from '../../utils/services/types';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ShlinkShortUrlsResponse } from '../../api/types';
import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, ShortUrlMetaEditedAction } from './shortUrlMeta';
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
import { ShortUrlsListParams } from './shortUrlsListParams';
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
/* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
@@ -31,7 +32,13 @@ export interface ListShortUrlsAction extends Action<string> {
}
export type ListShortUrlsCombinedAction = (
ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitsAction
ListShortUrlsAction
& EditShortUrlTagsAction
& ShortUrlEditedAction
& ShortUrlMetaEditedAction
& CreateVisitsAction
& CreateShortUrlAction
& DeleteShortUrlAction
);
const initialState: ShortUrlsList = {
@@ -55,10 +62,17 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
[SHORT_URL_DELETED]: (state, { shortCode, domain }) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ],
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
state,
[SHORT_URL_DELETED]: pipe(
(state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ],
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
state,
),
(state) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'pagination', 'totalItems' ],
state.shortUrls.pagination.totalItems - 1,
state,
),
),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl<EditShortUrlTagsAction>('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlMetaEditedAction>('meta'),
@@ -77,6 +91,20 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
),
state,
),
[CREATE_SHORT_URL]: pipe(
// The only place where the list and the creation form coexist is the overview page.
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL and remove the last one.
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ],
[ result, ...init(state.shortUrls.data) ],
state,
),
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'pagination', 'totalItems' ],
state.shortUrls.pagination.totalItems + 1,
state,
),
),
}, initialState);
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (

View File

@@ -15,6 +15,7 @@ export type OrderableFields = keyof typeof SORTABLE_FIELDS;
export interface ShortUrlsListParams {
page?: string;
itemsPerPage?: number;
tags?: string[];
searchTerm?: string;
startDate?: string;

View File

@@ -1,5 +1,3 @@
import { connect as reduxConnect } from 'react-redux';
import { assoc } from 'ramda';
import Bottle from 'bottlejs';
import ShortUrls from '../ShortUrls';
import SearchBar from '../SearchBar';
@@ -19,27 +17,22 @@ import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
import { editShortUrl } from '../reducers/shortUrlEdition';
import { ConnectDecorator, ShlinkState } from '../../container/types';
import { ConnectDecorator } from '../../container/types';
import { ShortUrlsTable } from '../ShortUrlsTable';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
bottle.decorator('ShortUrls', reduxConnect(
(state: ShlinkState) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList),
));
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
// Services
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
bottle.decorator('ShortUrlsList', connect(
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
));
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
bottle.serviceFactory(
'ShortUrlsRowMenu',
ShortUrlsRowMenu,
@@ -51,7 +44,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
);
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
bottle.serviceFactory(
'CreateShortUrl',
CreateShortUrl,
'TagsSelector',
'CreateShortUrlResult',
'ForServerVersion',
'DomainSelector',
);
bottle.decorator(
'CreateShortUrl',
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
@@ -69,6 +69,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
// Services
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
// Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);

View File

@@ -2,10 +2,6 @@
margin-bottom: .5rem;
}
.tag-card__header.tag-card__header {
background-color: #eeeeee;
}
.tag-card__header.tag-card__header,
.tag-card__body.tag-card__body {
padding: .75rem;

View File

@@ -4,6 +4,8 @@ import Message from '../utils/Message';
import SearchField from '../utils/SearchField';
import { SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { TagsList as TagsListState } from './reducers/tagsList';
import { TagCardProps } from './TagCard';
@@ -28,14 +30,14 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
const renderContent = () => {
if (tagsList.loading) {
return <Message noMargin loading />;
return <Message loading />;
}
if (tagsList.error) {
return (
<div className="col-12">
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
</div>
<Result type="error">
<ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" />
</Result>
);
}
@@ -48,7 +50,7 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
return (
<>
<div className="row">
{tagsGroups.map((group, index) => (
<div key={index} className="col-md-6 col-xl-3">
{group.map((tag) => (
@@ -63,16 +65,14 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
))}
</div>
))}
</>
</div>
);
};
return (
<>
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
<div className="row">
{renderContent()}
</div>
{renderContent()}
</>
);
}, () => 'https://shlink.io/new-visit');

View File

@@ -1,6 +1,8 @@
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { TagDeletion } from '../reducers/tagDelete';
import { TagModalProps } from '../data';
import { Result } from '../../utils/Result';
import { ShlinkApiError } from '../../api/ShlinkApiError';
interface DeleteTagConfirmModalProps extends TagModalProps {
deleteTag: (tag: string) => Promise<void>;
@@ -11,6 +13,7 @@ interface DeleteTagConfirmModalProps extends TagModalProps {
const DeleteTagConfirmModal = (
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
) => {
const { deleting, error, errorData } = tagDelete;
const doDelete = async () => {
await deleteTag(tag);
tagDeleted(tag);
@@ -24,16 +27,16 @@ const DeleteTagConfirmModal = (
</ModalHeader>
<ModalBody>
Are you sure you want to delete tag <b>{tag}</b>?
{tagDelete.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the tag :(
</div>
{error && (
<Result type="error" small className="mt-2">
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while deleting the tag :(" />
</Result>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-danger" disabled={tagDelete.deleting} onClick={doDelete}>
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
<button className="btn btn-danger" disabled={deleting} onClick={doDelete}>
{deleting ? 'Deleting tag...' : 'Delete tag'}
</button>
</ModalFooter>
</Modal>

View File

@@ -8,6 +8,8 @@ import { handleEventPreventingDefault } from '../../utils/utils';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { TagModalProps } from '../data';
import { TagEdition } from '../reducers/tagEdit';
import { Result } from '../../utils/Result';
import { ShlinkApiError } from '../../api/ShlinkApiError';
import './EditTagModal.scss';
interface EditTagModalProps extends TagModalProps {
@@ -22,6 +24,7 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
const [ newTagName, setNewTagName ] = useState(tag);
const [ color, setColor ] = useState(getColorForKey(tag));
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle();
const { editing, error, errorData } = tagEdit;
const saveTag = handleEventPreventingDefault(async () => editTag(tag, newTagName, color)
.then(() => tagEdited(tag, newTagName, color))
.then(toggle)
@@ -54,17 +57,15 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
/>
</div>
{tagEdit.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while editing the tag :(
</div>
{error && (
<Result type="error" small className="mt-2">
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while editing the tag :(" />
</Result>
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={tagEdit.editing}>
{tagEdit.editing ? 'Saving...' : 'Save'}
</button>
<button type="submit" className="btn btn-primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</button>
</ModalFooter>
</form>
</Modal>

View File

@@ -17,6 +17,8 @@ interface TagsSelectorConnectProps extends TagsSelectorProps {
tagsList: TagsList;
}
const noop = () => {};
const TagsSelector = (colorGenerator: ColorGenerator) => (
{ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
) => {
@@ -55,8 +57,8 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
{suggestion}
</>
)}
onSuggestionsFetchRequested={() => {}}
onSuggestionsClearRequested={() => {}}
onSuggestionsFetchRequested={noop}
onSuggestionsClearRequested={noop}
onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
addTag(suggestion);
}}

View File

@@ -1,7 +1,9 @@
import { Action, Dispatch } from 'redux';
import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
@@ -13,20 +15,25 @@ export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
export interface TagDeletion {
deleting: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface DeleteTagAction extends Action<string> {
tag: string;
}
export interface DeleteTagFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: TagDeletion = {
deleting: false,
error: false,
};
export default buildReducer({
export default buildReducer<TagDeletion, DeleteTagFailedAction>({
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
[DELETE_TAG_ERROR]: () => ({ deleting: false, error: true }),
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
[DELETE_TAG]: () => ({ deleting: false, error: false }),
}, initialState);
@@ -41,7 +48,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
await deleteTags([ tag ]);
dispatch({ type: DELETE_TAG });
} catch (e) {
dispatch({ type: DELETE_TAG_ERROR });
dispatch<DeleteTagFailedAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
throw e;
}

View File

@@ -3,7 +3,9 @@ import { Action, Dispatch } from 'redux';
import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
@@ -18,6 +20,7 @@ export interface TagEdition {
newName: string;
editing: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface EditTagAction extends Action<string> {
@@ -26,6 +29,10 @@ export interface EditTagAction extends Action<string> {
color: string;
}
export interface EditTagFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: TagEdition = {
oldName: '',
newName: '',
@@ -33,9 +40,9 @@ const initialState: TagEdition = {
error: false,
};
export default buildReducer<TagEdition, EditTagAction>({
export default buildReducer<TagEdition, EditTagAction & EditTagFailedAction>({
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
[EDIT_TAG_ERROR]: (state) => ({ ...state, editing: false, error: true }),
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
[EDIT_TAG]: (_, action) => ({
...pick([ 'oldName', 'newName' ], action),
editing: false,
@@ -56,7 +63,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
colorGenerator.setColorForKey(newName, color);
dispatch({ type: EDIT_TAG, oldName, newName });
} catch (e) {
dispatch({ type: EDIT_TAG_ERROR });
dispatch<EditTagFailedAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
throw e;
}

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