Merge pull request #1524 from shlinkio/develop

Release 4.4.0
This commit is contained in:
Alejandro Celaya
2025-04-20 17:07:11 +02:00
committed by GitHub
91 changed files with 5921 additions and 6826 deletions

View File

@@ -21,9 +21,12 @@ updates:
shlink:
patterns:
- '@shlinkio/*'
types:
react:
patterns:
- '@types/*'
- 'react'
- 'react-dom'
- '@types/react'
- '@types/react-dom'
testing:
patterns:
- '@testing-library/*'
@@ -38,6 +41,10 @@ updates:
workbox:
patterns:
- 'workbox*'
tailwindcss:
patterns:
- 'tailwindcss'
- '@tailwindcss/*'
ignore:
# Bootstrap can introduce visual breaking changes on styles
# Ignore it, since the plan is to remove it anyway

View File

@@ -13,3 +13,4 @@ jobs:
with:
node-version: 22.x
publish-coverage: true
install-playwright: true

View File

@@ -5,7 +5,7 @@ on:
jobs:
deploy:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
continue-on-error: true
steps:
- name: Checkout code
@@ -16,7 +16,7 @@ jobs:
- name: Use node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 22.10
- name: Build
run: |
npm ci && \

View File

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

View File

@@ -1,5 +0,0 @@
{
"extends": [
"@shlinkio/stylelint-config-css-coding-standard"
]
}

View File

@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [4.4.0] - 2025-04-20
### Added
* [#1510](https://github.com/shlinkio/shlink-web-client/issues/1510) Existing HTTP credentials (cookies, TLS certs, authentication headers) can now be forwarded to the API server if appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) are set
* [shlink-web-component#637](https://github.com/shlinkio/shlink-web-component/pull/637) QR codes are now generated client-side, without hitting Shlink.
* [shlink-web-component#641](https://github.com/shlinkio/shlink-web-component/issues/641) It is now possible to provide any logo to be used with QR codes.
* [shlink-web-component#640](https://github.com/shlinkio/shlink-web-component/issues/640) Allow default QR code settings to be handled via app settings.
### Changed
* Update to `react-router` 7.0
* Update to `@shlinkio/shlink-frontend-kit` 0.8.x
* Update to `@shlinkio/shlink-web-component` 0.13.x
* Update to `@shlinkio/shlink-js-sdk` 2.0.0
* Add `eslint-plugin-react-compiler`
* Run unit tests in a headless browser using vitest browser mode and playwright.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [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.
@@ -298,7 +323,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
### Fixed
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on miss-configured servers, after editing their params to set proper values.
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on misconfigured servers, after editing their params to set proper values.
## [3.8.2] - 2022-12-17

View File

@@ -45,11 +45,8 @@ shlink-web-client
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
* `./indocker node --run lint`: Checks coding styles are fulfilled, both in JS/TS files and in stylesheets.
* `./indocker node --run lint:js`: Checks coding styles are fulfilled in JS/TS files.
* `./indocker node --run lint:css`: Checks coding styles are fulfilled in stylesheets.
* `./indocker node --run lint:js:fix`: Fixes coding styles in JS/TS files.
* `./indocker node --run lint:css:fix`: Fixes coding styles in stylesheets.
* `./indocker node --run lint`: Checks coding styles are fulfilled in JS/TS files.
* `./indocker node --run lint:fix`: Fixes coding styles in JS/TS files.
* `./indocker node --run test`: Runs unit tests with Jest.
## Building the project

View File

@@ -1,4 +1,4 @@
FROM node:23.3-alpine AS node
FROM node:23.11-alpine AS node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION=${VERSION}

View File

@@ -1,24 +1,9 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import axe from 'axe-core';
import { afterEach } from 'vitest';
axe.configure({
checks: [
{
// Disable color contrast checking, as it doesn't work in jsdom
id: 'color-contrast',
enabled: false,
},
],
});
// Clear all mocks and cleanup DOM after every test
afterEach(() => {
vi.clearAllMocks();
cleanup();
});
HTMLCanvasElement.prototype.getContext = (() => {}) as any;
(global as any).scrollTo = () => {};
(global as any).matchMedia = () => ({ matches: false });

15
dev.Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM mcr.microsoft.com/playwright:v1.52.0-noble
ENV NODE_VERSION 22.14
ENV TINI_VERSION v0.19.0
# Install Node.js
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
\. "$HOME/.nvm/nvm.sh" && \
nvm install ${NODE_VERSION}
# Install tini
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini
RUN chmod +x /sbin/tini
# Set tini as the entry point, as node does not properly handle signals
ENTRYPOINT ["/sbin/tini", "--"]

View File

@@ -2,7 +2,9 @@ services:
shlink_web_client_node:
container_name: shlink_web_client_node
user: 1000:1000 # With this, files created via `indocker` script will belong to the host user
image: node:22.10-alpine
build:
context: .
dockerfile: ./dev.Dockerfile
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes:
- ./:/home/shlink/www

10473
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,12 +7,8 @@
"license": "MIT",
"type": "module",
"scripts": {
"lint": "node --run lint:css && node --run lint:js",
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:js": "eslint src test config/test",
"lint:fix": "node --run lint:css:fix && node --run lint:js:fix",
"lint:css:fix": "node --run lint:css -- --fix",
"lint:js:fix": "node --run lint:js -- --fix",
"lint": "eslint src test config/test",
"lint:fix": "node --run lint -- --fix",
"types": "tsc",
"start": "vite serve --host=0.0.0.0",
"preview": "vite preview --host=0.0.0.0",
@@ -24,18 +20,18 @@
"test:verbose": "node --run test -- --verbose"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.1",
"@fortawesome/fontawesome-svg-core": "^6.7.1",
"@fortawesome/free-brands-svg-icons": "^6.7.1",
"@fortawesome/free-regular-svg-icons": "^6.7.1",
"@fortawesome/free-solid-svg-icons": "^6.7.1",
"@fortawesome/fontawesome-free": "^6.7.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.4.0",
"@reduxjs/toolkit": "^2.7.0",
"@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.6.0",
"@shlinkio/shlink-js-sdk": "^1.3.0",
"@shlinkio/shlink-web-component": "^0.11.0",
"@shlinkio/shlink-frontend-kit": "^0.8.12",
"@shlinkio/shlink-js-sdk": "^2.1.0",
"@shlinkio/shlink-web-component": "^0.13.3",
"bootstrap": "5.2.3",
"bottlejs": "^2.0.1",
"clsx": "^2.1.1",
@@ -44,9 +40,9 @@
"date-fns": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-external-link": "^2.3.1",
"react-redux": "^9.1.2",
"react-router-dom": "^6.28.0",
"react-external-link": "^2.5.0",
"react-redux": "^9.2.0",
"react-router": "^7.5.1",
"reactstrap": "^9.2.3",
"redux-localstorage-simple": "^2.5.1",
"workbox-core": "^7.3.0",
@@ -56,35 +52,37 @@
"workbox-strategies": "^7.3.0"
},
"devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~3.2.1",
"@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
"@stylistic/eslint-plugin": "^2.11.0",
"@shlinkio/eslint-config-js-coding-standard": "~3.5.0",
"@stylistic/eslint-plugin": "^4.2.0",
"@tailwindcss/vite": "^4.1.4",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@total-typescript/shoehorn": "^0.1.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.6",
"@vitejs/plugin-react": "^4.4.0",
"@vitest/browser": "^3.1.1",
"@vitest/coverage-v8": "^3.1.1",
"adm-zip": "^0.5.16",
"axe-core": "^4.10.2",
"chalk": "^5.3.0",
"eslint": "^9.16.0",
"axe-core": "^4.10.3",
"chalk": "^5.4.1",
"eslint": "^9.25.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.0.0-beta-ebf51a3-20250411",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"history": "^5.3.0",
"jsdom": "^25.0.1",
"sass": "^1.81.0",
"stylelint": "^15.11.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0",
"vite": "^6.0.1",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^2.0.2"
"playwright": "^1.52.0",
"sass": "^1.86.3",
"tailwindcss": "^4.1.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.2",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^3.0.5"
},
"browserslist": [
">0.2%",

View File

@@ -10,7 +10,8 @@ setup_single_shlink_server() {
[ -n "$SHLINK_SERVER_URL" ] || return 0
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
local name="${SHLINK_SERVER_NAME:-Shlink}"
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
local forwardCredentials="${SHLINK_SERVER_FORWARD_CREDENTIALS:-false}"
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\",\"forwardCredentials\":${forwardCredentials}}]" > /usr/share/nginx/html/servers.json
}
setup_single_shlink_server

View File

@@ -4,7 +4,7 @@ import type { GetState } from '../../container/types';
import type { ServerWithId } from '../../servers/data';
import { hasServerData } from '../../servers/data';
const apiClients: Record<string, ShlinkApiClient> = {};
const apiClients: Map<string, ShlinkApiClient> = new Map();
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
typeof getStateOrSelectedServer === 'function';
@@ -18,14 +18,22 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => {
};
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
const { url: baseUrl, apiKey } = isGetState(getStateOrSelectedServer)
const { url: baseUrl, apiKey, forwardCredentials } = isGetState(getStateOrSelectedServer)
? getSelectedServerFromState(getStateOrSelectedServer)
: getStateOrSelectedServer;
const serverKey = `${apiKey}_${baseUrl}`;
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;
const existingApiClient = apiClients.get(serverKey);
const apiClient = apiClients[serverKey] ?? new ShlinkApiClient(httpClient, { apiKey, baseUrl });
apiClients[serverKey] = apiClient;
if (existingApiClient) {
return existingApiClient;
}
const apiClient = new ShlinkApiClient(
httpClient,
{ apiKey, baseUrl },
{ requestCredentials: forwardCredentials ? 'include' : undefined },
);
apiClients.set(serverKey, apiClient);
return apiClient;
};

View File

@@ -1,26 +0,0 @@
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.app-container {
height: 100%;
}
.app {
padding-top: $headerHeight;
height: 100%;
}
.shlink-wrapper {
min-height: 100%;
padding-bottom: $footer-height + $footer-margin;
margin-bottom: -($footer-height + $footer-margin);
}
.shlink-footer {
height: $footer-height;
margin-top: $footer-margin;
padding: 0;
@media (min-width: $mdMin) {
padding: 0 15px;
}
}

View File

@@ -3,14 +3,13 @@ import type { Settings } from '@shlinkio/shlink-web-component/settings';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect, useRef } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import { Route, Routes, useLocation } from 'react-router';
import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { NotFound } from '../common/NotFound';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from '../servers/data';
import { forceUpdate } from '../utils/helpers/sw';
import './App.scss';
type AppProps = {
fetchServers: () => void;
@@ -62,28 +61,38 @@ const App: FCWithDeps<AppProps, AppDeps> = (
}, [settings.ui?.theme]);
return (
<div className="container-fluid app-container">
<div className="tw:px-3 tw:h-full">
<MainHeader />
<div className="app">
<div className={clsx('shlink-wrapper', { 'd-flex align-items-center pt-3': isHome })}>
<div className="tw:h-full tw:pt-(--header-height)">
<div
data-testid="shlink-wrapper"
className={clsx(
'tw:min-h-full tw:pb-[calc(var(--footer-height)+var(--footer-margin))] tw:-mb-[calc(var(--footer-height)+var(--footer-margin))]',
{ 'tw:flex tw:items-center tw:pt-4': isHome },
)}
>
<Routes>
<Route index element={<Home />} />
<Route path="/settings/*" element={<Settings />} />
<Route path="/settings">
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
</Route>
<Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId/*" element={<ShlinkWebComponentContainer />} />
<Route path="/server/:serverId">
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</div>
<div className="shlink-footer">
<div className="tw:h-(--footer-height) tw:mt-(--footer-margin) tw:md:px-4">
<ShlinkVersionsContainer />
</div>
</div>
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
<AppUpdateBanner isOpen={appUpdated} onClose={resetAppUpdate} forceUpdate={forceUpdate} />
</div>
);
};

View File

@@ -1,17 +0,0 @@
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
@import '../utils/mixins/horizontal-align';
.app-update-banner.app-update-banner {
@include horizontal-align();
position: fixed;
top: $headerHeight - 25px;
padding: 0 4rem 0 0;
z-index: 1040;
margin: 0;
color: var(--text-color);
text-align: center;
width: 700px;
max-width: calc(100% - 30px);
box-shadow: 0 0 1rem var(--brand-color);
}

View File

@@ -1,34 +1,47 @@
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { SimpleCard, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { MouseEventHandler } from 'react';
import { forwardRef, useCallback } from 'react';
import { Alert, Button } from 'reactstrap';
import './AppUpdateBanner.scss';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { Button, Card, CloseButton } from '@shlinkio/shlink-frontend-kit/tailwind';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useCallback } from 'react';
interface AppUpdateBannerProps {
isOpen: boolean;
toggle: MouseEventHandler<any>;
onClose: () => void;
forceUpdate: () => void;
}
export const AppUpdateBanner = forwardRef<HTMLElement, AppUpdateBannerProps>(({ isOpen, toggle, forceUpdate }, ref) => {
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, forceUpdate }) => {
const [isUpdating,, setUpdating] = useToggle();
const update = useCallback(() => {
setUpdating();
forceUpdate();
}, [forceUpdate, setUpdating]);
if (!isOpen) {
return null;
}
return (
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary" innerRef={ref}>
<h4 className="mb-4">This app has just been updated!</h4>
<p className="mb-0">
<Card
role="alert"
className={clsx(
'tw:w-[700px] tw:max-w-[calc(100%-30px)]',
'tw:fixed tw:top-[35px] tw:left-[50%] tw:translate-x-[-50%] tw:z-[1040]',
)}
>
<Card.Header className="tw:flex tw:items-center tw:justify-between">
<h5>This app has just been updated!</h5>
<CloseButton onClick={onClose} />
</Card.Header>
<Card.Body className="tw:flex tw:gap-4 tw:items-center tw:justify-between tw:max-md:flex-col">
Restart it to enjoy the new features.
<Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
{isUpdating && <>Restarting...</>}
</Button>
</p>
</Alert>
</Card.Body>
</Card>
);
});
};

View File

@@ -1,7 +1,7 @@
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { PropsWithChildren, ReactNode } from 'react';
import { Component } from 'react';
import { Button } from 'reactstrap';
import { ErrorLayout } from './ErrorLayout';
type ErrorHandlerProps = PropsWithChildren<{
location?: typeof window.location;
@@ -33,14 +33,11 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
if (hasError) {
return (
<div className="home">
<SimpleCard className="p-4">
<h1>Oops! This is awkward :S</h1>
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<br />
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
</SimpleCard>
</div>
<ErrorLayout title="Oops! This is awkward :S">
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<br />
<Button size="lg" onClick={() => location.reload()}>Take me back</Button>
</ErrorLayout>
);
}

View File

@@ -0,0 +1,15 @@
import { SimpleCard } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC, PropsWithChildren } from 'react';
export type ErrorLayoutProps = PropsWithChildren<{
title: string;
}>;
export const ErrorLayout: FC<ErrorLayoutProps> = ({ children, title }) => (
<div className="tw:pt-4">
<SimpleCard className="tw:p-4 tw:w-full tw:lg:w-[65%] tw:m-auto">
<h2>{title}</h2>
{children}
</SimpleCard>
</div>
);

View File

@@ -1,15 +0,0 @@
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.home__title {
font-size: 1.75rem;
@media (min-width: $mdMin) {
font-size: 2.2rem;
}
}
.home__servers-container {
@media (min-width: $mdMin) {
border-left: 1px solid var(--border-color);
}
}

View File

@@ -1,18 +1,17 @@
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card } from '@shlinkio/shlink-frontend-kit/tailwind';
import { clsx } from 'clsx';
import { useEffect } from 'react';
import { ExternalLink } from 'react-external-link';
import { Link, useNavigate } from 'react-router-dom';
import { Card } from 'reactstrap';
import { useNavigate } from 'react-router';
import type { ServersMap } from '../servers/data';
import { ServersListGroup } from '../servers/ServersListGroup';
import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss';
interface HomeProps {
export type HomeProps = {
servers: ServersMap;
}
};
export const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate();
@@ -28,42 +27,42 @@ export const Home = ({ servers }: HomeProps) => {
}, [serversList, navigate]);
return (
<div className="w-100">
<Card className="mx-auto" style={{ maxWidth: '720px' }}>
<div className="d-flex flex-column flex-md-row">
<div className="p-4 d-none d-md-flex align-items-center" style={{ width: '40%' }}>
<div className="w-100">
<div className="tw:w-full">
<Card className="tw:mx-auto tw:max-w-[720px] tw:overflow-hidden">
<div className="tw:flex tw:flex-col tw:md:flex-row">
<div className="tw:p-6 tw:hidden tw:md:flex tw:items-center tw:w-[40%]">
<div className="tw:w-full">
<ShlinkLogo />
</div>
</div>
<div className="home__servers-container flex-grow-1">
<div className="tw:md:border-l tw:border-lm-border tw:dark:border-dm-border tw:flex-grow">
<h1
className={clsx('home__title p-4 text-center m-0', { 'border-bottom': !hasServers })}
style={{ borderColor: 'var(--border-color) !important' }}
className={clsx(
'tw:p-4 tw:text-center tw:border-lm-border tw:dark:border-dm-border',
{ 'tw:border-b': !hasServers },
)}
>
Welcome!
</h1>
<ServersListGroup embedded servers={serversList}>
{!hasServers && (
<div className="p-4 text-center d-flex flex-column gap-5">
<p className="mb-0">This application will help you manage your Shlink servers.</p>
<p className="mb-0">
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
<FontAwesomeIcon icon={faPlus}/> <span className="ms-1">Add a server</span>
</Link>
</p>
<p className="mb-0">
<ExternalLink href="https://shlink.io/documentation">
<small>
<span className="me-2">Learn more about Shlink</span>
<FontAwesomeIcon icon={faExternalLinkAlt}/>
</small>
</ExternalLink>
</p>
</div>
)}
</ServersListGroup>
{hasServers ? <ServersListGroup servers={serversList} /> : (
<div className="tw:p-6 tw:text-center tw:flex tw:flex-col tw:gap-12 tw:text-xl">
<p>This application will help you manage your Shlink servers.</p>
<p>
<Button to="/server/create" size="lg" inline>
<FontAwesomeIcon icon={faPlus} /> Add a server
</Button>
</p>
<p>
<ExternalLink href="https://shlink.io/documentation">
<small>
<span className="tw:mr-2">Learn more about Shlink</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</small>
</ExternalLink>
</p>
</div>
)}
</div>
</div>
</Card>

View File

@@ -1,24 +0,0 @@
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.main-header.main-header {
color: white;
background-color: var(--brand-color) !important;
.navbar-brand {
color: inherit !important;
}
}
.main-header__brand-logo {
width: 26px;
margin-right: 5px;
}
.main-header__toggle-icon {
width: 20px;
transition: transform 300ms;
}
.main-header__toggle-icon--opened {
transform: rotate(180deg);
}

View File

@@ -4,12 +4,11 @@ import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { ShlinkLogo } from './img/ShlinkLogo';
import './MainHeader.scss';
type MainHeaderDeps = {
ServersDropdown: FC;
@@ -25,20 +24,22 @@ const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
useEffect(collapse, [location, collapse]);
const settingsPath = '/settings';
const toggleClass = clsx('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
return (
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
<Navbar color="primary" dark fixed="top" expand="md" className="tw:text-white tw:bg-lm-brand tw:dark:bg-dm-brand">
<NavbarBrand tag={Link} to="/">
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
<ShlinkLogo className="tw:inline tw:w-7 tw:mr-1" color="white" /> Shlink
</NavbarBrand>
<NavbarToggler onClick={toggleCollapse}>
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
<FontAwesomeIcon
icon={arrowIcon}
className={clsx('tw:transition-transform tw:duration-300', { 'tw:rotate-180': isNotCollapsed })}
/>
</NavbarToggler>
<Collapse navbar isOpen={isNotCollapsed}>
<Nav navbar className="ms-auto">
<Nav navbar className="tw:ml-auto">
<NavItem>
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings

View File

@@ -1,9 +0,0 @@
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.no-menu-wrapper {
padding: 15px 0 0;
@media (min-width: $mdMin) {
padding: 30px 20px 20px;
}
}

View File

@@ -1,11 +1,12 @@
import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react';
import './NoMenuLayout.scss';
export type NoMenuLayoutProps = PropsWithChildren & {
className?: string;
};
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
<div className={clsx('no-menu-wrapper container-xl', className)}>{children}</div>
<div className={clsx('tw:container tw:mx-auto tw:p-5 tw:pt-8 tw:max-md:p-0 tw:max-md:py-4', className)}>
{children}
</div>
);

View File

@@ -1,19 +1,16 @@
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC, PropsWithChildren } from 'react';
import { Link } from 'react-router-dom';
import { ErrorLayout } from './ErrorLayout';
type NotFoundProps = PropsWithChildren<{ to?: string }>;
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
<div className="home">
<SimpleCard className="p-4">
<h2>Oops! We could not find requested route.</h2>
<p>
Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
button.
</p>
<br />
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
</SimpleCard>
</div>
<ErrorLayout title="Oops! We could not find requested route.">
<p>
Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
button.
</p>
<br />
<Button inline to={to} size="lg">{children}</Button>
</ErrorLayout>
);

View File

@@ -1,6 +1,6 @@
import type { FC, PropsWithChildren } from 'react';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useLocation } from 'react-router';
export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => {
const location = useLocation();

View File

@@ -12,7 +12,7 @@ export interface ShlinkVersionsProps {
}
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="tw:text-gray-500">
<b>{version}</b>
</ExternalLink>
);
@@ -21,7 +21,7 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
const normalizedClientVersion = normalizeVersion(clientVersion);
return (
<small className="text-muted">
<small className="tw:text-gray-500">
{isReachableServer(selectedServer) && (
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
)}

View File

@@ -1,9 +0,0 @@
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.shlink-versions-container--with-sidebar {
margin-left: 0;
@media (min-width: $mdMin) {
margin-left: $asideMenuWidth;
}
}

View File

@@ -2,7 +2,6 @@ import { clsx } from 'clsx';
import type { SelectedServer } from '../servers/data';
import { isReachableServer } from '../servers/data';
import { ShlinkVersions } from './ShlinkVersions';
import './ShlinkVersionsContainer.scss';
export type ShlinkVersionsContainerProps = {
selectedServer: SelectedServer;
@@ -10,9 +9,7 @@ export type ShlinkVersionsContainerProps = {
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
<div
className={clsx('text-center', {
'shlink-versions-container--with-sidebar': isReachableServer(selectedServer),
})}
className={clsx('tw:text-center', { 'tw:md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
>
<ShlinkVersions selectedServer={selectedServer} />
</div>

View File

@@ -1,4 +1,4 @@
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/browser';
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
import { ShlinkWebComponent } from '@shlinkio/shlink-web-component';
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';

View File

@@ -1,12 +1,11 @@
import type { IContainer } from 'bottlejs';
import type { FC } from 'react';
import { useRef } from 'react';
import { useMemo } from 'react';
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
const depsRef = useRef(obj as Omit<Required<Deps>, keyof FC>);
return depsRef.current;
return useMemo(() => obj as Omit<Required<Deps>, keyof FC>, [obj]);
}
export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>(

View File

@@ -1,4 +1,6 @@
@import '../node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet. Includes SASS var overrides
@import '../node_modules/bootstrap/scss/bootstrap.scss';
@import '../node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites
@import '../node_modules/@shlinkio/shlink-web-component/dist/index';
@use '../node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet
@use '../node_modules/bootstrap/scss/bootstrap.scss' with (
$primary: base.$mainColor // Override bootstrap's primary color
);
@use '../node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overrides
@use '../node_modules/@shlinkio/shlink-web-component/dist/index' as c-index;

View File

@@ -1,10 +1,11 @@
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { BrowserRouter } from 'react-router';
import pack from '../package.json';
import { container } from './container';
import { setUpStore } from './container/store';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import './tailwind.css';
import './index.scss';
const store = setUpStore(container);

View File

@@ -1,9 +1,10 @@
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Result, useToggle } from '@shlinkio/shlink-frontend-kit';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { ResultProps } from '@shlinkio/shlink-frontend-kit/tailwind';
import { Button, Result } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from 'reactstrap';
import { useNavigate } from 'react-router';
import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
@@ -26,11 +27,11 @@ type CreateServerDeps = {
useTimeoutToggle: TimeoutToggle;
};
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
<div className="mt-3">
<Result type={type}>
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
<div className="tw:mt-4">
<Result variant={variant}>
{variant === 'success' && 'Servers properly imported. You can now select one from the list :)'}
{variant === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
</Result>
</div>
);
@@ -40,7 +41,9 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
const navigate = useNavigate();
const goBack = useGoBack();
const hasServers = !!Object.keys(servers).length;
// eslint-disable-next-line react-compiler/react-compiler
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
// eslint-disable-next-line react-compiler/react-compiler
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [serverData, setServerData] = useState<ServerData>();
@@ -66,22 +69,22 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
return (
<NoMenuLayout>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={onSubmit}>
<ServerForm title="Add new server" onSubmit={onSubmit}>
{!hasServers && (
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
)}
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
<Button outline color="primary" className="ms-2">Create server</Button>
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
<Button type="submit">Create server</Button>
</ServerForm>
{serversImported && <ImportResult type="success" />}
{errorImporting && <ImportResult type="error" />}
{serversImported && <ImportResult variant="success" />}
{errorImporting && <ImportResult variant="error" />}
<DuplicatedServersModal
isOpen={isConfirmModalOpen}
open={isConfirmModalOpen}
duplicatedServers={serverData ? [serverData] : []}
onDiscard={goBack}
onSave={() => serverData && saveNewServer(serverData)}
onClose={goBack}
onConfirm={() => serverData && saveNewServer(serverData)}
/>
</NoMenuLayout>
);

View File

@@ -1,8 +1,7 @@
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data';
@@ -10,28 +9,29 @@ import type { DeleteServerModalProps } from './DeleteServerModal';
export type DeleteServerButtonProps = PropsWithChildren<{
server: ServerWithId;
className?: string;
textClassName?: string;
}>;
type DeleteServerButtonDeps = {
DeleteServerModal: FC<DeleteServerModalProps>;
};
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = (
{ server, className, children, textClassName },
) => {
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = ({ server, children }) => {
const { DeleteServerModal } = useDependencies(DeleteServerButton);
const [isModalOpen, , showModal, hideModal] = useToggle();
const navigate = useNavigate();
const onClose = useCallback((confirmed: boolean) => {
hideModal();
if (confirmed) {
navigate('/');
}
}, [hideModal, navigate]);
return (
<>
<button type="button" className={clsx(className, 'p-0 bg-transparent border-0')} onClick={showModal}>
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
<span className={textClassName}>{children ?? 'Remove this server'}</span>
<button type="button" className="tw:text-danger tw:hover:underline" onClick={showModal}>
{children}
</button>
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
<DeleteServerModal server={server} open={isModalOpen} onClose={onClose} />
</>
);
};

View File

@@ -1,44 +1,37 @@
import type { ExitAction } from '@shlinkio/shlink-frontend-kit/tailwind';
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { useCallback } from 'react';
import type { ServerWithId } from './data';
export interface DeleteServerModalProps {
export type DeleteServerModalProps = {
server: ServerWithId;
toggle: () => void;
isOpen: boolean;
redirectHome?: boolean;
}
onClose: (confirmed: boolean) => void;
open: boolean;
};
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
type DeleteServerModalConnectProps = DeleteServerModalProps & {
deleteServer: (server: ServerWithId) => void;
}
};
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
{ server, toggle, isOpen, deleteServer, redirectHome = true },
) => {
const navigate = useNavigate();
const doDelete = useRef<boolean>(false);
const toggleAndDelete = () => {
doDelete.current = true;
toggle();
};
const onClosed = () => {
if (!doDelete.current) {
return;
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = ({ server, onClose, open, deleteServer }) => {
const onClosed = useCallback((exitAction: ExitAction) => {
if (exitAction === 'confirm') {
deleteServer(server);
}
deleteServer(server);
if (redirectHome) {
navigate('/');
}
};
}, [deleteServer, server]);
return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
<ModalBody>
<CardModal
open={open}
title="Remove server"
variant="danger"
onClose={() => onClose(false)}
onConfirm={() => onClose(true)}
onClosed={onClosed}
confirmText="Delete"
>
<div className="tw:flex tw:flex-col tw:gap-y-4">
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
<i>
@@ -46,11 +39,7 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
You can create it again at any moment.
</i>
</p>
</ModalBody>
<ModalFooter>
<Button color="link" onClick={toggle}>Cancel</Button>
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
</ModalFooter>
</Modal>
</div>
</CardModal>
);
};

View File

@@ -1,6 +1,6 @@
import { useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory } from '../container/utils';
@@ -40,12 +40,12 @@ const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServ
return (
<NoMenuLayout>
<ServerForm
title={<h5 className="mb-0">Edit &quot;{selectedServer.name}&quot;</h5>}
title={<>Edit &quot;{selectedServer.name}&quot;</>}
initialValues={selectedServer}
onSubmit={handleSubmit}
>
<Button outline className="me-2" onClick={goBack}>Cancel</Button>
<Button outline color="primary">Save</Button>
<Button variant="secondary" onClick={goBack}>Cancel</Button>
<Button type="submit">Save</Button>
</ServerForm>
</NoMenuLayout>
);

View File

@@ -1,11 +1,9 @@
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
@@ -41,48 +39,50 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
[allServers, searchTerm],
);
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
// eslint-disable-next-line react-compiler/react-compiler
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
return (
<NoMenuLayout className="d-flex flex-column gap-3">
<SearchField onChange={setSearchTerm} />
<NoMenuLayout className="tw:flex tw:flex-col tw:gap-y-4">
<SearchInput onChange={setSearchTerm} />
<div className="d-flex flex-column flex-md-row gap-2">
<div className="d-flex gap-2">
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
<div className="tw:flex tw:flex-col tw:md:flex-row tw:gap-2">
<div className="tw:flex tw:gap-2">
<ImportServersBtn className="tw:flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
{filteredServers.length > 0 && (
<Button outline className="flex-fill" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
<Button variant="secondary" className="tw:flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} /> Export servers
</Button>
)}
</div>
<Button outline color="primary" className="ms-md-auto" tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
<Button className="tw:md:ml-auto" to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> Add a server
</Button>
</div>
<SimpleCard>
<table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header">
<tr>
{hasAutoConnect && <th style={{ width: '50px' }}><span className="sr-only">Auto-connect</span></th>}
<th>Name</th>
<th>Base URL</th>
<th><span className="sr-only">Options</span></th>
</tr>
</thead>
<tbody>
{!filteredServers.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
{filteredServers.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
))}
</tbody>
</table>
<SimpleCard className="card">
<Table header={(
<Table.Row>
{hasAutoConnect && (
<Table.Cell className="tw:w-[35px]"><span className="tw:sr-only">Auto-connect</span></Table.Cell>
)}
<Table.Cell>Name</Table.Cell>
<Table.Cell>Base URL</Table.Cell>
<Table.Cell><span className="sr-only">Options</span></Table.Cell>
</Table.Row>
)}>
{!filteredServers.length && (
<Table.Row className="tw:text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
)}
{filteredServers.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
))}
</Table>
</SimpleCard>
{errorImporting && (
<div>
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
<Result variant="error">The servers could not be imported. Make sure the format is correct.</Result>
</div>
)}
</NoMenuLayout>

View File

@@ -1,7 +1,8 @@
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Table } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
@@ -21,27 +22,27 @@ const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps>
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
return (
<tr className="responsive-table__row">
<Table.Row className="tw:relative">
{hasAutoConnect && (
<td className="responsive-table__cell" data-th="Auto-connect">
<Table.Cell columnName="Auto-connect">
{server.autoConnect && (
<>
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
<FontAwesomeIcon icon={checkIcon} className="tw:text-brand" id="autoConnectIcon" />
<UncontrolledTooltip target="autoConnectIcon" placement="right">
Auto-connect to this server
</UncontrolledTooltip>
</>
)}
</td>
</Table.Cell>
)}
<th className="responsive-table__cell" data-th="Name">
<Table.Cell className="tw:font-bold" columnName="Name">
<Link to={`/server/${server.id}`}>{server.name}</Link>
</th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<td className="responsive-table__cell text-end">
</Table.Cell>
<Table.Cell columnName="Base URL" className="tw:max-lg:border-b-0">{server.url}</Table.Cell>
<Table.Cell className="tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0">
<ManageServersRowDropdown server={server} />
</td>
</tr>
</Table.Cell>
</Table.Row>
);
};

View File

@@ -8,7 +8,7 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { DropdownItem } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
@@ -37,23 +37,25 @@ const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps,
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
return (
<RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
<DropdownItem tag={Link} to={serverUrl}>
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
</DropdownItem>
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
</DropdownItem>
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</DropdownItem>
<DropdownItem divider tag="hr" />
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
</DropdownItem>
<>
<RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
<DropdownItem tag={Link} to={serverUrl}>
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
</DropdownItem>
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
</DropdownItem>
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</DropdownItem>
<DropdownItem divider tag="hr" />
<DropdownItem className="tw:text-danger" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
</DropdownItem>
</RowDropdownBtn>
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
</RowDropdownBtn>
<DeleteServerModal server={server} open={isModalOpen} onClose={hideModal} />
</>
);
};

View File

@@ -1,6 +1,6 @@
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { SelectedServer, ServersMap } from './data';
import { getServerId } from './data';
@@ -13,36 +13,30 @@ export interface ServersDropdownProps {
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
const serversList = Object.values(servers);
const renderServers = () => {
if (serversList.length === 0) {
return (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
</DropdownItem>
);
}
return (
<>
{serversList.map(({ name, id }) => (
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
{name}
</DropdownItem>
))}
<DropdownItem divider tag="hr" />
<DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
</DropdownItem>
</>
);
};
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Servers</span>
</DropdownToggle>
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
<DropdownMenu end className="tw:right-0">
{serversList.length === 0 ? (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="tw:ml-1">Add a server</span>
</DropdownItem>
) : (
<>
{serversList.map(({ name, id }) => (
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
{name}
</DropdownItem>
))}
<DropdownItem divider tag="hr" />
<DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Manage servers</span>
</DropdownItem>
</>
)}
</DropdownMenu>
</UncontrolledDropdown>
);
};

View File

@@ -1,49 +0,0 @@
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
@import '../utils/mixins/vertical-align';
@import '../utils/mixins/thin-scroll';
.servers-list__list-group.servers-list__list-group {
width: 100%;
}
.servers-list__list-group:not(.servers-list__list-group--embedded) {
max-width: 400px;
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
}
.servers-list__server-item.servers-list__server-item {
text-align: left;
position: relative;
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item:not(:hover) {
color: $mainColor;
}
.servers-list__server-item:hover {
background-color: var(--secondary-color);
}
.servers-list__server-item-icon {
@include vertical-align();
right: 1rem;
}
.servers-list__list-group--embedded.servers-list__list-group--embedded {
border-radius: 0;
border-top: 1px solid var(--border-color);
@media (min-width: $mdMin) {
max-height: 220px;
overflow-x: auto;
@include thin-scroll();
}
.servers-list__server-item {
border: none;
border-bottom: 1px solid var(--border-color);
}
}

View File

@@ -1,35 +1,43 @@
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react';
import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import type { FC } from 'react';
import { Link } from 'react-router';
import type { ServerWithId } from './data';
import './ServersListGroup.scss';
type ServersListGroupProps = PropsWithChildren<{
type ServersListGroupProps = {
servers: ServerWithId[];
embedded?: boolean;
}>;
borderless?: boolean;
};
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
{name}
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
</ListGroupItem>
<Link
to={`/server/${id}`}
className={clsx(
'servers-list__server-item',
'tw:flex tw:items-center tw:justify-between tw:gap-x-2 tw:px-4 tw:py-3',
'tw:rounded-none tw:hover:bg-lm-secondary tw:hover:dark:bg-dm-secondary',
'tw:border-b tw:last:border-0 tw:border-lm-border tw:dark:border-dm-border',
)}
>
<span className="tw:truncate">{name}</span>
<FontAwesomeIcon icon={chevronIcon} />
</Link>
);
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
<>
{children && <div data-testid="title" className="mb-0 fs-5 fw-normal lh-sm">{children}</div>}
{servers.length > 0 && (
<ListGroup
<div
data-testid="list"
tag="div"
className={clsx('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
className={clsx(
'tw:w-full tw:border-lm-border tw:dark:border-dm-border',
'tw:md:max-h-56 tw:md:overflow-y-auto tw:-mb-1 tw:scroll-thin',
{ 'tw:border-y': !borderless },
)}
>
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</ListGroup>
</div>
)}
</>
);

View File

@@ -4,6 +4,7 @@ export interface ServerData {
name: string;
url: string;
apiKey: string;
forwardCredentials?: boolean;
}
export interface ServerWithId extends ServerData {
@@ -44,4 +45,31 @@ export const isNotFoundServer = (server: SelectedServer): server is NotFoundServ
export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
export const serverWithIdToServerData = ({ name, url, apiKey }: ServerWithId): ServerData => ({ name, url, apiKey });
/**
* Expose values that represent provided server, in a way that can be serialized in JSON or CSV strings.
*/
export const serializeServer = ({ name, url, apiKey, forwardCredentials }: ServerData): Record<string, string> => ({
name,
url,
apiKey,
forwardCredentials: forwardCredentials ? 'true' : 'false',
});
const validateServerData = (server: any): server is ServerData =>
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
/**
* Provided a record, it picks the right properties to build a ServerData object.
* @throws Error If any of the required ServerData properties is missing.
*/
export const deserializeServer = (potentialServer: Record<string, unknown>): ServerData => {
const { forwardCredentials, ...serverData } = potentialServer;
if (!validateServerData(serverData)) {
throw new Error('Server is missing required "url", "apiKey" and/or "name" properties');
}
return {
...serverData,
forwardCredentials: forwardCredentials === 'true',
};
};

View File

@@ -1,41 +1,42 @@
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { Fragment } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ServerData } from '../data';
interface DuplicatedServersModalProps {
export type DuplicatedServersModalProps = {
duplicatedServers: ServerData[];
isOpen: boolean;
onDiscard: () => void;
onSave: () => void;
}
open: boolean;
onClose: () => void;
onConfirm: () => void;
};
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
{ isOpen, duplicatedServers, onDiscard, onSave },
{ open, duplicatedServers, onClose, onConfirm },
) => {
const hasMultipleServers = duplicatedServers.length > 1;
return (
<Modal centered isOpen={isOpen}>
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
<ModalBody>
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul>
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}>
<li>URL: <b>{url}</b></li>
<li>API key: <b>{apiKey}</b></li>
</Fragment>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
</ul>
<span>
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
</span>
</ModalBody>
<ModalFooter>
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button>
<Button color="primary" onClick={onSave}>Save anyway</Button>
</ModalFooter>
</Modal>
<CardModal
size="lg"
title={`Duplicated server${hasMultipleServers ? 's' : ''}`}
open={open}
onClose={onClose}
onConfirm={onConfirm}
confirmText={`Save duplicate${hasMultipleServers ? 's' : ''}`}
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
>
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul className="tw:list-disc tw:mt-4">
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}>
<li>URL: <b>{url}</b></li>
<li>API key: <b>{apiKey}</b></li>
</Fragment>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
</ul>
<span>
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
</span>
</CardModal>
);
};

View File

@@ -1,9 +1,10 @@
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { ChangeEvent, PropsWithChildren } from 'react';
import { useCallback, useRef, useState } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap';
import { useCallback, useRef , useState } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import type { ServerData, ServersMap, ServerWithId } from '../data';
@@ -13,7 +14,7 @@ import { dedupServers, ensureUniqueIds } from './index';
export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void;
onImportError?: (error: Error) => void;
onError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom';
className?: string;
}>;
@@ -31,8 +32,8 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
createServers,
servers,
children,
onImport = () => {},
onImportError = () => {},
onImport,
onError = () => {},
tooltipPlacement = 'bottom',
className = '',
}) => {
@@ -40,50 +41,50 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
const ref = useElementRef<HTMLInputElement>();
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle();
const newServersCreatedRef = useRef(false);
const importedServersRef = useRef<ServerWithId[]>([]);
const newServersRef = useRef<ServerWithId[]>([]);
const create = useCallback((serversData: ServerWithId[]) => {
createServers(serversData);
onImport();
}, [createServers, onImport]);
const onFile = useCallback(
async ({ target }: ChangeEvent<HTMLInputElement>) =>
serversImporter.importServersFromFile(target.files?.[0])
.then((importedServers) => {
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
importedServersRef.current = ensureUniqueIds(servers, importedServers);
newServersRef.current = ensureUniqueIds(servers, newServers);
// Immediately create new servers
newServersCreatedRef.current = newServers.length > 0;
createServers(ensureUniqueIds(servers, newServers));
if (duplicatedServers.length === 0) {
create(importedServersRef.current);
} else {
// For duplicated servers, ask for confirmation
if (duplicatedServers.length > 0) {
setDuplicatedServers(duplicatedServers);
showModal();
} else {
onImport?.();
}
})
.then(() => {
// Reset file input after processing file
(target as { value: string | null }).value = null;
})
.catch(onImportError),
[create, onImportError, servers, serversImporter, showModal],
.catch(onError),
[createServers, onError, onImport, servers, serversImporter, showModal],
);
const createAllServers = useCallback(() => {
create(importedServersRef.current);
const createDuplicatedServers = useCallback(() => {
createServers(ensureUniqueIds(servers, duplicatedServers));
hideModal();
}, [create, hideModal]);
const createNonDuplicatedServers = useCallback(() => {
create(newServersRef.current);
onImport?.();
}, [createServers, duplicatedServers, hideModal, onImport, servers]);
const discardDuplicatedServers = useCallback(() => {
hideModal();
}, [create, hideModal]);
// If duplicated servers were discarded but some non-duplicated servers were created, call onImport
if (newServersCreatedRef.current) {
onImport?.();
}
}, [hideModal, onImport]);
return (
<>
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
<Button variant="secondary" id="importBtn" className={className} onClick={() => ref.current?.click()}>
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
</Button>
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
@@ -93,18 +94,19 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
<input
type="file"
accept=".csv"
className="d-none"
className="tw:hidden"
aria-hidden
ref={ref}
tabIndex={-1}
ref={ref as any /* TODO Remove After updating to React 19 */}
onChange={onFile}
data-testid="csv-file-input"
/>
<DuplicatedServersModal
isOpen={isModalOpen}
open={isModalOpen}
duplicatedServers={duplicatedServers}
onDiscard={createNonDuplicatedServers}
onSave={createAllServers}
onClose={discardDuplicatedServers}
onConfirm={createDuplicatedServers}
/>
</>
);

View File

@@ -1,17 +0,0 @@
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.server-error__container {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.server-error__delete-btn {
color: $dangerColor;
font-weight: inherit;
}
.server-error__delete-btn:hover {
text-decoration: underline;
}

View File

@@ -1,6 +1,6 @@
import { Message } from '@shlinkio/shlink-frontend-kit';
import { Card, Message } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
@@ -8,7 +8,6 @@ import type { SelectedServer, ServersMap } from '../data';
import { isServerWithId } from '../data';
import type { DeleteServerButtonProps } from '../DeleteServerButton';
import { ServersListGroup } from '../ServersListGroup';
import './ServerError.scss';
type ServerErrorProps = {
servers: ServersMap;
@@ -24,8 +23,8 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
return (
<NoMenuLayout>
<div className="server-error__container flex-column">
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
<div className="tw:flex tw:flex-col tw:items-center tw:gap-y-4 tw:md:gap-y-8">
<Message className="tw:w-full tw:lg:w-[80%]" variant="error">
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && (
<>
@@ -35,21 +34,21 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
)}
</Message>
<ServersListGroup servers={Object.values(servers)}>
<p className="mb-md-3">
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</p>
</ServersListGroup>
<p className="tw:text-xl">
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</p>
<Card className="tw:w-full tw:max-w-100 tw:overflow-hidden">
<ServersListGroup borderless servers={Object.values(servers)} />
</Card>
{isServerWithId(selectedServer) && (
<div className="container mt-3 mt-md-5">
<p className="fs-5 fw-normal lh-sm">
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</p>
</div>
<p className="tw:text-xl">
Alternatively, if you think you may have misconfigured this server, you
can <DeleteServerButton server={selectedServer}>remove
it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</p>
)}
</div>
</NoMenuLayout>

View File

@@ -1,7 +1,15 @@
import { InputFormGroup, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import {
Checkbox,
Details,
Label,
LabelledInput,
LabelledRevealablePasswordInput,
SimpleCard,
} from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { useState } from 'react';
import { handleEventPreventingDefault } from '../../utils/utils';
import { usePreventDefault } from '../../utils/utils';
import type { ServerData } from '../data';
type ServerFormProps = PropsWithChildren<{
@@ -14,17 +22,43 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
const [name, setName] = useState(initialValues?.name ?? '');
const [url, setUrl] = useState(initialValues?.url ?? '');
const [apiKey, setApiKey] = useState(initialValues?.apiKey ?? '');
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
const { flag: forwardCredentials, toggle: toggleForwardCredentials } = useToggle(
initialValues?.forwardCredentials ?? false,
true,
);
const handleSubmit = usePreventDefault(() => onSubmit({ name, url, apiKey, forwardCredentials }));
return (
<form className="server-form" name="serverForm" onSubmit={handleSubmit}>
<SimpleCard className="mb-3" title={title}>
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
<form name="serverForm" onSubmit={handleSubmit}>
<SimpleCard className="tw:mb-4" bodyClassName="tw:flex tw:flex-col tw:gap-y-3" title={title}>
<LabelledInput label="Name" value={name} onChange={(e) => setName(e.target.value)} required />
<LabelledInput label="URL" type="url" value={url} onChange={(e) => setUrl(e.target.value)} required />
<LabelledRevealablePasswordInput
label="API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
required
/>
<Details summary="Advanced options">
<div className="tw:flex tw:flex-col tw:gap-0.5">
<Label className="tw:flex tw:items-center tw:gap-x-1.5 tw:cursor-pointer">
<Checkbox onChange={toggleForwardCredentials} checked={forwardCredentials} />
Forward credentials to this server on every request.
</Label>
<small className="tw:pl-5.5 tw:text-gray-600 tw:dark:text-gray-400 tw:mt-0.5">
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a username
and password.
</small>
<small className="tw:pl-5.5 tw:text-gray-600 tw:dark:text-gray-400">
<b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will
make all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict
value for <code className="tw:whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
</small>
</div>
</Details>
</SimpleCard>
<div className="text-end">{children}</div>
<div className="tw:flex tw:items-center tw:justify-end tw:gap-x-2">{children}</div>
</form>
);
};

View File

@@ -1,7 +1,7 @@
import { Message } from '@shlinkio/shlink-frontend-kit';
import { Message } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useParams } from 'react-router';
import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { useDependencies } from '../../container/utils';

View File

@@ -2,24 +2,27 @@ import type { JsonToCsv } from '../../utils/helpers/csvjson';
import { saveCsv } from '../../utils/helpers/files';
import type { LocalStorage } from '../../utils/services/LocalStorage';
import type { ServersMap } from '../data';
import { serverWithIdToServerData } from '../data';
import { serializeServer } from '../data';
const SERVERS_FILENAME = 'shlink-servers.csv';
export class ServersExporter {
public constructor(
private readonly storage: LocalStorage,
private readonly window: Window,
private readonly jsonToCsv: JsonToCsv,
) {}
readonly #storage: LocalStorage;
readonly #window: Window;
readonly #jsonToCsv: JsonToCsv;
public constructor(storage: LocalStorage, window: Window, jsonToCsv: JsonToCsv) {
this.#storage = storage;
this.#window = window;
this.#jsonToCsv = jsonToCsv;
}
public readonly exportServers = async () => {
const servers = Object.values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
const servers = Object.values(this.#storage.get<ServersMap>('servers') ?? {}).map(serializeServer);
try {
const csv = this.jsonToCsv(servers);
saveCsv(this.window, csv, SERVERS_FILENAME);
const csv = this.#jsonToCsv(servers);
saveCsv(this.#window, csv, SERVERS_FILENAME);
} catch (e) {
// FIXME Handle error
console.error(e);

View File

@@ -1,14 +1,20 @@
import type { CsvToJson } from '../../utils/helpers/csvjson';
import type { ServerData } from '../data';
import { deserializeServer } from '../data';
const validateServer = (server: any): server is ServerData =>
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
const validateServers = (servers: any): servers is ServerData[] =>
Array.isArray(servers) && servers.every(validateServer);
const validateAndDeserializeServers = (servers: unknown): ServerData[] => {
if (!Array.isArray(servers)) {
throw new Error('Provided file does not have the right format.');
}
return servers.map(deserializeServer);
};
export class ServersImporter {
public constructor(private readonly csvToJson: CsvToJson) {}
readonly #csvToJson: CsvToJson;
public constructor(csvToJson: CsvToJson) {
this.#csvToJson = csvToJson;
}
public async importServersFromFile(file: File | null | undefined): Promise<ServerData[]> {
if (!file) {
@@ -16,12 +22,8 @@ export class ServersImporter {
}
const content = await file.text();
const servers = await this.csvToJson(content);
const servers = await this.#csvToJson(content);
if (!validateServers(servers)) {
throw new Error('Provided file does not have the right format.');
}
return servers;
return validateAndDeserializeServers(servers);
}
}

23
src/tailwind.css Normal file
View File

@@ -0,0 +1,23 @@
@import 'tailwindcss' prefix(tw) important;
@source '../node_modules/@shlinkio/shlink-frontend-kit';
@import '@shlinkio/shlink-frontend-kit/tailwind.preset.css';
@theme {
/* Override breakpoints with the values from bootstrap, to keep sizing until fully migrated */
--breakpoint-sm: 576px;
--breakpoint-md: 768px;
--breakpoint-lg: 992px;
--breakpoint-xl: 1200px;
--breakpoint-2xl: 1400px;
}
@layer base {
:root {
--header-height: 56px;
--footer-height: 2.3rem;
--footer-margin: .8rem;
/* Width of ShlinkWebComponent's side menu when not collapsed */
--aside-menu-width: 260px;
}
}

View File

@@ -1,46 +0,0 @@
import { DropdownBtn } from '@shlinkio/shlink-frontend-kit';
import type { VisitsSettings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { DropdownItem } from 'reactstrap';
export type DateInterval = VisitsSettings['defaultInterval'];
export interface DateIntervalSelectorProps {
active?: DateInterval;
allText: string;
onChange: (interval: DateInterval) => void;
}
export const INTERVAL_TO_STRING_MAP: Record<Exclude<DateInterval, 'all'>, string> = {
today: 'Today',
yesterday: 'Yesterday',
last7Days: 'Last 7 days',
last30Days: 'Last 30 days',
last90Days: 'Last 90 days',
last180Days: 'Last 180 days',
last365Days: 'Last 365 days',
};
const intervalToString = (interval: DateInterval | undefined, fallback: string): string => {
if (!interval || interval === 'all') {
return fallback;
}
return INTERVAL_TO_STRING_MAP[interval];
};
export const DateIntervalSelector: FC<DateIntervalSelectorProps> = ({ onChange, active, allText }) => (
<DropdownBtn text={intervalToString(active, allText)}>
<DropdownItem active={active === 'all'} onClick={() => onChange('all')}>
{allText}
</DropdownItem>
<DropdownItem divider />
{Object.entries(INTERVAL_TO_STRING_MAP).map(
([interval, name]) => (
<DropdownItem key={interval} active={active === interval} onClick={() => onChange(interval as DateInterval)}>
{name}
</DropdownItem>
),
)}
</DropdownBtn>
);

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
export const useGoBack = () => {
const navigate = useNavigate();

View File

@@ -1,5 +0,0 @@
@mixin horizontal-align {
position: absolute;
left: 50%;
transform: translateX(-50%);
}

View File

@@ -1,16 +0,0 @@
@mixin thin-scroll() {
/* Forefox scrollbar */
scrollbar-color: rgba(0, 0, 0, .2) #f5f5f5;
scrollbar-width: thin;
/* Chrome webkit scrollbar */
&::-webkit-scrollbar {
width: 6px;
background-color: #f5f5f5;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .2);
border-radius: .5rem;
}
}

View File

@@ -1,5 +0,0 @@
@mixin vertical-align($extraTransforms: null) {
position: absolute;
top: 50%;
transform: translateY(-50%) $extraTransforms;
}

View File

@@ -1,6 +1,11 @@
import type { SyntheticEvent } from 'react';
import { useCallback } from 'react';
export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => {
e.preventDefault();
handler();
};
/**
* Wraps an event handler so that it calls e.preventDefault() before invoking the event handler
*/
export const usePreventDefault = <Event extends SyntheticEvent = SyntheticEvent>(handler: (e: Event) => void) =>
useCallback((e: Event) => {
e.preventDefault();
handler(e);
}, [handler]);

View File

@@ -1,6 +1,6 @@
import type { FC, PropsWithChildren } from 'react';
import { useMemo } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { MemoryRouter, Route, Routes } from 'react-router';
export type MemoryRouterWithParamsProps = PropsWithChildren<{
params: Record<string, string>;

View File

@@ -1,14 +1,24 @@
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC, ReactElement } from 'react';
import { useCallback, useEffect , useState } from 'react';
interface RenderModalArgs {
isOpen: boolean;
toggle: () => void;
}
export type RenderModalArgs = {
open: boolean;
onClose: () => void;
};
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = (
{ renderModal },
) => {
const [isOpen, toggle] = useToggle(true);
return renderModal({ isOpen, toggle });
const [open, setOpen] = useState(true);
const onClose = useCallback(() => setOpen(false), []);
// Workaround to ensure CardModals from shlink-frontend-shared can be closed, as they depend on CSS transitions
// Since JSDOM does not support them, this dispatches the event right after the listener has been set-up
useEffect(() => {
if (!open) {
document.querySelector('[data-testid="transition-container"]')?.dispatchEvent(new Event('transitionend'));
}
}, [open]);
return renderModal({ open, onClose });
};

View File

@@ -1,3 +1,4 @@
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
import { fromPartial } from '@total-typescript/shoehorn';
import { buildShlinkApiClient } from '../../../src/api/services/ShlinkApiClientBuilder';
import type { ReachableServer, SelectedServer } from '../../../src/servers/data';
@@ -5,8 +6,8 @@ import type { ReachableServer, SelectedServer } from '../../../src/servers/data'
describe('ShlinkApiClientBuilder', () => {
const server = fromPartial<ReachableServer>;
const createBuilder = () => {
const builder = buildShlinkApiClient(fromPartial({}));
const createBuilder = (httpClient: HttpClient = fromPartial({})) => {
const builder = buildShlinkApiClient(httpClient);
return (selectedServer: SelectedServer) => builder(() => fromPartial({ selectedServer }));
};
@@ -16,9 +17,9 @@ describe('ShlinkApiClientBuilder', () => {
const secondApiClient = builder(server({ url: 'bar', apiKey: 'bar' }));
const thirdApiClient = builder(server({ url: 'bar', apiKey: 'foo' }));
expect(firstApiClient === secondApiClient).toEqual(false);
expect(firstApiClient === thirdApiClient).toEqual(false);
expect(secondApiClient === thirdApiClient).toEqual(false);
expect(firstApiClient).not.toBe(secondApiClient);
expect(firstApiClient).not.toBe(thirdApiClient);
expect(secondApiClient).not.toBe(thirdApiClient);
});
it('returns existing instances when provided params are the same', () => {
@@ -29,17 +30,42 @@ describe('ShlinkApiClientBuilder', () => {
const secondApiClient = builder(selectedServer);
const thirdApiClient = builder(selectedServer);
expect(firstApiClient === secondApiClient).toEqual(true);
expect(firstApiClient === thirdApiClient).toEqual(true);
expect(secondApiClient === thirdApiClient).toEqual(true);
expect(firstApiClient).toBe(secondApiClient);
expect(firstApiClient).toBe(thirdApiClient);
expect(secondApiClient).toBe(thirdApiClient);
});
it('does not fetch from state when provided param is already selected server', () => {
const url = 'url';
const apiKey = 'apiKey';
const apiClient = buildShlinkApiClient(fromPartial({}))(server({ url, apiKey }));
it('does not fetch from state when provided param is already a server', async () => {
const url = 'the_url';
const apiKey = 'the_api_key';
const jsonRequest = vi.fn();
const httpClient = fromPartial<HttpClient>({ jsonRequest });
const apiClient = createBuilder(httpClient)(server({ url, apiKey }));
expect(apiClient['serverInfo'].baseUrl).toEqual(url);
expect(apiClient['serverInfo'].apiKey).toEqual(apiKey);
await apiClient.health();
expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({
credentials: undefined,
headers: {
'X-Api-Key': apiKey,
},
}));
});
it('includes credentials when forwarding is enabled', async () => {
const url = 'the_url';
const apiKey = 'the_api_key';
const jsonRequest = vi.fn();
const httpClient = fromPartial<HttpClient>({ jsonRequest });
const apiClient = createBuilder(httpClient)(server({ url, apiKey, forwardCredentials: true }));
await apiClient.health();
expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({
credentials: 'include',
headers: {
'X-Api-Key': apiKey,
},
}));
});
});

View File

@@ -1,6 +1,6 @@
import { act, render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
import { AppFactory } from '../../src/app/App';
import { checkAccessibility } from '../__helpers__/accessibility';
@@ -54,13 +54,17 @@ describe('<App />', () => {
});
it.each([
['/foo', 'shlink-wrapper'],
['/bar', 'shlink-wrapper'],
['/', 'shlink-wrapper d-flex align-items-center pt-3'],
])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, expectedClasses) => {
const { container } = await setUp(pathname);
const shlinkWrapper = container.querySelector('.shlink-wrapper');
['/foo', false],
['/bar', false],
['/', true],
])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, isFlex) => {
await setUp(pathname);
const shlinkWrapper = screen.getByTestId('shlink-wrapper');
expect(shlinkWrapper).toHaveAttribute('class', expectedClasses);
if (isFlex) {
expect(shlinkWrapper).toHaveClass('tw:flex');
} else {
expect(shlinkWrapper).not.toHaveClass('tw:flex');
}
});
});

View File

@@ -4,11 +4,11 @@ import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<AppUpdateBanner />', () => {
const toggle = vi.fn();
const onClose = vi.fn();
const forceUpdate = vi.fn();
const setUp = async () => {
const result = await act(
() => renderWithEvents(<AppUpdateBanner isOpen toggle={toggle} forceUpdate={forceUpdate} />),
() => renderWithEvents(<AppUpdateBanner isOpen onClose={onClose} forceUpdate={forceUpdate} />),
);
await waitFor(() => screen.getByRole('alert'));
@@ -28,9 +28,9 @@ describe('<AppUpdateBanner />', () => {
it('invokes toggle when alert is closed', async () => {
const { user } = await setUp();
expect(toggle).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByLabelText('Close'));
expect(toggle).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('triggers the update when clicking the button', async () => {

View File

@@ -1,7 +1,7 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { Router } from 'react-router';
import { MainHeaderFactory } from '../../src/common/MainHeader';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
@@ -51,14 +51,12 @@ describe('<MainHeader />', () => {
const toggle = screen.getByLabelText('Toggle navigation');
const icon = toggle.firstChild;
expect(icon).toHaveAttribute('class', expect.stringMatching(/main-header__toggle-icon$/));
expect(icon).not.toHaveClass('tw:rotate-180');
await user.click(toggle);
expect(icon).toHaveAttribute(
'class',
expect.stringMatching(/main-header__toggle-icon main-header__toggle-icon--opened$/),
);
expect(icon).toHaveClass('tw:rotate-180');
await user.click(toggle);
expect(icon).toHaveAttribute('class', expect.stringMatching(/main-header__toggle-icon$/));
expect(icon).not.toHaveClass('tw:rotate-180');
});
it('opens Collapse when clicking toggle', async () => {

View File

@@ -30,6 +30,5 @@ describe('<NotFound />', () => {
expect(link).toHaveAttribute('href', expectedLink);
expect(link).toHaveTextContent(expectedText);
expect(link).toHaveAttribute('class', 'btn btn-outline-primary btn-lg');
});
});

View File

@@ -16,11 +16,16 @@ describe('<ShlinkVersionsContainer />', () => {
])('passes a11y checks', (selectedServer) => checkAccessibility(setUp(selectedServer)));
it.each([
[null, 'text-center'],
[fromPartial<SelectedServer>({}), 'text-center'],
[fromPartial<ReachableServer>({ version: '1.0.0' }), 'text-center shlink-versions-container--with-sidebar'],
])('renders proper col classes based on sidebar status', (selectedServer, expectedClasses) => {
[null, false],
[fromPartial<SelectedServer>({}), false],
[fromPartial<ReachableServer>({ version: '1.0.0' }), true],
])('renders proper col classes based on sidebar status', (selectedServer, shouldAddMargin) => {
const { container } = setUp(selectedServer);
expect(container.firstChild).toHaveAttribute('class', `${expectedClasses}`);
if (shouldAddMargin) {
expect(container.firstChild).toHaveClass('tw:md:ml-(--aside-menu-width)');
} else {
expect(container.firstChild).not.toHaveClass('tw:md:ml-(--aside-menu-width)');
}
});
});

View File

@@ -1,7 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { Router } from 'react-router';
import { CreateServerFactory } from '../../src/servers/CreateServer';
import type { ServersMap } from '../../src/servers/data';
import { checkAccessibility } from '../__helpers__/accessibility';

View File

@@ -1,18 +1,28 @@
import { screen, waitFor } from '@testing-library/react';
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import type { ReactNode } from 'react';
import { Router } from 'react-router';
import { DeleteServerButtonFactory } from '../../src/servers/DeleteServerButton';
import type { DeleteServerModalProps } from '../../src/servers/DeleteServerModal';
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<DeleteServerButton />', () => {
const DeleteServerButton = DeleteServerButtonFactory(fromPartial({
DeleteServerModal: ({ isOpen }: DeleteServerModalProps) => <>DeleteServerModal {isOpen ? '[Open]' : '[Closed]'}</>,
DeleteServerModal: (props: DeleteServerModalProps) => <DeleteServerModal {...props} deleteServer={vi.fn()} />,
}));
const setUp = (children?: ReactNode) => renderWithEvents(
<DeleteServerButton server={fromPartial({})} textClassName="button">{children}</DeleteServerButton>,
);
const setUp = (children: ReactNode = 'Remove this server') => {
const history = createMemoryHistory({ initialEntries: ['/foo'] });
const result = renderWithEvents(
<Router location={history.location} navigator={history}>
<DeleteServerButton server={fromPartial({})}>{children}</DeleteServerButton>
</Router>,
);
return { history, ...result };
};
it('passes a11y checks', () => checkAccessibility(setUp('Delete me')));
@@ -20,7 +30,6 @@ describe('<DeleteServerButton />', () => {
['Foo bar'],
['baz'],
['something'],
[undefined],
])('renders expected content', (children) => {
const { container } = setUp(children);
expect(container.firstChild).toBeTruthy();
@@ -28,14 +37,21 @@ describe('<DeleteServerButton />', () => {
});
it('displays modal when button is clicked', async () => {
const { user, container } = setUp();
const { user } = setUp();
expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Closed/);
expect(screen.getByText(/DeleteServerModal/)).not.toHaveTextContent(/Open/);
if (container.firstElementChild) {
await user.click(container.firstElementChild);
}
expect(screen.queryByText(/Are you sure you want to remove/)).not.toBeInTheDocument();
await user.click(screen.getByText('Remove this server'));
expect(screen.getByText(/Are you sure you want to remove/)).toBeInTheDocument();
});
await waitFor(() => expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Open/));
it('navigates to home when deletion is confirmed', async () => {
const { user, history } = setUp();
// Open modal
await user.click(screen.getByText('Remove this server'));
expect(history.location.pathname).toEqual('/foo');
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(history.location.pathname).toEqual('/');
});
});

View File

@@ -1,7 +1,5 @@
import { act, screen, waitFor } from '@testing-library/react';
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
@@ -10,36 +8,29 @@ import { TestModalWrapper } from '../__helpers__/TestModalWrapper';
describe('<DeleteServerModal />', () => {
const deleteServerMock = vi.fn();
const serverName = 'the_server_name';
const setUp = async () => {
const history = createMemoryHistory({ initialEntries: ['/foo'] });
const result = await act(() => renderWithEvents(
<Router location={history.location} navigator={history}>
<TestModalWrapper
renderModal={(args) => (
<DeleteServerModal
{...args}
server={fromPartial({ name: serverName })}
deleteServer={deleteServerMock}
/>
)}
const setUp = () => renderWithEvents(
<TestModalWrapper
renderModal={(args) => (
<DeleteServerModal
{...args}
server={fromPartial({ name: serverName })}
deleteServer={deleteServerMock}
/>
</Router>,
));
return { history, ...result };
};
)}
/>,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders a modal window', async () => {
await setUp();
it('renders a modal window', () => {
setUp();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByRole('heading')).toHaveTextContent('Remove server');
});
it('displays the name of the server as part of the content', async () => {
await setUp();
it('displays the name of the server as part of the content', () => {
setUp();
expect(screen.getByText(/^Are you sure you want to remove/)).toBeInTheDocument();
expect(screen.getByText(serverName)).toBeInTheDocument();
@@ -47,25 +38,21 @@ describe('<DeleteServerModal />', () => {
it.each([
[() => screen.getByRole('button', { name: 'Cancel' })],
[() => screen.getByLabelText('Close')],
])('toggles when clicking cancel button', async (getButton) => {
const { user, history } = await setUp();
[() => screen.getByLabelText('Close dialog')],
])('closes dialog when clicking cancel button', async (getButton) => {
const { user } = setUp();
expect(history.location.pathname).toEqual('/foo');
expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.click(getButton());
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(deleteServerMock).not.toHaveBeenCalled();
expect(history.location.pathname).toEqual('/foo'); // No navigation happens, keeping initial pathname
});
it('deletes server when clicking accept button', async () => {
const { user, history } = await setUp();
const { user } = setUp();
expect(deleteServerMock).not.toHaveBeenCalled();
expect(history.location.pathname).toEqual('/foo');
await user.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(deleteServerMock).toHaveBeenCalledTimes(1));
await waitFor(() => expect(history.location.pathname).toEqual('/'));
expect(deleteServerMock).toHaveBeenCalledOnce();
});
});

View File

@@ -1,7 +1,7 @@
import { fireEvent, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { Router } from 'react-router';
import type { ReachableServer, SelectedServer } from '../../src/servers/data';
import { EditServerFactory } from '../../src/servers/EditServer';
import { checkAccessibility } from '../__helpers__/accessibility';
@@ -47,16 +47,16 @@ describe('<EditServer />', () => {
it('display the server info in the form components', () => {
setUp();
expect(screen.getByDisplayValue('the_name')).toBeInTheDocument();
expect(screen.getByDisplayValue('the_url')).toBeInTheDocument();
expect(screen.getByDisplayValue('the_api_key')).toBeInTheDocument();
expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
expect(screen.getByLabelText(/^URL/)).toBeInTheDocument();
expect(screen.getByLabelText(/^API key/)).toBeInTheDocument();
});
it('edits server and redirects to it when form is submitted', async () => {
const { user, history } = setUp();
await user.type(screen.getByDisplayValue('the_name'), ' edited');
await user.type(screen.getByDisplayValue('the_url'), ' edited');
await user.type(screen.getByLabelText(/^Name/), ' edited');
await user.type(screen.getByLabelText(/^URL/), ' edited');
// TODO Using fire event because userEvent.click on the Submit button does not submit the form
// await user.click(screen.getByRole('button', { name: 'Save' }));
fireEvent.submit(screen.getByRole('form'));
@@ -65,9 +65,26 @@ describe('<EditServer />', () => {
name: 'the_name edited',
url: 'the_url edited',
apiKey: 'the_api_key',
forwardCredentials: false,
});
// After saving we go back, to the first route from history's initialEntries
expect(history.location.pathname).toEqual('/foo');
});
it.each([
{ forwardCredentials: true },
{ forwardCredentials: false },
])('edits advanced options - forward credentials', async (serverPartial) => {
const { user } = setUp({ ...defaultSelectedServer, ...serverPartial });
await user.click(screen.getByText('Advanced options'));
await user.click(screen.getByLabelText('Forward credentials to this server on every request.'));
fireEvent.submit(screen.getByRole('form'));
expect(editServerMock).toHaveBeenCalledWith('abc123', expect.objectContaining({
forwardCredentials: !serverPartial.forwardCredentials,
}));
});
});

View File

@@ -1,6 +1,6 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
import type { ServersMap, ServerWithId } from '../../src/servers/data';
import { ManageServersFactory } from '../../src/servers/ManageServers';
import type { ServersExporter } from '../../src/servers/services/ServersExporter';

View File

@@ -1,6 +1,7 @@
import { Table } from '@shlinkio/shlink-frontend-kit/tailwind';
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
import type { ServerWithId } from '../../src/servers/data';
import { ManageServersRowFactory } from '../../src/servers/ManageServersRow';
import { checkAccessibility } from '../__helpers__/accessibility';
@@ -17,11 +18,9 @@ describe('<ManageServersRow />', () => {
};
const setUp = (hasAutoConnect = false, autoConnect = false) => render(
<MemoryRouter>
<table>
<tbody>
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
</tbody>
</table>
<Table header={<Table.Row />}>
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
</Table>
</MemoryRouter>,
);
@@ -32,11 +31,7 @@ describe('<ManageServersRow />', () => {
[false, 3],
])('renders expected amount of columns', (hasAutoConnect, expectedCols) => {
setUp(hasAutoConnect);
const td = screen.getAllByRole('cell');
const th = screen.getAllByRole('columnheader');
expect(td.length + th.length).toEqual(expectedCols);
expect(screen.getAllByRole('cell')).toHaveLength(expectedCols);
});
it('renders a dropdown', () => {

View File

@@ -1,7 +1,7 @@
import { screen } from '@testing-library/react';
import type { UserEvent } from '@testing-library/user-event';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
import type { ServerWithId } from '../../src/servers/data';
import { ManageServersRowDropdownFactory } from '../../src/servers/ManageServersRowDropdown';
import { checkAccessibility } from '../__helpers__/accessibility';
@@ -9,8 +9,8 @@ import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ManageServersRowDropdown />', () => {
const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({
DeleteServerModal: ({ isOpen }: { isOpen: boolean }) => (
<span>DeleteServerModal {isOpen ? '[OPEN]' : '[CLOSED]'}</span>
DeleteServerModal: ({ open }: { open: boolean }) => (
<span>DeleteServerModal {open ? '[OPEN]' : '[CLOSED]'}</span>
),
}));
const setAutoConnect = vi.fn();
@@ -24,15 +24,13 @@ describe('<ManageServersRowDropdown />', () => {
};
const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button'));
it.each([
[setUp],
[async () => {
const { user, container } = setUp();
await toggleDropdown(user);
it('passes a11y checks', async () => {
const { user, container } = setUp();
// Open menu
await toggleDropdown(user);
return { container };
}],
])('passes a11y checks', (setUp) => checkAccessibility(setUp()));
return checkAccessibility({ container });
});
it('renders expected amount of dropdown items', async () => {
const { user } = setUp();

View File

@@ -1,6 +1,6 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
import type { ServersMap } from '../../src/servers/data';
import { ServersDropdown } from '../../src/servers/ServersDropdown';
import { checkAccessibility } from '../__helpers__/accessibility';
@@ -20,7 +20,13 @@ describe('<ServersDropdown />', () => {
</MemoryRouter>,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it('passes a11y checks', async () => {
const { user, ...rest } = setUp();
// Open menu
await user.click(screen.getByText('Servers'));
return checkAccessibility(rest);
});
it('contains the list of servers and the "mange servers" button', async () => {
const { user } = setUp();

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
import type { ServerWithId } from '../../src/servers/data';
import { ServersListGroup } from '../../src/servers/ServersListGroup';
import { checkAccessibility } from '../__helpers__/accessibility';
@@ -10,30 +10,18 @@ describe('<ServersListGroup />', () => {
fromPartial({ name: 'foo', id: '123' }),
fromPartial({ name: 'bar', id: '456' }),
];
const setUp = (params: { servers?: ServerWithId[]; withChildren?: boolean; embedded?: boolean } = {}) => {
const { servers = [], withChildren = true, embedded } = params;
const setUp = (params: { servers?: ServerWithId[]; borderless?: boolean } = {}) => {
const { servers = [], borderless } = params;
return render(
<MemoryRouter>
<ServersListGroup servers={servers} embedded={embedded}>
{withChildren ? 'The list of servers' : undefined}
</ServersListGroup>
<ServersListGroup servers={servers} borderless={borderless} />
</MemoryRouter>,
);
};
it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders title', () => {
setUp({});
expect(screen.getByTestId('title')).toHaveTextContent('The list of servers');
});
it('does not render title when children is not provided', () => {
setUp({ withChildren: false });
expect(screen.queryByTestId('title')).not.toBeInTheDocument();
});
it.each([
[servers],
[[]],
@@ -45,11 +33,17 @@ describe('<ServersListGroup />', () => {
});
it.each([
[true, 'servers-list__list-group servers-list__list-group--embedded'],
[false, 'servers-list__list-group'],
[undefined, 'servers-list__list-group'],
])('renders proper classes for embedded', (embedded, expectedClasses) => {
setUp({ servers, embedded });
expect(screen.getByTestId('list')).toHaveAttribute('class', `${expectedClasses} list-group`);
[true],
[false],
[undefined],
])('renders proper classes for embedded', (borderless) => {
setUp({ servers, borderless });
const list = screen.getByTestId('list');
if (!borderless) {
expect(list).toHaveClass('tw:border-y');
} else {
expect(list).not.toHaveClass('tw:border-y');
}
});
});

View File

@@ -2,67 +2,27 @@
exports[`<DeleteServerButton /> > renders expected content 1`] = `
<button
class="p-0 bg-transparent border-0"
class="tw:text-danger tw:hover:underline"
type="button"
>
<span
class="button"
>
Foo bar
</span>
Foo bar
</button>
`;
exports[`<DeleteServerButton /> > renders expected content 2`] = `
<button
class="p-0 bg-transparent border-0"
class="tw:text-danger tw:hover:underline"
type="button"
>
<span
class="button"
>
baz
</span>
baz
</button>
`;
exports[`<DeleteServerButton /> > renders expected content 3`] = `
<button
class="p-0 bg-transparent border-0"
class="tw:text-danger tw:hover:underline"
type="button"
>
<span
class="button"
>
something
</span>
</button>
`;
exports[`<DeleteServerButton /> > renders expected content 4`] = `
<button
class="p-0 bg-transparent border-0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-circle-minus fa-fw "
data-icon="circle-minus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
fill="currentColor"
/>
</svg>
<span
class="button"
>
Remove this server
</span>
something
</button>
`;

View File

@@ -2,18 +2,29 @@
exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 1`] = `
<div>
<table>
<tbody>
<table
class="tw:w-full"
>
<thead
class="tw:hidden tw:lg:table-header-group"
>
<tr
class="responsive-table__row"
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border"
/>
</thead>
<tbody
class="tw:lg:table-row-group tw:flex tw:flex-col tw:gap-y-3"
>
<tr
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border tw:hover:bg-lm-primary tw:dark:hover:bg-dm-primary tw:group-[&]/card:hover:bg-lm-secondary tw:dark:group-[&]/card:hover:bg-dm-secondary tw:relative"
>
<td
class="responsive-table__cell"
data-th="Auto-connect"
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1"
data-column="Auto-connect"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-check text-primary"
class="svg-inline--fa fa-check tw:text-brand"
data-icon="check"
data-prefix="fas"
focusable="false"
@@ -28,24 +39,25 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
/>
</svg>
</td>
<th
class="responsive-table__cell"
data-th="Name"
<td
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:font-bold"
data-column="Name"
>
<a
data-discover="true"
href="/server/abc"
>
My server
</a>
</th>
</td>
<td
class="responsive-table__cell"
data-th="Base URL"
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:max-lg:border-b-0"
data-column="Base URL"
>
https://example.com
</td>
<td
class="responsive-table__cell text-end"
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0"
>
<span>
ManageServersRowDropdown
@@ -59,33 +71,45 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 2`] = `
<div>
<table>
<tbody>
<table
class="tw:w-full"
>
<thead
class="tw:hidden tw:lg:table-header-group"
>
<tr
class="responsive-table__row"
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border"
/>
</thead>
<tbody
class="tw:lg:table-row-group tw:flex tw:flex-col tw:gap-y-3"
>
<tr
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border tw:hover:bg-lm-primary tw:dark:hover:bg-dm-primary tw:group-[&]/card:hover:bg-lm-secondary tw:dark:group-[&]/card:hover:bg-dm-secondary tw:relative"
>
<td
class="responsive-table__cell"
data-th="Auto-connect"
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1"
data-column="Auto-connect"
/>
<th
class="responsive-table__cell"
data-th="Name"
<td
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:font-bold"
data-column="Name"
>
<a
data-discover="true"
href="/server/abc"
>
My server
</a>
</th>
</td>
<td
class="responsive-table__cell"
data-th="Base URL"
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:max-lg:border-b-0"
data-column="Base URL"
>
https://example.com
</td>
<td
class="responsive-table__cell text-end"
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0"
>
<span>
ManageServersRowDropdown

View File

@@ -35,111 +35,12 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
role="menu"
style="min-width: 210px;"
tabindex="-1"
>
<a
class="dropdown-item"
href="/server/abc123"
role="menuitem"
tabindex="0"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plug fa-fw "
data-icon="plug"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 384 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M96 0C78.3 0 64 14.3 64 32l0 96 64 0 0-96c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32l0 96 64 0 0-96c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 32c0 77.4 55 142 128 156.8l0 67.2c0 17.7 14.3 32 32 32s32-14.3 32-32l0-67.2C297 398 352 333.4 352 256l0-32c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 160z"
fill="currentColor"
/>
</svg>
Connect
</a>
<a
class="dropdown-item"
href="/server/abc123/edit"
role="menuitem"
tabindex="0"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pen-to-square fa-fw "
data-icon="pen-to-square"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M471.6 21.7c-21.9-21.9-57.3-21.9-79.2 0L362.3 51.7l97.9 97.9 30.1-30.1c21.9-21.9 21.9-57.3 0-79.2L471.6 21.7zm-299.2 220c-6.1 6.1-10.8 13.6-13.5 21.9l-29.6 88.8c-2.9 8.6-.6 18.1 5.8 24.6s15.9 8.7 24.6 5.8l88.8-29.6c8.2-2.7 15.7-7.4 21.9-13.5L437.7 172.3 339.7 74.3 172.4 241.7zM96 64C43 64 0 107 0 160L0 416c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 96c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 64z"
fill="currentColor"
/>
</svg>
Edit server
</a>
<button
class="dropdown-item"
role="menuitem"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-ban fa-fw "
data-icon="ban"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M367.2 412.5L99.5 144.8C77.1 176.1 64 214.5 64 256c0 106 86 192 192 192c41.5 0 79.9-13.1 111.2-35.5zm45.3-45.3C434.9 335.9 448 297.5 448 256c0-106-86-192-192-192c-41.5 0-79.9 13.1-111.2 35.5L412.5 367.2zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"
fill="currentColor"
/>
</svg>
Do not a
uto-connect
</button>
<hr
class="dropdown-divider"
tabindex="-1"
/>
<button
class="dropdown-item--danger dropdown-item"
role="menuitem"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-circle-minus fa-fw "
data-icon="circle-minus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
fill="currentColor"
/>
</svg>
Remove server
</button>
<span>
DeleteServerModal
[CLOSED]
</span>
</div>
/>
</div>
<span>
DeleteServerModal
[CLOSED]
</span>
</div>
`;
@@ -178,110 +79,11 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
role="menu"
style="min-width: 170px;"
tabindex="-1"
>
<a
class="dropdown-item"
href="/server/abc123"
role="menuitem"
tabindex="0"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plug fa-fw "
data-icon="plug"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 384 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M96 0C78.3 0 64 14.3 64 32l0 96 64 0 0-96c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32l0 96 64 0 0-96c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 32c0 77.4 55 142 128 156.8l0 67.2c0 17.7 14.3 32 32 32s32-14.3 32-32l0-67.2C297 398 352 333.4 352 256l0-32c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 160z"
fill="currentColor"
/>
</svg>
Connect
</a>
<a
class="dropdown-item"
href="/server/abc123/edit"
role="menuitem"
tabindex="0"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pen-to-square fa-fw "
data-icon="pen-to-square"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M471.6 21.7c-21.9-21.9-57.3-21.9-79.2 0L362.3 51.7l97.9 97.9 30.1-30.1c21.9-21.9 21.9-57.3 0-79.2L471.6 21.7zm-299.2 220c-6.1 6.1-10.8 13.6-13.5 21.9l-29.6 88.8c-2.9 8.6-.6 18.1 5.8 24.6s15.9 8.7 24.6 5.8l88.8-29.6c8.2-2.7 15.7-7.4 21.9-13.5L437.7 172.3 339.7 74.3 172.4 241.7zM96 64C43 64 0 107 0 160L0 416c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 96c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 64z"
fill="currentColor"
/>
</svg>
Edit server
</a>
<button
class="dropdown-item"
role="menuitem"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-circle fa-fw "
data-icon="circle"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M464 256A208 208 0 1 0 48 256a208 208 0 1 0 416 0zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"
fill="currentColor"
/>
</svg>
A
uto-connect
</button>
<hr
class="dropdown-divider"
tabindex="-1"
/>
<button
class="dropdown-item--danger dropdown-item"
role="menuitem"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-circle-minus fa-fw "
data-icon="circle-minus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
fill="currentColor"
/>
</svg>
Remove server
</button>
<span>
DeleteServerModal
[CLOSED]
</span>
</div>
/>
</div>
<span>
DeleteServerModal
[CLOSED]
</span>
</div>
`;

View File

@@ -6,10 +6,10 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DuplicatedServersModal />', () => {
const onDiscard = vi.fn();
const onSave = vi.fn();
const onClose = vi.fn();
const onConfirm = vi.fn();
const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents(
<DuplicatedServersModal isOpen duplicatedServers={duplicatedServers} onDiscard={onDiscard} onSave={onSave} />,
<DuplicatedServersModal open duplicatedServers={duplicatedServers} onClose={onClose} onConfirm={onConfirm} />,
));
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
@@ -32,8 +32,9 @@ describe('<DuplicatedServersModal />', () => {
{
header: 'Duplicated server',
firstParagraph: 'There is already a server with:',
lastParagraph: 'Do you want to save this server anyway?',
lastParagraph: 'Do you want to save this server?',
discardBtn: 'Discard',
confirmButton: 'Save duplicate',
},
],
[
@@ -41,8 +42,9 @@ describe('<DuplicatedServersModal />', () => {
{
header: 'Duplicated servers',
firstParagraph: 'The next servers already exist:',
lastParagraph: 'Do you want to ignore duplicated servers?',
lastParagraph: 'Do you want to save duplicated servers?',
discardBtn: 'Ignore duplicates',
confirmButton: 'Save duplicates',
},
],
])('renders expected texts based on amount of servers', async (duplicatedServers, assertions) => {
@@ -52,6 +54,7 @@ describe('<DuplicatedServersModal />', () => {
expect(screen.getByText(assertions.firstParagraph)).toBeInTheDocument();
expect(screen.getByText(assertions.lastParagraph)).toBeInTheDocument();
expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument();
expect(screen.getByRole('button', { name: assertions.confirmButton })).toBeInTheDocument();
});
it.each([
@@ -80,19 +83,19 @@ describe('<DuplicatedServersModal />', () => {
}
});
it('invokes onDiscard when appropriate button is clicked', async () => {
it('invokes onClose when appropriate button is clicked', async () => {
const { user } = await setUp();
expect(onDiscard).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Discard' }));
expect(onDiscard).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('invokes onSave when appropriate button is clicked', async () => {
it('invokes onConfirm when appropriate button is clicked', async () => {
const { user } = await setUp();
expect(onSave).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Save anyway' }));
expect(onSave).toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Save duplicate' }));
expect(onConfirm).toHaveBeenCalled();
});
});

View File

@@ -65,9 +65,9 @@ describe('<ImportServersBtn />', () => {
});
it.each([
{ btnName: 'Save anyway',savesDuplicatedServers: true },
{ btnName: 'Save duplicate', savesDuplicatedServers: true },
{ btnName: 'Discard', savesDuplicatedServers: false },
])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
])('creates duplicated servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
const existingServerData: ServerData = {
name: 'existingServer',
url: 'http://s.test/existingUrl',
@@ -84,14 +84,20 @@ describe('<ImportServersBtn />', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
// Once the file is uploaded, non-duplicated servers are immediately created
expect(createServersMock).toHaveBeenCalledExactlyOnceWith([expect.objectContaining(newServer)]);
expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: btnName }));
expect(createServersMock).toHaveBeenCalledWith(
savesDuplicatedServers
? [expect.objectContaining(existingServerData), expect.objectContaining(newServer)]
: [expect.objectContaining(newServer)],
);
expect(onImportMock).toHaveBeenCalledTimes(1);
// If duplicated servers are saved, there's one extra call
if (savesDuplicatedServers) {
expect(createServersMock).toHaveBeenLastCalledWith([expect.objectContaining(existingServerData)]);
}
// On import is called only once, no matter what
expect(onImportMock).toHaveBeenCalledOnce();
expect(createServersMock).toHaveBeenCalledTimes(savesDuplicatedServers ? 2 : 1);
});
});

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data';
import { ServerErrorFactory } from '../../../src/servers/helpers/ServerError';
import { checkAccessibility } from '../../__helpers__/accessibility';
@@ -26,7 +26,7 @@ describe('<ServerError />', () => {
notFound: [
'Oops! Could not connect to this Shlink server.',
'Make sure you have internet connection, and the server is properly configured and on-line.',
/^Alternatively, if you think you may have miss-configured this server/,
/^Alternatively, if you think you may have misconfigured this server/,
],
},
],
@@ -36,7 +36,7 @@ describe('<ServerError />', () => {
found: [
'Oops! Could not connect to this Shlink server.',
'Make sure you have internet connection, and the server is properly configured and on-line.',
/^Alternatively, if you think you may have miss-configured this server/,
/^Alternatively, if you think you may have misconfigured this server/,
],
notFound: ['Could not find this Shlink server.'],
},

View File

@@ -1,18 +1,22 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, screen } from '@testing-library/react';
import { ServerForm } from '../../../src/servers/helpers/ServerForm';
import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<ServerForm />', () => {
const onSubmit = vi.fn();
const setUp = () => render(<ServerForm onSubmit={onSubmit}>Something</ServerForm>);
const setUp = () => renderWithEvents(<ServerForm onSubmit={onSubmit}>Something</ServerForm>);
it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders components', () => {
it('renders inputs', () => {
setUp();
expect(screen.getAllByRole('textbox')).toHaveLength(3);
expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
expect(screen.getByLabelText(/^URL/)).toBeInTheDocument();
expect(screen.getByLabelText(/^API key/)).toBeInTheDocument();
expect(screen.getByText('Something')).toBeInTheDocument();
expect(screen.getByText('Advanced options')).toBeInTheDocument();
});
it('invokes submit callback when submit event is triggered', async () => {
@@ -22,4 +26,13 @@ describe('<ServerForm />', () => {
fireEvent.submit(screen.getByRole('form'), { preventDefault: vi.fn() });
expect(onSubmit).toHaveBeenCalled();
});
it('shows advanced options', async () => {
const { user } = setUp();
const forwardCredentialsLabel = 'Forward credentials to this server on every request.';
expect(screen.queryByLabelText(forwardCredentialsLabel)).not.toBeInTheDocument();
await user.click(screen.getByText('Advanced options'));
expect(screen.getByLabelText(forwardCredentialsLabel)).toBeInTheDocument();
});
});

View File

@@ -1,45 +1,49 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ServersMap } from '../../../src/servers/data';
import { serializeServer } from '../../../src/servers/data';
import { ServersExporter } from '../../../src/servers/services/ServersExporter';
import type { LocalStorage } from '../../../src/utils/services/LocalStorage';
import { appendChild, removeChild, windowMock } from '../../__mocks__/Window.mock';
describe('ServersExporter', () => {
const servers: ServersMap = {
abc123: {
id: 'abc123',
name: 'foo',
url: 'https://foo.com',
apiKey: 'foo_api_key',
autoConnect: true,
},
def456: {
id: 'def456',
name: 'bar',
url: 'https://bar.com',
apiKey: 'bar_api_key',
forwardCredentials: true,
autoConnect: false,
},
};
const storageMock = fromPartial<LocalStorage>({
get: vi.fn(() => ({
abc123: {
id: 'abc123',
name: 'foo',
autoConnect: true,
},
def456: {
id: 'def456',
name: 'bar',
autoConnect: false,
},
} as any)),
get: vi.fn(() => servers as any),
});
const erroneousToCsv = vi.fn(() => {
throw new Error('');
});
const createCsvjsonMock = (throwError = false) => (throwError ? erroneousToCsv : vi.fn(() => ''));
const createJsonToCsvMock = (throwError = false) => (throwError ? erroneousToCsv : vi.fn(() => ''));
describe('exportServers', () => {
let originalConsole: Console;
const error = vi.fn();
beforeEach(() => {
originalConsole = global.console;
global.console = fromPartial<Console>({ error });
(global as any).Blob = class Blob {};
(global as any).URL = { createObjectURL: () => '' };
vi.stubGlobal('console', fromPartial<Console>({ error }));
});
afterEach(() => {
global.console = originalConsole;
vi.unstubAllGlobals();
});
it('logs an error if something fails', () => {
const csvjsonMock = createCsvjsonMock(true);
const exporter = new ServersExporter(storageMock, windowMock, csvjsonMock);
const jsonToCsvMock = createJsonToCsvMock(true);
const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock);
exporter.exportServers();
@@ -48,7 +52,8 @@ describe('ServersExporter', () => {
});
it('makes use of download link API', () => {
const exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock());
const jsonToCsvMock = createJsonToCsvMock();
const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock);
const { document: { createElement } } = windowMock;
exporter.exportServers();
@@ -57,6 +62,7 @@ describe('ServersExporter', () => {
expect(createElement).toHaveBeenCalledTimes(1);
expect(appendChild).toHaveBeenCalledTimes(1);
expect(removeChild).toHaveBeenCalledTimes(1);
expect(jsonToCsvMock).toHaveBeenCalledWith(Object.values(servers).map(serializeServer));
});
});
});

View File

@@ -1,5 +1,5 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { RegularServer } from '../../../src/servers/data';
import type { RegularServer, ServerData } from '../../../src/servers/data';
import { ServersImporter } from '../../../src/servers/services/ServersImporter';
describe('ServersImporter', () => {
@@ -25,20 +25,24 @@ describe('ServersImporter', () => {
});
it.each([
[{}],
[undefined],
[[{ foo: 'bar' }]],
[
[
{ parsedObject: {}, expectedError: 'Provided file does not have the right format.' },
{ parsedObject: undefined, expectedError: 'Provided file does not have the right format.' },
{
parsedObject: [{ foo: 'bar' }],
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
},
{
parsedObject: [
{
url: 1,
apiKey: 1,
name: 1,
},
],
],
[
[
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
},
{
parsedObject: [
{
url: 'foo',
apiKey: 'foo',
@@ -46,26 +50,29 @@ describe('ServersImporter', () => {
},
{ bar: 'foo' },
],
],
])('rejects with error if provided file does not parse to valid list of servers', async (parsedObject) => {
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
},
])('rejects with error if provided file does not parse to valid list of servers', async ({
parsedObject,
expectedError,
}) => {
csvjsonMock.mockResolvedValue(parsedObject);
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(
new Error('Provided file does not have the right format.'),
);
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(new Error(expectedError));
});
it('reads file when a CSV containing valid servers is provided', async () => {
const expectedServers = [
const expectedServers: Required<ServerData>[] = [
{
url: 'foo',
apiKey: 'foo',
name: 'foo',
forwardCredentials: false,
},
{
url: 'bar',
apiKey: 'bar',
name: 'bar',
forwardCredentials: false,
},
];

View File

@@ -1,5 +1,5 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
import { Settings } from '../../src/settings/Settings';
import { checkAccessibility } from '../__helpers__/accessibility';

View File

@@ -1,28 +0,0 @@
import { screen, waitFor } from '@testing-library/react';
import type { DateInterval } from '../../../src/utils/dates/DateIntervalSelector';
import { DateIntervalSelector, INTERVAL_TO_STRING_MAP } from '../../../src/utils/dates/DateIntervalSelector';
import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DateIntervalSelector />', () => {
const activeInterval: DateInterval = 'last7Days';
const onChange = vi.fn();
const setUp = () => renderWithEvents(
<DateIntervalSelector allText="All text" active={activeInterval} onChange={onChange} />,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it('passes props down to nested DateIntervalDropdownItems', async () => {
const { user } = setUp();
const btn = screen.getByRole('button');
await user.click(btn);
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
const items = screen.getAllByRole('menuitem');
expect(btn).toHaveTextContent(INTERVAL_TO_STRING_MAP[activeInterval] ?? '');
expect(items).toHaveLength(8);
});
});

View File

@@ -1,14 +1,18 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { VitePWA } from 'vite-plugin-pwa';
import { defineConfig } from 'vitest/config';
import { manifest } from './manifest';
import pack from './package.json';
import pack from './package.json' with { type: 'json' };
const DEFAULT_NODE_VERSION = 'v22.10.0';
const nodeVersion = process.version ?? DEFAULT_NODE_VERSION;
const homepage = pack.homepage?.trim();
/* eslint-disable-next-line no-restricted-exports */
export default defineConfig({
plugins: [react(), VitePWA({
plugins: [react(), tailwindcss(), VitePWA({
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
strategies: 'injectManifest',
srcDir: './src',
@@ -22,14 +26,24 @@ export default defineConfig({
},
server: {
port: 3000,
watch: {
// Do not watch test files or generated files, avoiding the dev server to constantly reload when not needed
ignored: ['**/.idea/**', '**/.git/**', '**/build/**', '**/coverage/**', '**/test/**'],
},
},
base: !homepage ? undefined : homepage, // Not using just homepage because empty string should be discarded
// Vitest config
test: {
// Run tests in an actual browser
browser: {
provider: 'playwright',
enabled: true,
headless: true,
screenshotFailures: false,
instances: [{ browser: 'chromium' }],
},
globals: true,
allowOnly: true,
environment: 'jsdom',
setupFiles: './config/test/setupTests.ts',
coverage: {
provider: 'v8',
@@ -46,10 +60,23 @@ export default defineConfig({
// Required code coverage. Lower than this will make the check fail
thresholds: {
statements: 95,
branches: 90,
functions: 90,
branches: 95,
functions: 95,
lines: 95,
},
},
// Silent warnings triggered by reactstrap components, as it's getting removed
onConsoleLog: (log) => !log.includes('`transition.timeout` is marked as required'),
// Workaround for bug in react-router (or vitest module resolution) which causes different react-router versions to
// be resolved for the main package and dependencies who have a peer dependency in react-router.
// This ensures always the same version is resolved.
// See https://github.com/remix-run/react-router/issues/12785 for details
alias: nodeVersion > DEFAULT_NODE_VERSION
? {
'react-router': resolve(__dirname, 'node_modules/react-router/dist/development/index.mjs'),
}
: undefined,
},
});