mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 01:20:24 +03:00
commit
5aa113ec16
41 changed files with 3208 additions and 4109 deletions
10
.github/workflows/ci-docker-image-build.yml
vendored
Normal file
10
.github/workflows/ci-docker-image-build.yml
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
name: Test docker image build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
|
||||
jobs:
|
||||
build-docker-image:
|
||||
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -11,5 +11,5 @@ jobs:
|
|||
ci:
|
||||
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||
with:
|
||||
node-version: 20.7
|
||||
node-version: 22.x
|
||||
publish-coverage: true
|
||||
|
|
8
.github/workflows/deploy-preview.yml
vendored
8
.github/workflows/deploy-preview.yml
vendored
|
@ -9,19 +9,19 @@ jobs:
|
|||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.7
|
||||
node-version: 22.x
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci && \
|
||||
node ./scripts/set-homepage.cjs /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||
npm run build
|
||||
node --run build
|
||||
- name: Deploy preview
|
||||
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||
with:
|
||||
|
|
6
.github/workflows/publish-release.yml
vendored
6
.github/workflows/publish-release.yml
vendored
|
@ -10,11 +10,11 @@ jobs:
|
|||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.7
|
||||
node-version: 22.x
|
||||
- name: Generate release assets
|
||||
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||
- name: Publish release with assets
|
||||
|
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -7,9 +7,7 @@
|
|||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
npm-debug.log*
|
||||
|
||||
docker-compose.override.yml
|
||||
home
|
||||
public/servers.json*
|
||||
|
|
28
CHANGELOG.md
28
CHANGELOG.md
|
@ -4,6 +4,34 @@ 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).
|
||||
|
||||
## [4.3.0] - 2024-11-30
|
||||
### Added
|
||||
* [#1360](https://github.com/shlinkio/shlink-web-client/issues/1360) Added ability for server IDs to be generated based on the server name and URL, instead of generating a random UUID.
|
||||
|
||||
This can improve sharing a predefined set of servers cia servers.json, env vars, or simply export and import your servers in some other device, and then be able to share server URLs which continue working.
|
||||
|
||||
All existing servers will keep their generated IDs in existing devices for backwards compatibility, but newly created servers will use the new approach.
|
||||
|
||||
* [shlink-web-component#491](https://github.com/shlinkio/shlink-web-component/issues/491) Add support for colors in QR code configurator.
|
||||
* [shlink-web-component#515](https://github.com/shlinkio/shlink-web-component/issues/515) Add support for geolocation redirect conditions, when using Shlink 4.3 or newer.
|
||||
* [shlink-web-component#514](https://github.com/shlinkio/shlink-web-component/issues/514) Allow filtering short URLs list by domain, when using Shlink 4.3 or newer.
|
||||
* [shlink-web-component#520](https://github.com/shlinkio/shlink-web-component/issues/520) Allow navigating from domains list to short URLs list filtered by one domain, when using Shlink 4.3 or newer.
|
||||
* [shlink-web-component#517](https://github.com/shlinkio/shlink-web-component/issues/517) Update list of known domains when a short URL is created with a new domain.
|
||||
* [shlink-web-component#292](https://github.com/shlinkio/shlink-web-component/issues/292) Add icon in short URLs list indicating if a short URL has redirect rules.
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [shlink-web-component#504](https://github.com/shlinkio/shlink-web-component/issues/504) Fix fallback interval not causing new visits to be loaded.
|
||||
|
||||
|
||||
## [4.2.2] - 2024-10-19
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
|
|
@ -14,16 +14,13 @@ Because of this, the only actual dependencies are [docker](https://docs.docker.c
|
|||
|
||||
The first thing you need to do is fork the repository, and clone it in your local machine.
|
||||
|
||||
Then you will have to follow these steps:
|
||||
Then simply run `docker compose up` and you will have the project exposed in port `3000` (http://localhost:3000).
|
||||
|
||||
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
||||
* Start-up the project by running `docker compose up`.
|
||||
|
||||
Once this is finished, you will have the project exposed in port `3000` (http://localhost:3000).
|
||||
> The first time the container is created, the project dependencies will be installed and the container may take a bit longer to start.
|
||||
|
||||
## Project structure
|
||||
|
||||
This project is a [react](https://reactjs.org/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
|
||||
This project is a [react](https://react.dev/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
|
||||
|
||||
This is the basic project structure:
|
||||
|
||||
|
@ -39,7 +36,7 @@ shlink-web-client
|
|||
```
|
||||
|
||||
* `config`: It contains some configuration scripts, used during testing, linting and building of the project.
|
||||
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc).
|
||||
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc.).
|
||||
* `scripts`: It has some of the CLI scripts used to run tests or building.
|
||||
* `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic.
|
||||
* `test`: Contains the project tests.
|
||||
|
@ -48,20 +45,19 @@ shlink-web-client
|
|||
|
||||
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
|
||||
|
||||
* `./indocker npm run lint`: Checks coding styles are fulfilled, both in JS/TS files as well as in stylesheets.
|
||||
* `./indocker npm run lint:js`: Checks coding styles are fulfilled in JS/TS files.
|
||||
* `./indocker npm run lint:css`: Checks coding styles are fulfilled in stylesheets.
|
||||
* `./indocker npm run lint:js:fix`: Fixes coding styles in JS/TS files.
|
||||
* `./indocker npm run lint:css:fix`: Fixes coding styles in stylesheets.
|
||||
* `./indocker npm run test`: Runs unit tests with Jest.
|
||||
* `./indocker npm run mutate`: Runs mutation tests with StrykerJS (this command can be very slow).
|
||||
* `./indocker node --run lint`: Checks coding styles are fulfilled, both in JS/TS files and in stylesheets.
|
||||
* `./indocker node --run lint:js`: Checks coding styles are fulfilled in JS/TS files.
|
||||
* `./indocker node --run lint:css`: Checks coding styles are fulfilled in stylesheets.
|
||||
* `./indocker node --run lint:js:fix`: Fixes coding styles in JS/TS files.
|
||||
* `./indocker node --run lint:css:fix`: Fixes coding styles in stylesheets.
|
||||
* `./indocker node --run test`: Runs unit tests with Jest.
|
||||
|
||||
## Building the project
|
||||
|
||||
The source code in this project cannot be run directly in a web browser, you need to build it first.
|
||||
|
||||
* `./indocker npm run build`: Builds the project using a combination of `webpack`, `babel` and `tsc`, generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
|
||||
* `./indocker npm run serve:build`: Serves the static files inside the `build` folder in port 5000 (http://localhost:5000). Useful to test the content built with previous command.
|
||||
* `./indocker node --run run build`: Builds the project for production using [vite](https://vite.dev/), generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
|
||||
* `./indocker node --run run preview`: Serves the static files inside the `build` folder in a random port. Useful to test the content built with previous command.
|
||||
|
||||
## Pull request process
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
FROM node:23.0-alpine as node
|
||||
FROM node:23.3-alpine AS node
|
||||
COPY . /shlink-web-client
|
||||
ARG VERSION="latest"
|
||||
ENV VERSION ${VERSION}
|
||||
RUN cd /shlink-web-client && npm ci && npm run build
|
||||
ENV VERSION=${VERSION}
|
||||
RUN cd /shlink-web-client && npm ci && node --run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||
ARG UID=101
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
[![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)
|
||||
|
||||
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
|
||||
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social)
|
||||
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlink.io)
|
||||
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
|
2
dist/.gitignore
vendored
2
dist/.gitignore
vendored
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -1,7 +0,0 @@
|
|||
services:
|
||||
shlink_web_client_node:
|
||||
user: 1000:1000
|
||||
volumes:
|
||||
- /etc/passwd:/etc/passwd:ro
|
||||
- /etc/group:/etc/group:ro
|
||||
- ./home:/home/alejandro
|
|
@ -1,7 +1,8 @@
|
|||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:22.3-alpine
|
||||
user: 1000:1000 # With this, files created via `indocker` script will belong to the host user
|
||||
image: node:22.10-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
|
|
6614
package-lock.json
generated
6614
package-lock.json
generated
File diff suppressed because it is too large
Load diff
75
package.json
75
package.json
|
@ -7,35 +7,35 @@
|
|||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint": "node --run lint:css && node --run lint:js",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"lint:js": "eslint src test config/test",
|
||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||
"lint:css:fix": "npm run lint:css -- --fix",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"lint:fix": "node --run lint:css:fix && node --run lint:js:fix",
|
||||
"lint:css:fix": "node --run lint:css -- --fix",
|
||||
"lint:js:fix": "node --run lint:js -- --fix",
|
||||
"types": "tsc",
|
||||
"start": "vite serve --host=0.0.0.0",
|
||||
"preview": "vite preview --host=0.0.0.0",
|
||||
"build": "npm run types && vite build && node scripts/replace-version.mjs",
|
||||
"build:dist": "npm run build && node scripts/create-dist-file.mjs",
|
||||
"build": "node --run types && vite build && node scripts/replace-version.mjs",
|
||||
"build:dist": "node --run build && node scripts/create-dist-file.mjs",
|
||||
"test": "vitest run --run",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:ci": "npm run test -- --coverage",
|
||||
"test:verbose": "npm run test -- --verbose"
|
||||
"test:ci": "node --run test -- --coverage",
|
||||
"test:verbose": "node --run test -- --verbose"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/fontawesome-free": "^6.7.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.1",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@json2csv/plainjs": "^7.0.6",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@reduxjs/toolkit": "^2.4.0",
|
||||
"@shlinkio/data-manipulation": "^1.0.3",
|
||||
"@shlinkio/shlink-frontend-kit": "^0.6.0",
|
||||
"@shlinkio/shlink-js-sdk": "^1.2.0",
|
||||
"@shlinkio/shlink-web-component": "^0.10.1",
|
||||
"@shlinkio/shlink-js-sdk": "^1.3.0",
|
||||
"@shlinkio/shlink-web-component": "^0.11.0",
|
||||
"bootstrap": "5.2.3",
|
||||
"bottlejs": "^2.0.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
@ -46,45 +46,44 @@
|
|||
"react-dom": "^18.3.1",
|
||||
"react-external-link": "^2.3.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"reactstrap": "^9.2.3",
|
||||
"redux-localstorage-simple": "^2.5.1",
|
||||
"uuid": "^10.0.0",
|
||||
"workbox-core": "^7.1.0",
|
||||
"workbox-expiration": "^7.1.0",
|
||||
"workbox-precaching": "^7.1.0",
|
||||
"workbox-routing": "^7.1.0",
|
||||
"workbox-strategies": "^7.1.0"
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-expiration": "^7.3.0",
|
||||
"workbox-precaching": "^7.3.0",
|
||||
"workbox-routing": "^7.3.0",
|
||||
"workbox-strategies": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~3.2.0",
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~3.2.1",
|
||||
"@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
|
||||
"@stylistic/eslint-plugin": "^2.9.0",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@stylistic/eslint-plugin": "^2.11.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@total-typescript/shoehorn": "^0.1.2",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitest/coverage-v8": "^2.1.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^2.1.6",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axe-core": "^4.10.1",
|
||||
"axe-core": "^4.10.2",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"history": "^5.3.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"sass": "^1.80.3",
|
||||
"sass": "^1.81.0",
|
||||
"stylelint": "^15.11.0",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.10.0",
|
||||
"vite": "^5.4.9",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.16.0",
|
||||
"vite": "^6.0.1",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^2.0.2"
|
||||
},
|
||||
"browserslist": [
|
||||
|
|
|
@ -4,6 +4,8 @@ set -e
|
|||
|
||||
ME=$(basename $0)
|
||||
|
||||
# In order to allow people to pre-configure a server in their shlink-web-client instance via env vars, this function
|
||||
# dumps a servers.json file based on the values provided via env vars
|
||||
setup_single_shlink_server() {
|
||||
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
||||
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.app-container {
|
||||
height: 100%;
|
||||
|
|
|
@ -50,8 +50,8 @@ const App: FCWithDeps<AppProps, AppDeps> = (
|
|||
const isHome = location.pathname === '/';
|
||||
|
||||
useEffect(() => {
|
||||
// Try to fetch the remote servers if the list is empty at first
|
||||
// We use a ref because we don't care if the servers list becomes empty later
|
||||
// Try to fetch the remote servers if the list is empty during first render.
|
||||
// We use a ref because we don't care if the servers list becomes empty later.
|
||||
if (Object.keys(initialServers.current).length === 0) {
|
||||
fetchServers();
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ const App: FCWithDeps<AppProps, AppDeps> = (
|
|||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<div className={clsx('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||
<div className={clsx('shlink-wrapper', { 'd-flex align-items-center pt-3': isHome })}>
|
||||
<Routes>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../utils/mixins/horizontal-align';
|
||||
|
||||
.app-update-banner.app-update-banner {
|
||||
|
|
|
@ -1,50 +1,7 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
$mainCardWidth: 720px;
|
||||
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
|
||||
|
||||
.home {
|
||||
position: relative;
|
||||
padding-top: 15px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding-top: 0;
|
||||
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
|
||||
}
|
||||
}
|
||||
|
||||
.home__logo-wrapper {
|
||||
padding: 1.5rem !important;
|
||||
height: 100% !important;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.home__logo {
|
||||
@include vertical-align();
|
||||
|
||||
width: calc(#{$mainCardWidth * $fiveColumnsSize} - 3rem);
|
||||
}
|
||||
|
||||
.home__main-card {
|
||||
margin: 0 auto;
|
||||
max-width: $mainCardWidth;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
@include vertical-align();
|
||||
}
|
||||
}
|
||||
|
||||
.home__title-wrapper {
|
||||
padding: 1.5rem !important;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.home__title {
|
||||
text-align: center;
|
||||
font-size: 1.75rem;
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
font-size: 2.2rem;
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { clsx } from 'clsx';
|
||||
import { useEffect } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Card, Row } from 'reactstrap';
|
||||
import { Card } from 'reactstrap';
|
||||
import type { ServersMap } from '../servers/data';
|
||||
import { ServersListGroup } from '../servers/ServersListGroup';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
|
@ -27,33 +28,36 @@ export const Home = ({ servers }: HomeProps) => {
|
|||
}, [serversList, navigate]);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<Card className="home__main-card">
|
||||
<Row className="g-0">
|
||||
<div className="col-md-5 d-none d-md-block">
|
||||
<div className="home__logo-wrapper">
|
||||
<div className="home__logo">
|
||||
<div className="w-100">
|
||||
<Card className="mx-auto" style={{ maxWidth: '720px' }}>
|
||||
<div className="d-flex flex-column flex-md-row">
|
||||
<div className="p-4 d-none d-md-flex align-items-center" style={{ width: '40%' }}>
|
||||
<div className="w-100">
|
||||
<ShlinkLogo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-7 home__servers-container">
|
||||
<div className="home__title-wrapper">
|
||||
<h1 className="home__title">Welcome!</h1>
|
||||
</div>
|
||||
|
||||
<div className="home__servers-container flex-grow-1">
|
||||
<h1
|
||||
className={clsx('home__title p-4 text-center m-0', { 'border-bottom': !hasServers })}
|
||||
style={{ borderColor: 'var(--border-color) !important' }}
|
||||
>
|
||||
Welcome!
|
||||
</h1>
|
||||
<ServersListGroup embedded servers={serversList}>
|
||||
{!hasServers && (
|
||||
<div className="p-4 text-center">
|
||||
<p className="mb-5">This application will help you manage your Shlink servers.</p>
|
||||
<p>
|
||||
<div className="p-4 text-center d-flex flex-column gap-5">
|
||||
<p className="mb-0">This application will help you manage your Shlink servers.</p>
|
||||
<p className="mb-0">
|
||||
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
|
||||
<FontAwesomeIcon icon={faPlus}/> <span className="ms-1">Add a server</span>
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-0 mt-5">
|
||||
<p className="mb-0">
|
||||
<ExternalLink href="https://shlink.io/documentation">
|
||||
<small>
|
||||
<span className="me-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span className="me-2">Learn more about Shlink</span>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt}/>
|
||||
</small>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
|
@ -61,7 +65,7 @@ export const Home = ({ servers }: HomeProps) => {
|
|||
)}
|
||||
</ServersListGroup>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.main-header.main-header {
|
||||
color: white;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.no-menu-wrapper {
|
||||
padding: 15px 0 0;
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import { clsx } from 'clsx';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import './NoMenuLayout.scss';
|
||||
|
||||
export const NoMenuLayout: FC<PropsWithChildren> = ({ children }) => (
|
||||
<div className="no-menu-wrapper container-xl">{children}</div>
|
||||
export type NoMenuLayoutProps = PropsWithChildren & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
|
||||
<div className={clsx('no-menu-wrapper container-xl', className)}>{children}</div>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.shlink-versions-container--with-sidebar {
|
||||
margin-left: 0;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet. Includes SASS var overrides
|
||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites
|
||||
@import 'node_modules/@shlinkio/shlink-web-component/dist/index';
|
||||
@import '../node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet. Includes SASS var overrides
|
||||
@import '../node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@import '../node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites
|
||||
@import '../node_modules/@shlinkio/shlink-web-component/dist/index';
|
||||
|
|
|
@ -8,8 +8,8 @@ import { NoMenuLayout } from '../common/NoMenuLayout';
|
|||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import { useGoBack } from '../utils/helpers/hooks';
|
||||
import { randomUUID } from '../utils/utils';
|
||||
import type { ServerData, ServersMap, ServerWithId } from './data';
|
||||
import { ensureUniqueIds } from './helpers';
|
||||
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
|
@ -44,12 +44,12 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
|
|||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||
const [serverData, setServerData] = useState<ServerData>();
|
||||
const saveNewServer = useCallback((theServerData: ServerData) => {
|
||||
const id = randomUUID();
|
||||
const saveNewServer = useCallback((newServerData: ServerData) => {
|
||||
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
|
||||
|
||||
createServers([{ ...theServerData, id }]);
|
||||
navigate(`/server/${id}`);
|
||||
}, [createServers, navigate]);
|
||||
createServers([newServerWithUniqueId]);
|
||||
navigate(`/server/${newServerWithUniqueId.id}`);
|
||||
}, [createServers, navigate, servers]);
|
||||
const onSubmit = useCallback((newServerData: ServerData) => {
|
||||
setServerData(newServerData);
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
|||
import type { FC } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Row } from 'reactstrap';
|
||||
import { Button } from 'reactstrap';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
|
@ -44,24 +44,22 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
|
|||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<SearchField className="mb-3" onChange={setSearchTerm} />
|
||||
<NoMenuLayout className="d-flex flex-column gap-3">
|
||||
<SearchField onChange={setSearchTerm} />
|
||||
|
||||
<Row className="mb-3">
|
||||
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
||||
<div className="d-flex flex-column flex-md-row gap-2">
|
||||
<div className="d-flex gap-2">
|
||||
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||
{filteredServers.length > 0 && (
|
||||
<Button outline className="ms-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||
<Button outline className="flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6 text-md-end d-flex d-md-block">
|
||||
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
|
||||
<Button outline color="primary" className="ms-md-auto" tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<SimpleCard>
|
||||
<table className="table table-hover responsive-table mb-0">
|
||||
|
@ -83,7 +81,7 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
|
|||
</SimpleCard>
|
||||
|
||||
{errorImporting && (
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
@import '../utils/mixins/thin-scroll';
|
||||
|
||||
|
|
|
@ -6,9 +6,10 @@ import { useCallback, useRef, useState } from 'react';
|
|||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import type { FCWithDeps } from '../../container/utils';
|
||||
import { componentFactory, useDependencies } from '../../container/utils';
|
||||
import type { ServerData, ServersMap } from '../data';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
import type { ServersImporter } from '../services/ServersImporter';
|
||||
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||
import { dedupServers, ensureUniqueIds } from './index';
|
||||
|
||||
export type ImportServersBtnProps = PropsWithChildren<{
|
||||
onImport?: () => void;
|
||||
|
@ -18,7 +19,7 @@ export type ImportServersBtnProps = PropsWithChildren<{
|
|||
}>;
|
||||
|
||||
type ImportServersBtnConnectProps = ImportServersBtnProps & {
|
||||
createServers: (servers: ServerData[]) => void;
|
||||
createServers: (servers: ServerWithId[]) => void;
|
||||
servers: ServersMap;
|
||||
};
|
||||
|
||||
|
@ -26,9 +27,6 @@ type ImportServersBtnDeps = {
|
|||
ServersImporter: ServersImporter
|
||||
};
|
||||
|
||||
const serversInclude = (servers: ServerData[], { url, apiKey }: ServerData) =>
|
||||
servers.some((server) => server.url === url && server.apiKey === apiKey);
|
||||
|
||||
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
|
||||
createServers,
|
||||
servers,
|
||||
|
@ -43,30 +41,31 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||
|
||||
const serversToCreate = useRef<ServerData[]>([]);
|
||||
const create = useCallback((serversData: ServerData[]) => {
|
||||
const importedServersRef = useRef<ServerWithId[]>([]);
|
||||
const newServersRef = useRef<ServerWithId[]>([]);
|
||||
|
||||
const create = useCallback((serversData: ServerWithId[]) => {
|
||||
createServers(serversData);
|
||||
onImport();
|
||||
}, [createServers, onImport]);
|
||||
const onFile = useCallback(
|
||||
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
serversImporter.importServersFromFile(target.files?.[0])
|
||||
.then((newServers) => {
|
||||
serversToCreate.current = newServers;
|
||||
.then((importedServers) => {
|
||||
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
||||
|
||||
const existingServers = Object.values(servers);
|
||||
const dupServers = newServers.filter((server) => serversInclude(existingServers, server));
|
||||
const hasDuplicatedServers = !!dupServers.length;
|
||||
importedServersRef.current = ensureUniqueIds(servers, importedServers);
|
||||
newServersRef.current = ensureUniqueIds(servers, newServers);
|
||||
|
||||
if (!hasDuplicatedServers) {
|
||||
create(newServers);
|
||||
if (duplicatedServers.length === 0) {
|
||||
create(importedServersRef.current);
|
||||
} else {
|
||||
setDuplicatedServers(dupServers);
|
||||
setDuplicatedServers(duplicatedServers);
|
||||
showModal();
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
// Reset file input after processing file
|
||||
(target as { value: string | null }).value = null;
|
||||
})
|
||||
.catch(onImportError),
|
||||
|
@ -74,13 +73,13 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||
);
|
||||
|
||||
const createAllServers = useCallback(() => {
|
||||
create(serversToCreate.current);
|
||||
create(importedServersRef.current);
|
||||
hideModal();
|
||||
}, [create, hideModal, serversToCreate]);
|
||||
}, [create, hideModal]);
|
||||
const createNonDuplicatedServers = useCallback(() => {
|
||||
create(serversToCreate.current.filter((server) => !serversInclude(duplicatedServers, server)));
|
||||
create(newServersRef.current);
|
||||
hideModal();
|
||||
}, [create, duplicatedServers, hideModal]);
|
||||
}, [create, hideModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -91,7 +90,15 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||
You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
|
||||
</UncontrolledTooltip>
|
||||
|
||||
<input type="file" accept=".csv" className="d-none" ref={ref} onChange={onFile} aria-hidden />
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="d-none"
|
||||
aria-hidden
|
||||
ref={ref}
|
||||
onChange={onFile}
|
||||
data-testid="csv-file-input"
|
||||
/>
|
||||
|
||||
<DuplicatedServersModal
|
||||
isOpen={isModalOpen}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.server-error__container {
|
||||
text-align: center;
|
||||
|
|
85
src/servers/helpers/index.ts
Normal file
85
src/servers/helpers/index.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { groupBy } from '@shlinkio/data-manipulation';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
|
||||
/**
|
||||
* Builds a potentially unique ID for a server, based on concatenating their name and the hostname of their domain, all
|
||||
* in lowercase and replacing invalid URL characters with hyphens.
|
||||
*/
|
||||
function idForServer(server: ServerData): string {
|
||||
let urlSegment = server.url;
|
||||
try {
|
||||
const { host, pathname } = new URL(urlSegment);
|
||||
urlSegment = host;
|
||||
|
||||
// Remove leading slash from pathname
|
||||
const normalizedPathname = pathname.substring(1);
|
||||
|
||||
// Include pathname in the ID, if not empty
|
||||
if (normalizedPathname.length > 0) {
|
||||
urlSegment = `${urlSegment} ${normalizedPathname}`;
|
||||
}
|
||||
} catch {
|
||||
// If the server URL is not valid, use the value as is
|
||||
}
|
||||
|
||||
return `${server.name} ${urlSegment}`.toLowerCase().replace(/[^a-zA-Z0-9-_.~]/g, '-');
|
||||
}
|
||||
|
||||
export function serversListToMap(servers: ServerWithId[]): ServersMap {
|
||||
const serversMap: ServersMap = {};
|
||||
servers.forEach((server) => {
|
||||
serversMap[server.id] = server;
|
||||
});
|
||||
|
||||
return serversMap;
|
||||
}
|
||||
|
||||
const serversInclude = (serversList: ServerData[], { url, apiKey }: ServerData) =>
|
||||
serversList.some((server) => server.url === url && server.apiKey === apiKey);
|
||||
|
||||
export type DedupServersResult = {
|
||||
/** Servers which already exist in the reference list */
|
||||
duplicatedServers: ServerData[];
|
||||
/** Servers which are new based on a reference list */
|
||||
newServers: ServerData[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a list of new servers, checks which of them already exist in a servers map, and which don't
|
||||
*/
|
||||
export function dedupServers(servers: ServersMap, serversToAdd: ServerData[]): DedupServersResult {
|
||||
const serversList = Object.values(servers);
|
||||
const { duplicatedServers = [], newServers = [] } = groupBy(
|
||||
serversToAdd,
|
||||
(server) => serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers',
|
||||
);
|
||||
|
||||
return { duplicatedServers, newServers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a servers map and a list of servers, return the same list of servers but all with an ID, ensuring the ID is
|
||||
* unique both among all those servers and existing ones
|
||||
*/
|
||||
export function ensureUniqueIds(existingServers: ServersMap, serversList: ServerData[]): ServerWithId[] {
|
||||
const existingIds = new Set(Object.keys(existingServers));
|
||||
const serversWithId: ServerWithId[] = [];
|
||||
|
||||
serversList.forEach((server) => {
|
||||
const baseId = idForServer(server);
|
||||
|
||||
let id = baseId;
|
||||
let iterations = 1;
|
||||
while (existingIds.has(id)) {
|
||||
id = `${baseId}-${iterations}`;
|
||||
iterations++;
|
||||
}
|
||||
|
||||
serversWithId.push({ ...server, id });
|
||||
|
||||
// Add this server's ID to the list, so that it is taken into consideration for the next ones
|
||||
existingIds.add(id);
|
||||
});
|
||||
|
||||
return serversWithId;
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
||||
import pack from '../../../package.json';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import type { ServerData } from '../data';
|
||||
import { hasServerData } from '../data';
|
||||
import { ensureUniqueIds } from '../helpers';
|
||||
import { createServers } from './servers';
|
||||
|
||||
const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []);
|
||||
const responseToServersList = (data: any) => ensureUniqueIds(
|
||||
{},
|
||||
(Array.isArray(data) ? data.filter(hasServerData) : []),
|
||||
);
|
||||
|
||||
export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
|
||||
'shlink/remoteServers/fetchServers',
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { randomUUID } from '../../utils/utils';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
import { serversListToMap } from '../helpers';
|
||||
|
||||
interface EditServer {
|
||||
serverId: string;
|
||||
|
@ -15,19 +15,6 @@ interface SetAutoConnect {
|
|||
|
||||
const initialState: ServersMap = {};
|
||||
|
||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||
if ('id' in server) {
|
||||
return server;
|
||||
}
|
||||
|
||||
return { ...server, id: randomUUID() };
|
||||
};
|
||||
|
||||
const serversListToMap = (servers: ServerWithId[]): ServersMap => servers.reduce<ServersMap>(
|
||||
(acc, server) => ({ ...acc, [server.id]: server }),
|
||||
{},
|
||||
);
|
||||
|
||||
export const { actions, reducer } = createSlice({
|
||||
name: 'shlink/servers',
|
||||
initialState,
|
||||
|
@ -70,10 +57,7 @@ export const { actions, reducer } = createSlice({
|
|||
},
|
||||
},
|
||||
createServers: {
|
||||
prepare: (servers: ServerData[]) => {
|
||||
const payload = serversListToMap(servers.map(serverWithId));
|
||||
return { payload };
|
||||
},
|
||||
prepare: (servers: ServerWithId[]) => ({ payload: serversListToMap(servers) }),
|
||||
reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }),
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import type { FC, PropsWithChildren } from 'react';
|
||||
|
||||
export const FormText: FC<PropsWithChildren<unknown>> = ({ children }) => (
|
||||
<small className="form-text text-muted d-block">{children}</small>
|
||||
);
|
|
@ -1,9 +1,6 @@
|
|||
import type { SyntheticEvent } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
};
|
||||
|
||||
export const randomUUID = () => v4();
|
||||
|
|
|
@ -56,7 +56,7 @@ describe('<App />', () => {
|
|||
it.each([
|
||||
['/foo', 'shlink-wrapper'],
|
||||
['/bar', 'shlink-wrapper'],
|
||||
['/', 'shlink-wrapper d-flex d-md-block align-items-center'],
|
||||
['/', 'shlink-wrapper d-flex align-items-center pt-3'],
|
||||
])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, expectedClasses) => {
|
||||
const { container } = await setUp(pathname);
|
||||
const shlinkWrapper = container.querySelector('.shlink-wrapper');
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import type { ServersMap, ServerWithId } from '../../../src/servers/data';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../../../src/servers/data';
|
||||
import type {
|
||||
ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn';
|
||||
import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn';
|
||||
|
@ -9,6 +9,7 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
|
|||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||
|
||||
describe('<ImportServersBtn />', () => {
|
||||
const csvFile = new File([''], 'servers.csv', { type: 'text/csv' });
|
||||
const onImportMock = vi.fn();
|
||||
const createServersMock = vi.fn();
|
||||
const importServersFromFile = vi.fn().mockResolvedValue([]);
|
||||
|
@ -54,34 +55,43 @@ describe('<ImportServersBtn />', () => {
|
|||
});
|
||||
|
||||
it('imports servers when file input changes', async () => {
|
||||
const { container } = setUp();
|
||||
const input = container.querySelector('[type=file]');
|
||||
const { user } = setUp();
|
||||
|
||||
const input = screen.getByTestId('csv-file-input');
|
||||
await user.upload(input, csvFile);
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { files: [''] } });
|
||||
}
|
||||
expect(importServersFromFile).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(createServersMock).toHaveBeenCalledTimes(1));
|
||||
expect(createServersMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Save anyway', true],
|
||||
['Discard', false],
|
||||
])('creates expected servers depending on selected option in modal', async (btnName, savesDuplicatedServers) => {
|
||||
const existingServer = fromPartial<ServerWithId>({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' });
|
||||
const newServer = fromPartial<ServerWithId>({ url: 'newUrl', apiKey: 'newApiKey' });
|
||||
const { container, user } = setUp({}, { abc: existingServer });
|
||||
const input = container.querySelector('[type=file]');
|
||||
{ btnName: 'Save anyway',savesDuplicatedServers: true },
|
||||
{ btnName: 'Discard', savesDuplicatedServers: false },
|
||||
])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
|
||||
const existingServerData: ServerData = {
|
||||
name: 'existingServer',
|
||||
url: 'http://s.test/existingUrl',
|
||||
apiKey: 'existingApiKey',
|
||||
};
|
||||
const existingServer: ServerWithId = {
|
||||
...existingServerData,
|
||||
id: 'existingserver-s.test',
|
||||
};
|
||||
const newServer: ServerData = { name: 'newServer', url: 'http://s.test/newUrl', apiKey: 'newApiKey' };
|
||||
const { user } = setUp({}, { [existingServer.id]: existingServer });
|
||||
|
||||
importServersFromFile.mockResolvedValue([existingServer, newServer]);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { files: [''] } });
|
||||
}
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: btnName }));
|
||||
|
||||
expect(createServersMock).toHaveBeenCalledWith(savesDuplicatedServers ? [existingServer, newServer] : [newServer]);
|
||||
expect(createServersMock).toHaveBeenCalledWith(
|
||||
savesDuplicatedServers
|
||||
? [expect.objectContaining(existingServerData), expect.objectContaining(newServer)]
|
||||
: [expect.objectContaining(newServer)],
|
||||
);
|
||||
expect(onImportMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
69
test/servers/helpers/index.test.ts
Normal file
69
test/servers/helpers/index.test.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import type { ServersMap } from '../../../src/servers/data';
|
||||
import { ensureUniqueIds } from '../../../src/servers/helpers';
|
||||
|
||||
describe('index', () => {
|
||||
describe('ensureUniqueIds', () => {
|
||||
const servers: ServersMap = {
|
||||
'the-name-example.com': fromPartial({}),
|
||||
'another-name-example.com': fromPartial({}),
|
||||
'short-domain-s.test': fromPartial({}),
|
||||
};
|
||||
|
||||
it('returns expected list of servers when existing IDs conflict', () => {
|
||||
const result = ensureUniqueIds(servers, [
|
||||
fromPartial({ name: 'The name', url: 'https://example.com' }),
|
||||
fromPartial({ name: 'Short domain', url: 'https://s.test' }),
|
||||
fromPartial({ name: 'The name', url: 'https://example.com' }),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ id: 'the-name-example.com-1' }),
|
||||
expect.objectContaining({ id: 'short-domain-s.test-1' }),
|
||||
expect.objectContaining({ id: 'the-name-example.com-2' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns expected list of servers when IDs conflict in provided list of servers', () => {
|
||||
const result = ensureUniqueIds(servers, [
|
||||
fromPartial({ name: 'Foo', url: 'https://example.com' }),
|
||||
fromPartial({ name: 'Bar', url: 'https://s.test' }),
|
||||
fromPartial({ name: 'Foo', url: 'https://example.com' }),
|
||||
fromPartial({ name: 'Baz', url: 'https://s.test' }),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ id: 'foo-example.com' }),
|
||||
expect.objectContaining({ id: 'bar-s.test' }),
|
||||
expect.objectContaining({ id: 'foo-example.com-1' }),
|
||||
expect.objectContaining({ id: 'baz-s.test' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes server paths when not empty', () => {
|
||||
const result = ensureUniqueIds({}, [
|
||||
fromPartial({ name: 'Foo', url: 'https://example.com' }),
|
||||
fromPartial({ name: 'Bar', url: 'https://s.test/some/path' }),
|
||||
fromPartial({ name: 'Baz', url: 'https://s.test/some/other-path-here/123' }),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ id: 'foo-example.com' }),
|
||||
expect.objectContaining({ id: 'bar-s.test-some-path' }),
|
||||
expect.objectContaining({ id: 'baz-s.test-some-other-path-here-123' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses server URL verbatim when it is not a valid URL', () => {
|
||||
const result = ensureUniqueIds({}, [
|
||||
fromPartial({ name: 'Foo', url: 'invalid' }),
|
||||
fromPartial({ name: 'Bar', url: 'this is not a URL' }),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ id: 'foo-invalid' }),
|
||||
expect.objectContaining({ id: 'bar-this-is-not-a-url' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -9,74 +9,76 @@ describe('remoteServersReducer', () => {
|
|||
const httpClient = fromPartial<HttpClient>({ jsonRequest });
|
||||
|
||||
it.each([
|
||||
[
|
||||
[
|
||||
{
|
||||
id: '111',
|
||||
serversArray: [
|
||||
{
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
{
|
||||
id: '222',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
],
|
||||
{
|
||||
111: {
|
||||
id: '111',
|
||||
expectedNewServers: {
|
||||
'acel.me-from-servers.json-acel.me': {
|
||||
id: 'acel.me-from-servers.json-acel.me',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
222: {
|
||||
id: '222',
|
||||
'local-from-servers.json-localhost-8000': {
|
||||
id: 'local-from-servers.json-localhost-8000',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
[
|
||||
},
|
||||
{
|
||||
serversArray: [
|
||||
{
|
||||
id: '111',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
{
|
||||
id: '222',
|
||||
name: 'Invalid',
|
||||
},
|
||||
{
|
||||
id: '333',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
],
|
||||
{
|
||||
111: {
|
||||
id: '111',
|
||||
expectedNewServers: {
|
||||
'acel.me-from-servers.json-acel.me': {
|
||||
id: 'acel.me-from-servers.json-acel.me',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
333: {
|
||||
id: '333',
|
||||
'local-from-servers.json-localhost-8000': {
|
||||
id: 'local-from-servers.json-localhost-8000',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
|
||||
},
|
||||
],
|
||||
['<html></html>', {}],
|
||||
[{}, {}],
|
||||
])('tries to fetch servers from remote', async (mockedValue, expectedNewServers) => {
|
||||
jsonRequest.mockResolvedValue(mockedValue);
|
||||
},
|
||||
{
|
||||
serversArray: '<html></html>',
|
||||
expectedNewServers: {},
|
||||
},
|
||||
{
|
||||
serversArray: {},
|
||||
expectedNewServers: {},
|
||||
},
|
||||
])('tries to fetch servers from remote', async ({ serversArray, expectedNewServers }) => {
|
||||
jsonRequest.mockResolvedValue(serversArray);
|
||||
const doFetchServers = fetchServers(httpClient);
|
||||
|
||||
await doFetchServers()(dispatch, vi.fn(), {});
|
||||
|
|
|
@ -9,7 +9,6 @@ import {
|
|||
selectedServerReducerCreator,
|
||||
selectServer as selectServerCreator,
|
||||
} from '../../../src/servers/reducers/selectedServer';
|
||||
import { randomUUID } from '../../../src/utils/utils';
|
||||
|
||||
describe('selectedServerReducer', () => {
|
||||
const dispatch = vi.fn();
|
||||
|
@ -41,7 +40,7 @@ describe('selectedServerReducer', () => {
|
|||
['latest', MAX_FALLBACK_VERSION, 'latest'],
|
||||
['%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%'],
|
||||
])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
|
||||
const id = randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
const getState = createGetStateMock(id);
|
||||
const expectedSelectedServer = {
|
||||
id,
|
||||
|
@ -60,7 +59,7 @@ describe('selectedServerReducer', () => {
|
|||
});
|
||||
|
||||
it('dispatches error when health endpoint fails', async () => {
|
||||
const id = randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
const getState = createGetStateMock(id);
|
||||
const expectedSelectedServer = fromPartial<NonReachableServer>({ id, serverNotReachable: true });
|
||||
|
||||
|
@ -73,7 +72,7 @@ describe('selectedServerReducer', () => {
|
|||
});
|
||||
|
||||
it('dispatches error when server is not found', async () => {
|
||||
const id = randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} }));
|
||||
const expectedSelectedServer: NotFoundServer = { serverNotFound: true };
|
||||
|
||||
|
|
|
@ -105,15 +105,6 @@ describe('serversReducer', () => {
|
|||
|
||||
expect(payload).toEqual(list);
|
||||
});
|
||||
|
||||
it('generates an id for every provided server if they do not have it', () => {
|
||||
const servers = Object.values(list).map(({ name, autoConnect, url, apiKey }) => (
|
||||
{ name, autoConnect, url, apiKey }
|
||||
));
|
||||
const { payload } = createServers(servers);
|
||||
|
||||
expect(Object.values(payload).every(({ id }) => !!id)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAutoConnect', () => {
|
||||
|
|
Loading…
Reference in a new issue