mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +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:
|
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
|
||||||
|
|
8
.github/workflows/deploy-preview.yml
vendored
8
.github/workflows/deploy-preview.yml
vendored
|
@ -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:
|
||||||
|
|
6
.github/workflows/publish-release.yml
vendored
6
.github/workflows/publish-release.yml
vendored
|
@ -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
4
.gitignore
vendored
|
@ -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*
|
||||||
|
|
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).
|
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*
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
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:
|
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
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",
|
"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": [
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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%;
|
||||||
|
|
|
@ -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 />} />
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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__title-wrapper">
|
<div className="home__servers-container flex-grow-1">
|
||||||
<h1 className="home__title">Welcome!</h1>
|
<h1
|
||||||
</div>
|
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}>
|
<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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
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 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',
|
||||||
|
|
|
@ -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 }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 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();
|
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
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 });
|
const httpClient = fromPartial<HttpClient>({ jsonRequest });
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[
|
{
|
||||||
[
|
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: '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(), {});
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
Loading…
Reference in a new issue