Merge pull request #1387 from shlinkio/develop

Release 4.3.0
This commit is contained in:
Alejandro Celaya 2024-11-30 10:48:52 +01:00 committed by GitHub
commit 5aa113ec16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3208 additions and 4109 deletions

View 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

View file

@ -11,5 +11,5 @@ jobs:
ci: ci:
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
with: with:
node-version: 20.7 node-version: 22.x
publish-coverage: true publish-coverage: true

View file

@ -9,19 +9,19 @@ jobs:
continue-on-error: true continue-on-error: true
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
- name: Use node.js - name: Use node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.7 node-version: 22.x
- name: Build - name: Build
run: | run: |
npm ci && \ npm ci && \
node ./scripts/set-homepage.cjs /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \ node ./scripts/set-homepage.cjs /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
npm run build node --run build
- name: Deploy preview - name: Deploy preview
uses: shlinkio/deploy-preview-action@v1.0.1 uses: shlinkio/deploy-preview-action@v1.0.1
with: with:

View file

@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Use node.js - name: Use node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.7 node-version: 22.x
- name: Generate release assets - name: Generate release assets
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
- name: Publish release with assets - name: Publish release with assets

4
.gitignore vendored
View file

@ -7,9 +7,7 @@
# production # production
/build /build
/dist
npm-debug.log* npm-debug.log*
docker-compose.override.yml
home
public/servers.json* public/servers.json*

View file

@ -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). 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 ## [4.2.2] - 2024-10-19
### Added ### Added
* *Nothing* * *Nothing*

View file

@ -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. 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. > The first time the container is created, the project dependencies will be installed and the container may take a bit longer to start.
* 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).
## Project structure ## 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: 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. * `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. * `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. * `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic.
* `test`: Contains the project tests. * `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. > 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 node --run lint`: Checks coding styles are fulfilled, both in JS/TS files and in stylesheets.
* `./indocker npm run lint:js`: Checks coding styles are fulfilled in JS/TS files. * `./indocker node --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 node --run lint:css`: Checks coding styles are fulfilled in stylesheets.
* `./indocker npm run lint:js:fix`: Fixes coding styles in JS/TS files. * `./indocker node --run lint:js:fix`: Fixes coding styles in JS/TS files.
* `./indocker npm run lint:css:fix`: Fixes coding styles in stylesheets. * `./indocker node --run lint:css:fix`: Fixes coding styles in stylesheets.
* `./indocker npm run test`: Runs unit tests with Jest. * `./indocker node --run test`: Runs unit tests with Jest.
* `./indocker npm run mutate`: Runs mutation tests with StrykerJS (this command can be very slow).
## Building the project ## Building the project
The source code in this project cannot be run directly in a web browser, you need to build it first. 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 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 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 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 ## Pull request process

View file

@ -1,8 +1,8 @@
FROM node:23.0-alpine as node FROM node:23.3-alpine AS node
COPY . /shlink-web-client COPY . /shlink-web-client
ARG VERSION="latest" ARG VERSION="latest"
ENV VERSION ${VERSION} ENV VERSION=${VERSION}
RUN cd /shlink-web-client && npm ci && npm run build RUN cd /shlink-web-client && npm ci && node --run build
FROM nginxinc/nginx-unprivileged:1.27-alpine FROM nginxinc/nginx-unprivileged:1.27-alpine
ARG UID=101 ARG UID=101

View file

@ -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) [![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) [![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) [![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). A ReactJS-based progressive web application for [Shlink](https://shlink.io).

2
dist/.gitignore vendored
View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -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

View file

@ -1,7 +1,8 @@
services: services:
shlink_web_client_node: shlink_web_client_node:
container_name: 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" command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes: volumes:
- ./:/home/shlink/www - ./:/home/shlink/www

6614
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,35 +7,35 @@
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "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:css": "stylelint src/*.scss src/**/*.scss",
"lint:js": "eslint src test config/test", "lint:js": "eslint src test config/test",
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix", "lint:fix": "node --run lint:css:fix && node --run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix", "lint:css:fix": "node --run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix", "lint:js:fix": "node --run lint:js -- --fix",
"types": "tsc", "types": "tsc",
"start": "vite serve --host=0.0.0.0", "start": "vite serve --host=0.0.0.0",
"preview": "vite preview --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": "node --run types && vite build && node scripts/replace-version.mjs",
"build:dist": "npm run build && node scripts/create-dist-file.mjs", "build:dist": "node --run build && node scripts/create-dist-file.mjs",
"test": "vitest run --run", "test": "vitest run --run",
"test:watch": "vitest --watch", "test:watch": "vitest --watch",
"test:ci": "npm run test -- --coverage", "test:ci": "node --run test -- --coverage",
"test:verbose": "npm run test -- --verbose" "test:verbose": "node --run test -- --verbose"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.7.1",
"@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/fontawesome-svg-core": "^6.7.1",
"@fortawesome/free-brands-svg-icons": "^6.6.0", "@fortawesome/free-brands-svg-icons": "^6.7.1",
"@fortawesome/free-regular-svg-icons": "^6.6.0", "@fortawesome/free-regular-svg-icons": "^6.7.1",
"@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.7.1",
"@fortawesome/react-fontawesome": "^0.2.2", "@fortawesome/react-fontawesome": "^0.2.2",
"@json2csv/plainjs": "^7.0.6", "@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.3.0", "@reduxjs/toolkit": "^2.4.0",
"@shlinkio/data-manipulation": "^1.0.3", "@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.6.0", "@shlinkio/shlink-frontend-kit": "^0.6.0",
"@shlinkio/shlink-js-sdk": "^1.2.0", "@shlinkio/shlink-js-sdk": "^1.3.0",
"@shlinkio/shlink-web-component": "^0.10.1", "@shlinkio/shlink-web-component": "^0.11.0",
"bootstrap": "5.2.3", "bootstrap": "5.2.3",
"bottlejs": "^2.0.1", "bottlejs": "^2.0.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -46,45 +46,44 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-external-link": "^2.3.1", "react-external-link": "^2.3.1",
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"react-router-dom": "^6.27.0", "react-router-dom": "^6.28.0",
"reactstrap": "^9.2.3", "reactstrap": "^9.2.3",
"redux-localstorage-simple": "^2.5.1", "redux-localstorage-simple": "^2.5.1",
"uuid": "^10.0.0", "workbox-core": "^7.3.0",
"workbox-core": "^7.1.0", "workbox-expiration": "^7.3.0",
"workbox-expiration": "^7.1.0", "workbox-precaching": "^7.3.0",
"workbox-precaching": "^7.1.0", "workbox-routing": "^7.3.0",
"workbox-routing": "^7.1.0", "workbox-strategies": "^7.3.0"
"workbox-strategies": "^7.1.0"
}, },
"devDependencies": { "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", "@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
"@stylistic/eslint-plugin": "^2.9.0", "@stylistic/eslint-plugin": "^2.11.0",
"@testing-library/jest-dom": "^6.5.0", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@total-typescript/shoehorn": "^0.1.2", "@total-typescript/shoehorn": "^0.1.2",
"@types/react": "^18.3.11", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.3", "@vitest/coverage-v8": "^2.1.6",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"axe-core": "^4.10.1", "axe-core": "^4.10.2",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"eslint": "^9.13.0", "eslint": "^9.16.0",
"eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.1", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"history": "^5.3.0", "history": "^5.3.0",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"sass": "^1.80.3", "sass": "^1.81.0",
"stylelint": "^15.11.0", "stylelint": "^15.11.0",
"typescript": "^5.6.3", "typescript": "^5.7.2",
"typescript-eslint": "^8.10.0", "typescript-eslint": "^8.16.0",
"vite": "^5.4.9", "vite": "^6.0.1",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^0.21.1",
"vitest": "^2.0.2" "vitest": "^2.0.2"
}, },
"browserslist": [ "browserslist": [

View file

@ -4,6 +4,8 @@ set -e
ME=$(basename $0) 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() { setup_single_shlink_server() {
[ -n "$SHLINK_SERVER_URL" ] || return 0 [ -n "$SHLINK_SERVER_URL" ] || return 0
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0 [ -n "$SHLINK_SERVER_API_KEY" ] || return 0

View file

@ -1,4 +1,4 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; @import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.app-container { .app-container {
height: 100%; height: 100%;

View file

@ -50,8 +50,8 @@ const App: FCWithDeps<AppProps, AppDeps> = (
const isHome = location.pathname === '/'; const isHome = location.pathname === '/';
useEffect(() => { useEffect(() => {
// Try to fetch the remote servers if the list is empty at first // 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 // We use a ref because we don't care if the servers list becomes empty later.
if (Object.keys(initialServers.current).length === 0) { if (Object.keys(initialServers.current).length === 0) {
fetchServers(); fetchServers();
} }
@ -66,7 +66,7 @@ const App: FCWithDeps<AppProps, AppDeps> = (
<MainHeader /> <MainHeader />
<div className="app"> <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> <Routes>
<Route index element={<Home />} /> <Route index element={<Home />} />
<Route path="/settings/*" element={<Settings />} /> <Route path="/settings/*" element={<Settings />} />

View file

@ -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'; @import '../utils/mixins/horizontal-align';
.app-update-banner.app-update-banner { .app-update-banner.app-update-banner {

View file

@ -1,50 +1,7 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; @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);
}
.home__title { .home__title {
text-align: center;
font-size: 1.75rem; font-size: 1.75rem;
margin: 0;
@media (min-width: $mdMin) { @media (min-width: $mdMin) {
font-size: 2.2rem; font-size: 2.2rem;

View file

@ -1,9 +1,10 @@
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons'; import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { clsx } from 'clsx';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Card, Row } from 'reactstrap'; import { Card } from 'reactstrap';
import type { ServersMap } from '../servers/data'; import type { ServersMap } from '../servers/data';
import { ServersListGroup } from '../servers/ServersListGroup'; import { ServersListGroup } from '../servers/ServersListGroup';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
@ -27,33 +28,36 @@ export const Home = ({ servers }: HomeProps) => {
}, [serversList, navigate]); }, [serversList, navigate]);
return ( return (
<div className="home"> <div className="w-100">
<Card className="home__main-card"> <Card className="mx-auto" style={{ maxWidth: '720px' }}>
<Row className="g-0"> <div className="d-flex flex-column flex-md-row">
<div className="col-md-5 d-none d-md-block"> <div className="p-4 d-none d-md-flex align-items-center" style={{ width: '40%' }}>
<div className="home__logo-wrapper"> <div className="w-100">
<div className="home__logo">
<ShlinkLogo /> <ShlinkLogo />
</div> </div>
</div> </div>
</div>
<div className="col-md-7 home__servers-container"> <div className="home__servers-container flex-grow-1">
<div className="home__title-wrapper"> <h1
<h1 className="home__title">Welcome!</h1> className={clsx('home__title p-4 text-center m-0', { 'border-bottom': !hasServers })}
</div> style={{ borderColor: 'var(--border-color) !important' }}
>
Welcome!
</h1>
<ServersListGroup embedded servers={serversList}> <ServersListGroup embedded servers={serversList}>
{!hasServers && ( {!hasServers && (
<div className="p-4 text-center"> <div className="p-4 text-center d-flex flex-column gap-5">
<p className="mb-5">This application will help you manage your Shlink servers.</p> <p className="mb-0">This application will help you manage your Shlink servers.</p>
<p> <p className="mb-0">
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2"> <Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
<FontAwesomeIcon icon={faPlus}/> <span className="ms-1">Add a server</span> <FontAwesomeIcon icon={faPlus}/> <span className="ms-1">Add a server</span>
</Link> </Link>
</p> </p>
<p className="mb-0 mt-5"> <p className="mb-0">
<ExternalLink href="https://shlink.io/documentation"> <ExternalLink href="https://shlink.io/documentation">
<small> <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> </small>
</ExternalLink> </ExternalLink>
</p> </p>
@ -61,7 +65,7 @@ export const Home = ({ servers }: HomeProps) => {
)} )}
</ServersListGroup> </ServersListGroup>
</div> </div>
</Row> </div>
</Card> </Card>
</div> </div>
); );

View file

@ -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 { .main-header.main-header {
color: white; color: white;

View file

@ -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 { .no-menu-wrapper {
padding: 15px 0 0; padding: 15px 0 0;

View file

@ -1,6 +1,11 @@
import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import './NoMenuLayout.scss'; import './NoMenuLayout.scss';
export const NoMenuLayout: FC<PropsWithChildren> = ({ children }) => ( export type NoMenuLayoutProps = PropsWithChildren & {
<div className="no-menu-wrapper container-xl">{children}</div> className?: string;
};
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
<div className={clsx('no-menu-wrapper container-xl', className)}>{children}</div>
); );

View file

@ -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 { .shlink-versions-container--with-sidebar {
margin-left: 0; margin-left: 0;

View file

@ -1,4 +1,4 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet. Includes SASS var overrides @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/bootstrap/scss/bootstrap.scss';
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites @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-web-component/dist/index';

View file

@ -8,8 +8,8 @@ import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import { randomUUID } from '../utils/utils';
import type { ServerData, ServersMap, ServerWithId } from './data'; import type { ServerData, ServersMap, ServerWithId } from './data';
import { ensureUniqueIds } from './helpers';
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal'; import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
import type { ImportServersBtnProps } from './helpers/ImportServersBtn'; import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerForm } from './helpers/ServerForm'; 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 [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [isConfirmModalOpen, toggleConfirmModal] = useToggle(); const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [serverData, setServerData] = useState<ServerData>(); const [serverData, setServerData] = useState<ServerData>();
const saveNewServer = useCallback((theServerData: ServerData) => { const saveNewServer = useCallback((newServerData: ServerData) => {
const id = randomUUID(); const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
createServers([{ ...theServerData, id }]); createServers([newServerWithUniqueId]);
navigate(`/server/${id}`); navigate(`/server/${newServerWithUniqueId.id}`);
}, [createServers, navigate]); }, [createServers, navigate, servers]);
const onSubmit = useCallback((newServerData: ServerData) => { const onSubmit = useCallback((newServerData: ServerData) => {
setServerData(newServerData); setServerData(newServerData);

View file

@ -5,7 +5,7 @@ import { Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button, Row } from 'reactstrap'; import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } 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); const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
return ( return (
<NoMenuLayout> <NoMenuLayout className="d-flex flex-column gap-3">
<SearchField className="mb-3" onChange={setSearchTerm} /> <SearchField onChange={setSearchTerm} />
<Row className="mb-3"> <div className="d-flex flex-column flex-md-row gap-2">
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0"> <div className="d-flex gap-2">
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn> <ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
{filteredServers.length > 0 && ( {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 <FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
</Button> </Button>
)} )}
</div> </div>
<div className="col-md-6 text-md-end d-flex d-md-block"> <Button outline color="primary" className="ms-md-auto" tag={Link} to="/server/create">
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server <FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
</Button> </Button>
</div> </div>
</Row>
<SimpleCard> <SimpleCard>
<table className="table table-hover responsive-table mb-0"> <table className="table table-hover responsive-table mb-0">
@ -83,7 +81,7 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
</SimpleCard> </SimpleCard>
{errorImporting && ( {errorImporting && (
<div className="mt-3"> <div>
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result> <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
</div> </div>
)} )}

View file

@ -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/vertical-align';
@import '../utils/mixins/thin-scroll'; @import '../utils/mixins/thin-scroll';

View file

@ -6,9 +6,10 @@ import { useCallback, useRef, useState } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../../container/utils'; import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } 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 type { ServersImporter } from '../services/ServersImporter';
import { DuplicatedServersModal } from './DuplicatedServersModal'; import { DuplicatedServersModal } from './DuplicatedServersModal';
import { dedupServers, ensureUniqueIds } from './index';
export type ImportServersBtnProps = PropsWithChildren<{ export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void; onImport?: () => void;
@ -18,7 +19,7 @@ export type ImportServersBtnProps = PropsWithChildren<{
}>; }>;
type ImportServersBtnConnectProps = ImportServersBtnProps & { type ImportServersBtnConnectProps = ImportServersBtnProps & {
createServers: (servers: ServerData[]) => void; createServers: (servers: ServerWithId[]) => void;
servers: ServersMap; servers: ServersMap;
}; };
@ -26,9 +27,6 @@ type ImportServersBtnDeps = {
ServersImporter: ServersImporter ServersImporter: ServersImporter
}; };
const serversInclude = (servers: ServerData[], { url, apiKey }: ServerData) =>
servers.some((server) => server.url === url && server.apiKey === apiKey);
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
createServers, createServers,
servers, servers,
@ -43,30 +41,31 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]); const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const serversToCreate = useRef<ServerData[]>([]); const importedServersRef = useRef<ServerWithId[]>([]);
const create = useCallback((serversData: ServerData[]) => { const newServersRef = useRef<ServerWithId[]>([]);
const create = useCallback((serversData: ServerWithId[]) => {
createServers(serversData); createServers(serversData);
onImport(); onImport();
}, [createServers, onImport]); }, [createServers, onImport]);
const onFile = useCallback( const onFile = useCallback(
async ({ target }: ChangeEvent<HTMLInputElement>) => async ({ target }: ChangeEvent<HTMLInputElement>) =>
serversImporter.importServersFromFile(target.files?.[0]) serversImporter.importServersFromFile(target.files?.[0])
.then((newServers) => { .then((importedServers) => {
serversToCreate.current = newServers; const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
const existingServers = Object.values(servers); importedServersRef.current = ensureUniqueIds(servers, importedServers);
const dupServers = newServers.filter((server) => serversInclude(existingServers, server)); newServersRef.current = ensureUniqueIds(servers, newServers);
const hasDuplicatedServers = !!dupServers.length;
if (!hasDuplicatedServers) { if (duplicatedServers.length === 0) {
create(newServers); create(importedServersRef.current);
} else { } else {
setDuplicatedServers(dupServers); setDuplicatedServers(duplicatedServers);
showModal(); showModal();
} }
}) })
.then(() => { .then(() => {
// Reset input after processing file // Reset file input after processing file
(target as { value: string | null }).value = null; (target as { value: string | null }).value = null;
}) })
.catch(onImportError), .catch(onImportError),
@ -74,13 +73,13 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
); );
const createAllServers = useCallback(() => { const createAllServers = useCallback(() => {
create(serversToCreate.current); create(importedServersRef.current);
hideModal(); hideModal();
}, [create, hideModal, serversToCreate]); }, [create, hideModal]);
const createNonDuplicatedServers = useCallback(() => { const createNonDuplicatedServers = useCallback(() => {
create(serversToCreate.current.filter((server) => !serversInclude(duplicatedServers, server))); create(newServersRef.current);
hideModal(); hideModal();
}, [create, duplicatedServers, hideModal]); }, [create, hideModal]);
return ( 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. You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
</UncontrolledTooltip> </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 <DuplicatedServersModal
isOpen={isModalOpen} isOpen={isModalOpen}

View file

@ -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 { .server-error__container {
text-align: center; text-align: center;

View 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;
}

View file

@ -1,11 +1,14 @@
import type { HttpClient } from '@shlinkio/shlink-js-sdk'; import type { HttpClient } from '@shlinkio/shlink-js-sdk';
import pack from '../../../package.json'; import pack from '../../../package.json';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
import type { ServerData } from '../data';
import { hasServerData } from '../data'; import { hasServerData } from '../data';
import { ensureUniqueIds } from '../helpers';
import { createServers } from './servers'; 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( export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
'shlink/remoteServers/fetchServers', 'shlink/remoteServers/fetchServers',

View file

@ -1,7 +1,7 @@
import type { PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { randomUUID } from '../../utils/utils';
import type { ServerData, ServersMap, ServerWithId } from '../data'; import type { ServerData, ServersMap, ServerWithId } from '../data';
import { serversListToMap } from '../helpers';
interface EditServer { interface EditServer {
serverId: string; serverId: string;
@ -15,19 +15,6 @@ interface SetAutoConnect {
const initialState: ServersMap = {}; 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({ export const { actions, reducer } = createSlice({
name: 'shlink/servers', name: 'shlink/servers',
initialState, initialState,
@ -70,10 +57,7 @@ export const { actions, reducer } = createSlice({
}, },
}, },
createServers: { createServers: {
prepare: (servers: ServerData[]) => { prepare: (servers: ServerWithId[]) => ({ payload: serversListToMap(servers) }),
const payload = serversListToMap(servers.map(serverWithId));
return { payload };
},
reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }), reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }),
}, },
}, },

View file

@ -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>
);

View file

@ -1,9 +1,6 @@
import type { SyntheticEvent } from 'react'; import type { SyntheticEvent } from 'react';
import { v4 } from 'uuid';
export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => { export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
handler(); handler();
}; };
export const randomUUID = () => v4();

View file

@ -56,7 +56,7 @@ describe('<App />', () => {
it.each([ it.each([
['/foo', 'shlink-wrapper'], ['/foo', 'shlink-wrapper'],
['/bar', '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) => { ])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, expectedClasses) => {
const { container } = await setUp(pathname); const { container } = await setUp(pathname);
const shlinkWrapper = container.querySelector('.shlink-wrapper'); const shlinkWrapper = container.querySelector('.shlink-wrapper');

View file

@ -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 { fromPartial } from '@total-typescript/shoehorn';
import type { ServersMap, ServerWithId } from '../../../src/servers/data'; import type { ServerData, ServersMap, ServerWithId } from '../../../src/servers/data';
import type { import type {
ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn'; ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn';
import { ImportServersBtnFactory } 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'; import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<ImportServersBtn />', () => { describe('<ImportServersBtn />', () => {
const csvFile = new File([''], 'servers.csv', { type: 'text/csv' });
const onImportMock = vi.fn(); const onImportMock = vi.fn();
const createServersMock = vi.fn(); const createServersMock = vi.fn();
const importServersFromFile = vi.fn().mockResolvedValue([]); const importServersFromFile = vi.fn().mockResolvedValue([]);
@ -54,34 +55,43 @@ describe('<ImportServersBtn />', () => {
}); });
it('imports servers when file input changes', async () => { it('imports servers when file input changes', async () => {
const { container } = setUp(); const { user } = setUp();
const input = container.querySelector('[type=file]');
const input = screen.getByTestId('csv-file-input');
await user.upload(input, csvFile);
if (input) {
fireEvent.change(input, { target: { files: [''] } });
}
expect(importServersFromFile).toHaveBeenCalledTimes(1); expect(importServersFromFile).toHaveBeenCalledTimes(1);
await waitFor(() => expect(createServersMock).toHaveBeenCalledTimes(1)); expect(createServersMock).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
['Save anyway', true], { btnName: 'Save anyway',savesDuplicatedServers: true },
['Discard', false], { btnName: 'Discard', savesDuplicatedServers: false },
])('creates expected servers depending on selected option in modal', async (btnName, savesDuplicatedServers) => { ])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
const existingServer = fromPartial<ServerWithId>({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' }); const existingServerData: ServerData = {
const newServer = fromPartial<ServerWithId>({ url: 'newUrl', apiKey: 'newApiKey' }); name: 'existingServer',
const { container, user } = setUp({}, { abc: existingServer }); url: 'http://s.test/existingUrl',
const input = container.querySelector('[type=file]'); 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]); importServersFromFile.mockResolvedValue([existingServer, newServer]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
if (input) { await user.upload(screen.getByTestId('csv-file-input'), csvFile);
fireEvent.change(input, { target: { files: [''] } }); expect(screen.getByRole('dialog')).toBeInTheDocument();
}
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: btnName })); 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); expect(onImportMock).toHaveBeenCalledTimes(1);
}); });
}); });

View 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' }),
]);
});
});
});

View file

@ -9,74 +9,76 @@ describe('remoteServersReducer', () => {
const httpClient = fromPartial<HttpClient>({ jsonRequest }); const httpClient = fromPartial<HttpClient>({ jsonRequest });
it.each([ it.each([
[
[
{ {
id: '111', serversArray: [
{
name: 'acel.me from servers.json', name: 'acel.me from servers.json',
url: 'https://acel.me', url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
}, },
{ {
id: '222',
name: 'Local from servers.json', name: 'Local from servers.json',
url: 'http://localhost:8000', url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
}, },
], ],
{ expectedNewServers: {
111: { 'acel.me-from-servers.json-acel.me': {
id: '111', id: 'acel.me-from-servers.json-acel.me',
name: 'acel.me from servers.json', name: 'acel.me from servers.json',
url: 'https://acel.me', url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
}, },
222: { 'local-from-servers.json-localhost-8000': {
id: '222', id: 'local-from-servers.json-localhost-8000',
name: 'Local from servers.json', name: 'Local from servers.json',
url: 'http://localhost:8000', url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
}, },
}, },
], },
[ {
[ serversArray: [
{ {
id: '111',
name: 'acel.me from servers.json', name: 'acel.me from servers.json',
url: 'https://acel.me', url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
}, },
{ {
id: '222',
name: 'Invalid', name: 'Invalid',
}, },
{ {
id: '333',
name: 'Local from servers.json', name: 'Local from servers.json',
url: 'http://localhost:8000', url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
}, },
], ],
{ expectedNewServers: {
111: { 'acel.me-from-servers.json-acel.me': {
id: '111', id: 'acel.me-from-servers.json-acel.me',
name: 'acel.me from servers.json', name: 'acel.me from servers.json',
url: 'https://acel.me', url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
}, },
333: { 'local-from-servers.json-localhost-8000': {
id: '333', id: 'local-from-servers.json-localhost-8000',
name: 'Local from servers.json', name: 'Local from servers.json',
url: 'http://localhost:8000', url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
}, },
}, },
], },
['<html></html>', {}], {
[{}, {}], serversArray: '<html></html>',
])('tries to fetch servers from remote', async (mockedValue, expectedNewServers) => { expectedNewServers: {},
jsonRequest.mockResolvedValue(mockedValue); },
{
serversArray: {},
expectedNewServers: {},
},
])('tries to fetch servers from remote', async ({ serversArray, expectedNewServers }) => {
jsonRequest.mockResolvedValue(serversArray);
const doFetchServers = fetchServers(httpClient); const doFetchServers = fetchServers(httpClient);
await doFetchServers()(dispatch, vi.fn(), {}); await doFetchServers()(dispatch, vi.fn(), {});

View file

@ -9,7 +9,6 @@ import {
selectedServerReducerCreator, selectedServerReducerCreator,
selectServer as selectServerCreator, selectServer as selectServerCreator,
} from '../../../src/servers/reducers/selectedServer'; } from '../../../src/servers/reducers/selectedServer';
import { randomUUID } from '../../../src/utils/utils';
describe('selectedServerReducer', () => { describe('selectedServerReducer', () => {
const dispatch = vi.fn(); const dispatch = vi.fn();
@ -41,7 +40,7 @@ describe('selectedServerReducer', () => {
['latest', MAX_FALLBACK_VERSION, 'latest'], ['latest', MAX_FALLBACK_VERSION, 'latest'],
['%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%'], ['%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%'],
])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => { ])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
const id = randomUUID(); const id = crypto.randomUUID();
const getState = createGetStateMock(id); const getState = createGetStateMock(id);
const expectedSelectedServer = { const expectedSelectedServer = {
id, id,
@ -60,7 +59,7 @@ describe('selectedServerReducer', () => {
}); });
it('dispatches error when health endpoint fails', async () => { it('dispatches error when health endpoint fails', async () => {
const id = randomUUID(); const id = crypto.randomUUID();
const getState = createGetStateMock(id); const getState = createGetStateMock(id);
const expectedSelectedServer = fromPartial<NonReachableServer>({ id, serverNotReachable: true }); const expectedSelectedServer = fromPartial<NonReachableServer>({ id, serverNotReachable: true });
@ -73,7 +72,7 @@ describe('selectedServerReducer', () => {
}); });
it('dispatches error when server is not found', async () => { it('dispatches error when server is not found', async () => {
const id = randomUUID(); const id = crypto.randomUUID();
const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} })); const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} }));
const expectedSelectedServer: NotFoundServer = { serverNotFound: true }; const expectedSelectedServer: NotFoundServer = { serverNotFound: true };

View file

@ -105,15 +105,6 @@ describe('serversReducer', () => {
expect(payload).toEqual(list); 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', () => { describe('setAutoConnect', () => {