Merge pull request #1324 from shlinkio/develop

Release 4.2.0
This commit is contained in:
Alejandro Celaya 2024-10-07 09:54:43 +02:00 committed by GitHub
commit 9e1a803b8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 6869 additions and 6436 deletions

View file

@ -1,10 +0,0 @@
{
"root": true,
"extends": [
"@shlinkio/js-coding-standard"
],
"parserOptions": {
"project": "./tsconfig.json"
},
"ignorePatterns": ["src/service*.ts"]
}

View file

@ -12,11 +12,11 @@ updates:
fontawesome:
patterns:
- '@fortawesome/*'
eslint:
eslint-plugins: # TODO Add eslint back once updated to v9
patterns:
- '@shlinkio/eslint-config-js-coding-standard'
- '@typescript-eslint/*'
- 'eslint'
- 'typescript-eslint'
- '*eslint-plugin*'
shlink:
patterns:
- '@shlinkio/*'
@ -34,6 +34,9 @@ updates:
patterns:
- 'vitest'
- '@vitest/*'
workbox:
patterns:
- 'workbox*'
ignore:
# Bootstrap can introduce visual breaking changes on styles
# Ignore it, since the plan is to remove it anyway

View file

@ -7,7 +7,7 @@ on:
jobs:
build:
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main
secrets: inherit
with:
image-name: shlinkio/shlink-web-client

View file

@ -4,12 +4,36 @@ 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.2.0] - 2024-10-07
### Added
* [shlink-web-component#411](https://github.com/shlinkio/shlink-web-component/issues/411) Add support for `ip-address` redirect conditions when Shlink server is >=4.2
* [shlink-web-component#196](https://github.com/shlinkio/shlink-web-component/issues/196) Allow active date range to be changed by selecting a range in visits and visits-comparison line charts.
* [shlink-web-component#307](https://github.com/shlinkio/shlink-web-component/issues/307) Add new setting to disable short URL deletions confirmation.
* [shlink-web-component#435](https://github.com/shlinkio/shlink-web-component/issues/435) Allow toggling between displaying raw user agent and parsed browser/OS in visits table.
* [shlink-web-component#197](https://github.com/shlinkio/shlink-web-component/issues/197) Allow line charts to be expanded to the full size of the viewport, both in individual visits views, and when comparing visits.
* [shlink-web-component#382](https://github.com/shlinkio/shlink-web-component/issues/382) Initialize QR code modal with all params unset, so that they fall back to the server defaults. Additionally, allow them to be unset if desired.
### Changed
* Use `ShlinkWebSettings` from `@shlinkio/shlink-web-component` to replace local settings UI.
* Update to `@shlinkio/eslint-config-js-coding-standard` 3.0, and migrate to ESLint flat config.
* Remove dependency on `uuid` package, and use `crypto.randomUUID()` instead.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.1.2] - 2024-04-17
### Added
* *Nothing*
### Changed
* *Nothing*
* Use new reusable workflow to publish docker image
### Deprecated
* *Nothing*

View file

@ -1,10 +1,10 @@
FROM node:21.7-alpine as node
FROM node:22.9-alpine as node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && npm ci && npm run build
FROM nginxinc/nginx-unprivileged:1.25-alpine
FROM nginxinc/nginx-unprivileged:1.27-alpine
ARG UID=101
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

View file

@ -31,7 +31,7 @@ The application runs 100% in the browser, so you can safely access any shlink in
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the [shlinkio/shlink-web-client](https://hub.docker.com/r/shlinkio/shlink-web-client/) image and do it.
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 8080.
### Self-hosted

View file

@ -1,5 +1,3 @@
version: '3'
services:
shlink_web_client_node:
user: 1000:1000

View file

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

4
eslint.config.js Normal file
View file

@ -0,0 +1,4 @@
import shlink from '@shlinkio/eslint-config-js-coding-standard';
/* eslint-disable-next-line no-restricted-exports */
export default shlink;

11856
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@
"scripts": {
"lint": "npm run lint:css && npm run lint:js",
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:js": "eslint --ext .js,.ts,.tsx src test",
"lint:js": "eslint src test config/test",
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix",
@ -24,64 +24,67 @@
"test:verbose": "npm run test -- --verbose"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@fortawesome/fontawesome-free": "^6.6.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.2.3",
"@reduxjs/toolkit": "^2.2.7",
"@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.5.1",
"@shlinkio/shlink-js-sdk": "^1.1.0",
"@shlinkio/shlink-web-component": "^0.6.2",
"@shlinkio/shlink-frontend-kit": "^0.5.2",
"@shlinkio/shlink-js-sdk": "^1.2.0",
"@shlinkio/shlink-web-component": "^0.8.0",
"bootstrap": "5.2.3",
"bottlejs": "^2.0.1",
"clsx": "^2.1.0",
"compare-versions": "^6.1.0",
"clsx": "^2.1.1",
"compare-versions": "^6.1.1",
"csvtojson": "^2.0.10",
"date-fns": "^3.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"date-fns": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-external-link": "^2.3.1",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.3",
"reactstrap": "^9.2.2",
"react-redux": "^9.1.2",
"react-router-dom": "^6.26.2",
"reactstrap": "^9.2.3",
"redux-localstorage-simple": "^2.5.1",
"uuid": "^9.0.1",
"workbox-core": "^7.0.0",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0"
"workbox-core": "^7.1.0",
"workbox-expiration": "^7.1.0",
"workbox-precaching": "^7.1.0",
"workbox-routing": "^7.1.0",
"workbox-strategies": "^7.1.0"
},
"devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~2.4.1",
"@shlinkio/eslint-config-js-coding-standard": "~3.1.0",
"@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^15.0.1",
"@stylistic/eslint-plugin": "^2.9.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@total-typescript/shoehorn": "^0.1.2",
"@types/react": "^18.2.77",
"@types/react-dom": "^18.2.25",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.5.0",
"adm-zip": "^0.5.12",
"axe-core": "^4.9.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^2.1.2",
"adm-zip": "^0.5.16",
"axe-core": "^4.10.0",
"chalk": "^5.3.0",
"eslint": "^8.57.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-simple-import-sort": "^12.1.1",
"history": "^5.3.0",
"jsdom": "^24.0.0",
"sass": "^1.75.0",
"jsdom": "^25.0.1",
"sass": "^1.79.4",
"stylelint": "^15.11.0",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vite-plugin-pwa": "^0.19.8",
"vitest": "^1.2.2"
"typescript": "^5.6.2",
"typescript-eslint": "^8.8.0",
"vite": "^5.4.8",
"vite-plugin-pwa": "^0.20.5",
"vitest": "^2.0.2"
},
"browserslist": [
">0.2%",

View file

@ -1,4 +1,5 @@
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect, useRef } from 'react';
@ -8,14 +9,13 @@ import { NotFound } from '../common/NotFound';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from '../servers/data';
import type { AppSettings } from '../settings/reducers/settings';
import { forceUpdate } from '../utils/helpers/sw';
import './App.scss';
type AppProps = {
fetchServers: () => void;
servers: ServersMap;
settings: AppSettings;
settings: Settings;
resetAppUpdate: () => void;
appUpdated: boolean;
};

View file

@ -9,7 +9,7 @@ import './AppUpdateBanner.scss';
interface AppUpdateBannerProps {
isOpen: boolean;
toggle: MouseEventHandler<any>;
forceUpdate: Function;
forceUpdate: () => void;
}
export const AppUpdateBanner = forwardRef<HTMLElement, AppUpdateBannerProps>(({ isOpen, toggle, forceUpdate }, ref) => {

View file

@ -21,7 +21,9 @@ export const Home = ({ servers }: HomeProps) => {
useEffect(() => {
// Try to redirect to the first server marked as auto-connect
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
if (autoConnectServer) {
navigate(`/server/${autoConnectServer.id}`);
}
}, [serversList, navigate]);
return (

View file

@ -15,7 +15,7 @@ type MainHeaderDeps = {
ServersDropdown: FC;
};
const MainHeader: FCWithDeps<{}, MainHeaderDeps> = () => {
const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
const { ServersDropdown } = useDependencies(MainHeader);
const [isNotCollapsed, toggleCollapse, , collapse] = useToggle();
const location = useLocation();

View file

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

View file

@ -1,4 +1,5 @@
import type { Settings, ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
import type { ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { memo } from 'react';
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
@ -21,8 +22,8 @@ type ShlinkWebComponentContainerDeps = {
};
const ShlinkWebComponentContainer: FCWithDeps<
ShlinkWebComponentContainerProps,
ShlinkWebComponentContainerDeps
ShlinkWebComponentContainerProps,
ShlinkWebComponentContainerDeps
// FIXME Using `memo` here to solve a flickering effect in charts.
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
// extra rendering there.

View file

@ -9,13 +9,13 @@ import { provideServices as provideSettingsServices } from '../settings/services
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
import type { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>;
type LazyActionMap = Record<string, (...args: unknown[]) => unknown>;
const bottle = new Bottle();
export const { container } = bottle;
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
const lazyService = <T extends (...args: unknown[]) => unknown, K>(cont: IContainer, serviceName: string) =>
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({

View file

@ -1,4 +1,4 @@
import type { Settings } from '@shlinkio/shlink-web-component';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import type { SelectedServer, ServersMap } from '../servers/data';
export interface ShlinkState {

View file

@ -4,7 +4,6 @@ import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from 'reactstrap';
import { v4 as uuid } from 'uuid';
import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
@ -45,7 +44,7 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [serverData, setServerData] = useState<ServerData>();
const saveNewServer = useCallback((theServerData: ServerData) => {
const id = uuid();
const id = crypto.randomUUID();
createServers([{ ...theServerData, id }]);
navigate(`/server/${id}`);
@ -60,7 +59,11 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
);
serverExists ? toggleConfirmModal() : saveNewServer(serverData);
if (serverExists) {
toggleConfirmModal();
} else {
saveNewServer(serverData);
}
}, [saveNewServer, serverData, servers, toggleConfirmModal]);
return (

View file

@ -30,7 +30,9 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
}
deleteServer(server);
redirectHome && navigate('/');
if (redirectHome) {
navigate('/');
}
};
return (

View file

@ -1,9 +1,10 @@
import { useParsedQuery } from '@shlinkio/shlink-frontend-kit';
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';
import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
import { useGoBack } from '../utils/helpers/hooks';
import type { ServerData } from './data';
import { isServerWithId } from './data';
import { ServerForm } from './helpers/ServerForm';
@ -30,7 +31,9 @@ const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServ
const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData);
reconnect === 'true' && selectServer(selectedServer.id);
if (reconnect === 'true') {
selectServer(selectedServer.id);
}
goBack();
};

View file

@ -44,4 +44,4 @@ export const isNotFoundServer = (server: SelectedServer): server is NotFoundServ
export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
export const serverWithIdToServerData = ({ id, autoConnect, ...server }: ServerWithId): ServerData => server;
export const serverWithIdToServerData = ({ name, url, apiKey }: ServerWithId): ServerData => ({ name, url, apiKey });

View file

@ -58,8 +58,12 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
const dupServers = newServers.filter((server) => serversInclude(existingServers, server));
const hasDuplicatedServers = !!dupServers.length;
!hasDuplicatedServers ? create(newServers) : setDuplicatedServers(dupServers);
hasDuplicatedServers && showModal();
if (!hasDuplicatedServers) {
create(newServers);
} else {
setDuplicatedServers(dupServers);
showModal();
}
})
.then(() => {
// Reset input after processing file

View file

@ -17,9 +17,11 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
useEffect(() => {
initialValues && setName(initialValues.name);
initialValues && setUrl(initialValues.url);
initialValues && setApiKey(initialValues.apiKey);
if (initialValues) {
setName(initialValues.name);
setUrl(initialValues.url);
setApiKey(initialValues.apiKey);
}
}, [initialValues]);
return (

View file

@ -17,7 +17,7 @@ type WithSelectedServerPropsDeps = {
ServerError: FC;
};
export function withSelectedServer<T = {}>(
export function withSelectedServer<T extends object>(
WrappedComponent: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps>,
) {
const ComponentWrapper: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps> = (props) => {
@ -26,7 +26,9 @@ export function withSelectedServer<T = {}>(
const { selectServer, selectedServer } = props;
useEffect(() => {
params.serverId && selectServer(params.serverId);
if (params.serverId) {
selectServer(params.serverId);
}
}, [params.serverId, selectServer]);
if (!selectedServer) {

View file

@ -2,10 +2,10 @@ import type { FC } from 'react';
import { useEffect } from 'react';
interface WithoutSelectedServerProps {
resetSelectedServer: Function;
resetSelectedServer: () => unknown;
}
export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
export function withoutSelectedServer<T extends object>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
return (props: WithoutSelectedServerProps & T) => {
const { resetSelectedServer } = props;
useEffect(() => {

View file

@ -50,7 +50,7 @@ export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => cr
version,
printableVersion,
};
} catch (e) {
} catch {
return { ...selectedServer, serverNotReachable: true };
}
},

View file

@ -1,6 +1,5 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { v4 as uuid } from 'uuid';
import type { ServerData, ServersMap, ServerWithId } from '../data';
interface EditServer {
@ -20,7 +19,7 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
return server;
}
return { ...server, id: uuid() };
return { ...server, id: crypto.randomUUID() };
};
const serversListToMap = (servers: ServerWithId[]): ServersMap => servers.reduce<ServersMap>(
@ -44,8 +43,8 @@ export const { actions, reducer } = createSlice({
},
},
deleteServer: (state, { payload }) => {
const { [payload.id]: deletedServer, ...rest } = state;
return rest;
delete state[payload.id];
return state;
},
setAutoConnect: {
prepare: ({ id: serverId }: ServerWithId, autoConnect: boolean) => ({

View file

@ -9,7 +9,7 @@
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
import pack from '../package.json';
@ -50,7 +50,7 @@ registerRoute(
// Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(`${pack.homepage}/index.html`)
createHandlerBoundToURL(`${pack.homepage}/index.html`),
);
// An example runtime caching route for requests that aren't handled by the
@ -66,7 +66,7 @@ registerRoute(
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
}),
);
// This allows the web app to trigger skipWaiting via

View file

@ -9,14 +9,14 @@
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
import pack from'../package.json';
import pack from '../package.json';
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/),
);
type Config = {
@ -47,7 +47,7 @@ export function register(config?: Config) {
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://cra.link/PWA'
'worker. To learn more, visit https://cra.link/PWA',
);
});
} else {
@ -75,7 +75,7 @@ function registerValidSW(swUrl: string, config?: Config) {
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://cra.link/PWA.'
'tabs for this page are closed. See https://cra.link/PWA.',
);
// Execute callback

View file

@ -1,60 +0,0 @@
import { LabeledFormGroup, SimpleCard, ToggleSwitch, useDomId } from '@shlinkio/shlink-frontend-kit';
import type { Settings } from '@shlinkio/shlink-web-component';
import { clsx } from 'clsx';
import { FormGroup, Input } from 'reactstrap';
import { FormText } from '../utils/forms/FormText';
type RealTimeUpdatesProps = {
settings: Settings;
toggleRealTimeUpdates: (enabled: boolean) => void;
setRealTimeUpdatesInterval: (interval: number) => void;
};
const intervalValue = (interval?: number) => (!interval ? '' : `${interval}`);
export const RealTimeUpdatesSettings = (
{ settings, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
) => {
const { realTimeUpdates = { enabled: true } } = settings;
const inputId = useDomId();
return (
<SimpleCard title="Real-time updates" className="h-100">
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates.
<FormText>
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup
noMargin
label="Real-time updates frequency (in minutes):"
labelClassName={clsx('form-label', { 'text-muted': !realTimeUpdates.enabled })}
id={inputId}
>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
id={inputId}
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
/>
{realTimeUpdates.enabled && (
<FormText>
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI
every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</FormText>
)}
</LabeledFormGroup>
</SimpleCard>
);
};

View file

@ -1,58 +1,20 @@
import { NavPillItem, NavPills } from '@shlinkio/shlink-frontend-kit';
import type { FC, ReactNode } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import type { Settings as AppSettings } from '@shlinkio/shlink-web-component/settings';
import { ShlinkWebSettings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { DEFAULT_SHORT_URLS_ORDERING } from './reducers/settings';
type SettingsDeps = {
RealTimeUpdatesSettings: FC;
ShortUrlCreationSettings: FC;
ShortUrlsListSettings: FC;
UserInterfaceSettings: FC;
VisitsSettings: FC;
TagsSettings: FC;
export type SettingsProps = {
settings: AppSettings;
setSettings: (newSettings: AppSettings) => void;
};
const SettingsSections: FC<{ items: ReactNode[] }> = ({ items }) => (
<>
{items.map((child, index) => <div key={index} className="mb-3">{child}</div>)}
</>
);
const Settings: FCWithDeps<{}, SettingsDeps> = () => {
const {
RealTimeUpdatesSettings: RealTimeUpdates,
ShortUrlCreationSettings: ShortUrlCreation,
ShortUrlsListSettings: ShortUrlsList,
UserInterfaceSettings: UserInterface,
VisitsSettings: Visits,
TagsSettings: Tags,
} = useDependencies(Settings);
return (
export const Settings: FC<SettingsProps> = ({ settings, setSettings }) => (
<NoMenuLayout>
<NavPills className="mb-3">
<NavPillItem to="general">General</NavPillItem>
<NavPillItem to="short-urls">Short URLs</NavPillItem>
<NavPillItem to="other-items">Other items</NavPillItem>
</NavPills>
<Routes>
<Route path="general" element={<SettingsSections items={[<UserInterface />, <RealTimeUpdates />]} />} />
<Route path="short-urls" element={<SettingsSections items={[<ShortUrlCreation />, <ShortUrlsList />]} />} />
<Route path="other-items" element={<SettingsSections items={[<Tags />, <Visits />]} />} />
<Route path="*" element={<Navigate replace to="general" />} />
</Routes>
<ShlinkWebSettings
settings={settings}
updateSettings={setSettings}
defaultShortUrlsListOrdering={DEFAULT_SHORT_URLS_ORDERING}
/>
</NoMenuLayout>
);
};
export const SettingsFactory = componentFactory(Settings, [
'RealTimeUpdatesSettings',
'ShortUrlCreationSettings',
'ShortUrlsListSettings',
'UserInterfaceSettings',
'VisitsSettings',
'TagsSettings',
]);
);

View file

@ -1,75 +0,0 @@
import { DropdownBtn, LabeledFormGroup, SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit';
import type { Settings, ShortUrlCreationSettings as ShortUrlsSettings } from '@shlinkio/shlink-web-component';
import type { FC, ReactNode } from 'react';
import { DropdownItem, FormGroup } from 'reactstrap';
import { FormText } from '../utils/forms/FormText';
import type { Defined } from '../utils/types';
type TagFilteringMode = Defined<ShortUrlsSettings['tagFilteringMode']>;
interface ShortUrlCreationProps {
settings: Settings;
setShortUrlCreationSettings: (settings: ShortUrlsSettings) => void;
}
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
(tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input');
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode => (
tagFilteringMode === 'includes'
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>
);
export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
);
return (
<SimpleCard title="Short URLs form" className="h-100">
<FormGroup>
<ToggleSwitch
checked={shortUrlCreation.validateUrls ?? false}
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
>
Request validation on long URLs when creating new short URLs.{' '}
<b>This option is ignored by Shlink {'>='}4.0.0</b>
<FormText>
The initial state of the <b>Validate URL</b> checkbox will
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup>
<ToggleSwitch
checked={shortUrlCreation.forwardQuery ?? true}
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
>
Make all new short URLs forward their query params to the long URL.
<FormText>
The initial state of the <b>Forward query params on redirect</b> checkbox will
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup noMargin label="Tag suggestions search mode:">
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
<DropdownItem
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
onClick={changeTagsFilteringMode('startsWith')}
>
{tagFilteringModeText('startsWith')}
</DropdownItem>
<DropdownItem
active={shortUrlCreation.tagFilteringMode === 'includes'}
onClick={changeTagsFilteringMode('includes')}
>
{tagFilteringModeText('includes')}
</DropdownItem>
</DropdownBtn>
<FormText>{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}</FormText>
</LabeledFormGroup>
</SimpleCard>
);
};

View file

@ -1,31 +0,0 @@
import { LabeledFormGroup, OrderingDropdown, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { Settings, ShortUrlsListSettings as ShortUrlsSettings } from '@shlinkio/shlink-web-component';
import type { FC } from 'react';
import { DEFAULT_SHORT_URLS_ORDERING } from './reducers/settings';
interface ShortUrlsListSettingsProps {
settings: Settings;
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
}
const SHORT_URLS_ORDERABLE_FIELDS = {
dateCreated: 'Created at',
shortCode: 'Short URL',
longUrl: 'Long URL',
title: 'Title',
visits: 'Visits',
};
export const ShortUrlsListSettings: FC<ShortUrlsListSettingsProps> = (
{ settings: { shortUrlsList }, setShortUrlsListSettings },
) => (
<SimpleCard title="Short URLs list" className="h-100">
<LabeledFormGroup noMargin label="Default ordering for short URLs list:">
<OrderingDropdown
items={SHORT_URLS_ORDERABLE_FIELDS}
order={shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING}
onChange={(field, dir) => setShortUrlsListSettings({ defaultOrdering: { field, dir } })}
/>
</LabeledFormGroup>
</SimpleCard>
);

View file

@ -1,29 +0,0 @@
import { LabeledFormGroup, OrderingDropdown, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { Settings, TagsSettings as TagsSettingsOptions } from '@shlinkio/shlink-web-component';
import type { FC } from 'react';
import type { Defined } from '../utils/types';
export type TagsOrder = Defined<TagsSettingsOptions['defaultOrdering']>;
interface TagsProps {
settings: Settings;
setTagsSettings: (settings: TagsSettingsOptions) => void;
}
const TAGS_ORDERABLE_FIELDS = {
tag: 'Tag',
shortUrls: 'Short URLs',
visits: 'Visits',
};
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
<SimpleCard title="Tags" className="h-100">
<LabeledFormGroup noMargin label="Default ordering for tags list:">
<OrderingDropdown
items={TAGS_ORDERABLE_FIELDS}
order={tags?.defaultOrdering ?? {}}
onChange={(field, dir) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })}
/>
</LabeledFormGroup>
</SimpleCard>
);

View file

@ -1,4 +0,0 @@
.user-interface__theme-icon {
float: right;
margin-top: .25rem;
}

View file

@ -1,34 +0,0 @@
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { Theme } from '@shlinkio/shlink-frontend-kit';
import { getSystemPreferredTheme, SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useMemo } from 'react';
import type { AppSettings, UiSettings } from './reducers/settings';
import './UserInterfaceSettings.scss';
interface UserInterfaceProps {
settings: AppSettings;
setUiSettings: (settings: UiSettings) => void;
/* Test seam */
_matchMedia?: typeof window.matchMedia;
}
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings, _matchMedia }) => {
const currentTheme = useMemo(() => ui?.theme ?? getSystemPreferredTheme(_matchMedia), [ui?.theme, _matchMedia]);
return (
<SimpleCard title="User interface" className="h-100">
<FontAwesomeIcon icon={currentTheme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
<ToggleSwitch
checked={currentTheme === 'dark'}
onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
setUiSettings({ ...ui, theme });
}}
>
Use dark theme.
</ToggleSwitch>
</SimpleCard>
);
};

View file

@ -1,60 +0,0 @@
import { LabeledFormGroup, SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit';
import type { Settings, VisitsSettings as VisitsSettingsConfig } from '@shlinkio/shlink-web-component';
import type { FC } from 'react';
import { useCallback } from 'react';
import { FormGroup } from 'reactstrap';
import type { DateInterval } from '../utils/dates/DateIntervalSelector';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { FormText } from '../utils/forms/FormText';
type VisitsProps = {
settings: Settings;
setVisitsSettings: (settings: VisitsSettingsConfig) => void;
};
const currentDefaultInterval = (settings: Settings): DateInterval => settings.visits?.defaultInterval ?? 'last30Days';
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => {
const updateSettings = useCallback(
({ defaultInterval, ...rest }: Partial<VisitsSettingsConfig>) => setVisitsSettings(
{ defaultInterval: defaultInterval ?? currentDefaultInterval(settings), ...rest },
),
[setVisitsSettings, settings],
);
return (
<SimpleCard title="Visits" className="h-100">
<FormGroup>
<ToggleSwitch
checked={!!settings.visits?.excludeBots}
onChange={(excludeBots) => updateSettings({ excludeBots })}
>
Exclude bots wherever possible (this option&lsquo;s effect might depend on Shlink server&lsquo;s version).
<FormText>
The visits coming from potential bots will
be <b>{settings.visits?.excludeBots ? 'excluded' : 'included'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup>
<ToggleSwitch
checked={!!settings.visits?.loadPrevInterval}
onChange={(loadPrevInterval) => updateSettings({ loadPrevInterval })}
>
Compare visits with previous period.
<FormText>
When loading visits, previous period <b>{settings.visits?.loadPrevInterval ? 'will' : 'won\'t'}</b> be
loaded by default.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup noMargin label="Default interval to load on visits sections:">
<DateIntervalSelector
allText="All visits"
active={currentDefaultInterval(settings)}
onChange={(defaultInterval) => updateSettings({ defaultInterval })}
/>
</LabeledFormGroup>
</SimpleCard>
);
};

View file

@ -7,8 +7,8 @@ export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<
}
// The "last180Days" interval had a typo, with a lowercase d
if ((state.settings.visits?.defaultInterval as any) === 'last180days') {
state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days');
if (state.settings.visits && (state.settings.visits.defaultInterval as any) === 'last180days') {
state.settings.visits.defaultInterval = 'last180Days';
}
return state;

View file

@ -1,15 +1,8 @@
import type { PayloadAction, PrepareAction } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { mergeDeepRight } from '@shlinkio/data-manipulation';
import type { Theme } from '@shlinkio/shlink-frontend-kit';
import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type {
Settings,
ShortUrlCreationSettings,
ShortUrlsListSettings,
TagsSettings,
VisitsSettings,
} from '@shlinkio/shlink-web-component';
import type { Settings, ShortUrlsListSettings } from '@shlinkio/shlink-web-component/settings';
import type { Defined } from '../../utils/types';
type ShortUrlsOrder = Defined<ShortUrlsListSettings['defaultOrdering']>;
@ -19,15 +12,9 @@ export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
dir: 'DESC',
};
export type UiSettings = {
theme: Theme;
};
type SettingsAction = PayloadAction<Settings>;
export type AppSettings = Settings & {
ui?: UiSettings;
};
const initialState: AppSettings = {
const initialState: Settings = {
realTimeUpdates: {
enabled: true,
},
@ -45,39 +32,14 @@ const initialState: AppSettings = {
},
};
type SettingsAction = PayloadAction<AppSettings>;
type SettingsPrepareAction = PrepareAction<AppSettings>;
const commonReducer = (state: AppSettings, { payload }: SettingsAction) => mergeDeepRight(state, payload);
const toReducer = (prepare: SettingsPrepareAction) => ({ reducer: commonReducer, prepare });
const toPreparedAction: SettingsPrepareAction = (payload: AppSettings) => ({ payload });
const { reducer, actions } = createSlice({
name: 'shlink/settings',
initialState,
reducers: {
toggleRealTimeUpdates: toReducer((enabled: boolean) => toPreparedAction({ realTimeUpdates: { enabled } })),
setRealTimeUpdatesInterval: toReducer((interval: number) => toPreparedAction({ realTimeUpdates: { interval } })),
setShortUrlCreationSettings: toReducer(
(shortUrlCreation: ShortUrlCreationSettings) => toPreparedAction({ shortUrlCreation }),
),
setShortUrlsListSettings: toReducer(
(shortUrlsList: ShortUrlsListSettings) => toPreparedAction({ shortUrlsList }),
),
setUiSettings: toReducer((ui: UiSettings) => toPreparedAction({ ui })),
setVisitsSettings: toReducer((visits: VisitsSettings) => toPreparedAction({ visits })),
setTagsSettings: toReducer((tags: TagsSettings) => toPreparedAction({ tags })),
setSettings: (state: Settings, { payload }: SettingsAction) => mergeDeepRight(state, payload),
},
});
export const {
toggleRealTimeUpdates,
setRealTimeUpdatesInterval,
setShortUrlCreationSettings,
setShortUrlsListSettings,
setUiSettings,
setVisitsSettings,
setTagsSettings,
} = actions;
export const { setSettings } = actions;
export const settingsReducer = reducer;

View file

@ -1,56 +1,15 @@
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { RealTimeUpdatesSettings } from '../RealTimeUpdatesSettings';
import {
setRealTimeUpdatesInterval,
setShortUrlCreationSettings,
setShortUrlsListSettings,
setTagsSettings,
setUiSettings,
setVisitsSettings,
toggleRealTimeUpdates,
} from '../reducers/settings';
import { SettingsFactory } from '../Settings';
import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings';
import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
import { TagsSettings } from '../TagsSettings';
import { UserInterfaceSettings } from '../UserInterfaceSettings';
import { VisitsSettings } from '../VisitsSettings';
import { setSettings } from '../reducers/settings';
import { Settings } from '../Settings';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.factory('Settings', SettingsFactory);
bottle.serviceFactory('Settings', () => Settings);
bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(null, ['resetSelectedServer']));
bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
bottle.decorator(
'RealTimeUpdatesSettings',
connect(['settings'], ['toggleRealTimeUpdates', 'setRealTimeUpdatesInterval']),
);
bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
bottle.decorator('ShortUrlCreationSettings', connect(['settings'], ['setShortUrlCreationSettings']));
bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
bottle.decorator('UserInterfaceSettings', connect(['settings'], ['setUiSettings']));
bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
bottle.decorator('VisitsSettings', connect(['settings'], ['setVisitsSettings']));
bottle.serviceFactory('TagsSettings', () => TagsSettings);
bottle.decorator('TagsSettings', connect(['settings'], ['setTagsSettings']));
bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings);
bottle.decorator('ShortUrlsListSettings', connect(['settings'], ['setShortUrlsListSettings']));
bottle.decorator('Settings', connect(['settings'], ['setSettings', 'resetSelectedServer']));
// Actions
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
bottle.serviceFactory('setShortUrlsListSettings', () => setShortUrlsListSettings);
bottle.serviceFactory('setUiSettings', () => setUiSettings);
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
bottle.serviceFactory('setTagsSettings', () => setTagsSettings);
bottle.serviceFactory('setSettings', () => setSettings);
};

View file

@ -1,5 +1,5 @@
import { DropdownBtn } from '@shlinkio/shlink-frontend-kit';
import type { VisitsSettings } from '@shlinkio/shlink-web-component';
import type { VisitsSettings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { DropdownItem } from 'reactstrap';

View file

@ -1,13 +1,7 @@
import { parseQuery } from '@shlinkio/shlink-frontend-kit';
import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
export const useGoBack = () => {
const navigate = useNavigate();
return useCallback(() => navigate(-1), [navigate]);
};
export const useParsedQuery = <T>(): T => {
const { search } = useLocation();
return useMemo(() => parseQuery<T>(search), [search]);
};

View file

@ -9,4 +9,4 @@ export const createAsyncThunk = <Returned, ThunkArg>(
typePrefix,
payloadCreator,
{ serializeError: (e) => e },
);
);

View file

@ -38,7 +38,7 @@ export const versionMatch = (versionToMatch: SemVer | Empty, { maxVersion, minVe
const versionIsValidSemVer = memoizeWith((v) => v, (version: string): version is SemVer => {
try {
return compare(version, version, '=');
} catch (e) {
} catch {
return false;
}
});

View file

@ -1,4 +1,4 @@
import { run } from 'axe-core';
import axe from 'axe-core';
type ContainerWrapper = { container: HTMLElement };
@ -6,7 +6,7 @@ type AccessibilityTestSubject = ContainerWrapper | Promise<ContainerWrapper>;
export const checkAccessibility = async (subject: AccessibilityTestSubject) => {
const { container } = await subject;
const { violations } = await run(container);
const { violations } = await axe.run(container);
expect(violations).toStrictEqual([]);
};

View file

@ -32,7 +32,9 @@ describe('<DeleteServerButton />', () => {
expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Closed/);
expect(screen.getByText(/DeleteServerModal/)).not.toHaveTextContent(/Open/);
container.firstElementChild && await user.click(container.firstElementChild);
if (container.firstElementChild) {
await user.click(container.firstElementChild);
}
await waitFor(() => expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Open/));
});

View file

@ -55,7 +55,7 @@ exports[`<DeleteServerButton /> > renders expected content 4`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232H328c13.3 0 24 10.7 24 24s-10.7 24-24 24H184c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
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>

View file

@ -53,7 +53,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M96 0C78.3 0 64 14.3 64 32v96h64V32c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32v96h64V32c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32v32c0 77.4 55 142 128 156.8V480c0 17.7 14.3 32 32 32s32-14.3 32-32V412.8C297 398 352 333.4 352 256V224c17.7 0 32-14.3 32-32s-14.3-32-32-32H32z"
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>
@ -76,7 +76,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
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 160V416c0 53 43 96 96 96H352c53 0 96-43 96-96V320c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7-14.3 32-32 32H96c-17.7 0-32-14.3-32-32V160c0-17.7 14.3-32 32-32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H96z"
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>
@ -128,7 +128,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232H328c13.3 0 24 10.7 24 24s-10.7 24-24 24H184c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
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>
@ -196,7 +196,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M96 0C78.3 0 64 14.3 64 32v96h64V32c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32v96h64V32c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32v32c0 77.4 55 142 128 156.8V480c0 17.7 14.3 32 32 32s32-14.3 32-32V412.8C297 398 352 333.4 352 256V224c17.7 0 32-14.3 32-32s-14.3-32-32-32H32z"
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>
@ -219,7 +219,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
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 160V416c0 53 43 96 96 96H352c53 0 96-43 96-96V320c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7-14.3 32-32 32H96c-17.7 0-32-14.3-32-32V160c0-17.7 14.3-32 32-32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H96z"
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>
@ -271,7 +271,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232H328c13.3 0 24 10.7 24 24s-10.7 24-24 24H184c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
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>

View file

@ -57,7 +57,9 @@ describe('<ImportServersBtn />', () => {
const { container } = setUp();
const input = container.querySelector('[type=file]');
input && fireEvent.change(input, { target: { files: [''] } });
if (input) {
fireEvent.change(input, { target: { files: [''] } });
}
expect(importServersFromFile).toHaveBeenCalledTimes(1);
await waitFor(() => expect(createServersMock).toHaveBeenCalledTimes(1));
});
@ -73,7 +75,9 @@ describe('<ImportServersBtn />', () => {
importServersFromFile.mockResolvedValue([existingServer, newServer]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
input && fireEvent.change(input, { target: { files: [''] } });
if (input) {
fireEvent.change(input, { target: { files: [''] } });
}
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: btnName }));

View file

@ -1,6 +1,5 @@
import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
import { fromPartial } from '@total-typescript/shoehorn';
import { v4 as uuid } from 'uuid';
import type { ShlinkState } from '../../../src/container/types';
import type { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data';
import {
@ -41,7 +40,7 @@ describe('selectedServerReducer', () => {
['latest', MAX_FALLBACK_VERSION, 'latest'],
['%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%'],
])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
const id = uuid();
const id = crypto.randomUUID();
const getState = createGetStateMock(id);
const expectedSelectedServer = {
id,
@ -60,7 +59,7 @@ describe('selectedServerReducer', () => {
});
it('dispatches error when health endpoint fails', async () => {
const id = uuid();
const id = crypto.randomUUID();
const getState = createGetStateMock(id);
const expectedSelectedServer = fromPartial<NonReachableServer>({ id, serverNotReachable: true });
@ -73,7 +72,7 @@ describe('selectedServerReducer', () => {
});
it('dispatches error when server is not found', async () => {
const id = uuid();
const id = crypto.randomUUID();
const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} }));
const expectedSelectedServer: NotFoundServer = { serverNotFound: true };

View file

@ -107,7 +107,9 @@ describe('serversReducer', () => {
});
it('generates an id for every provided server if they do not have it', () => {
const servers = Object.values(list).map(({ id, ...rest }) => rest);
const servers = Object.values(list).map(({ name, autoConnect, url, apiKey }) => (
{ name, autoConnect, url, apiKey }
));
const { payload } = createServers(servers);
expect(Object.values(payload).every(({ id }) => !!id)).toEqual(true);

View file

@ -1,86 +0,0 @@
import type { RealTimeUpdatesSettings as RealTimeUpdatesSettingsOptions } from '@shlinkio/shlink-web-component';
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { RealTimeUpdatesSettings } from '../../src/settings/RealTimeUpdatesSettings';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<RealTimeUpdatesSettings />', () => {
const toggleRealTimeUpdates = vi.fn();
const setRealTimeUpdatesInterval = vi.fn();
const setUp = (realTimeUpdates: Partial<RealTimeUpdatesSettingsOptions> = {}) => renderWithEvents(
<RealTimeUpdatesSettings
settings={fromPartial({ realTimeUpdates })}
toggleRealTimeUpdates={toggleRealTimeUpdates}
setRealTimeUpdatesInterval={setRealTimeUpdatesInterval}
/>,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders enabled real time updates as expected', () => {
setUp({ enabled: true });
expect(screen.getByLabelText(/^Enable or disable real-time updates./)).toBeChecked();
expect(screen.getByText(/^Real-time updates are currently being/)).toHaveTextContent('processed');
expect(screen.getByText(/^Real-time updates are currently being/)).not.toHaveTextContent('ignored');
expect(screen.getByText('Real-time updates frequency (in minutes):')).not.toHaveAttribute(
'class',
expect.stringContaining('text-muted'),
);
expect(screen.getByLabelText('Real-time updates frequency (in minutes):')).not.toHaveAttribute('disabled');
expect(screen.getByText('Updates will be reflected in the UI as soon as they happen.')).toBeInTheDocument();
});
it('renders disabled real time updates as expected', () => {
setUp({ enabled: false });
expect(screen.getByLabelText(/^Enable or disable real-time updates./)).not.toBeChecked();
expect(screen.getByText(/^Real-time updates are currently being/)).not.toHaveTextContent('processed');
expect(screen.getByText(/^Real-time updates are currently being/)).toHaveTextContent('ignored');
expect(screen.getByText('Real-time updates frequency (in minutes):')).toHaveAttribute(
'class',
expect.stringContaining('text-muted'),
);
expect(screen.getByLabelText('Real-time updates frequency (in minutes):')).toHaveAttribute('disabled');
expect(screen.queryByText('Updates will be reflected in the UI as soon as they happen.')).not.toBeInTheDocument();
});
it.each([
[1, 'minute'],
[2, 'minutes'],
[10, 'minutes'],
[100, 'minutes'],
])('shows expected children when interval is greater than 0', (interval, minutesWord) => {
setUp({ enabled: true, interval });
expect(screen.getByText(/^Updates will be reflected in the UI every/)).toHaveTextContent(
`${interval} ${minutesWord}`,
);
expect(screen.getByLabelText('Real-time updates frequency (in minutes):')).toHaveValue(interval);
expect(screen.queryByText('Updates will be reflected in the UI as soon as they happen.')).not.toBeInTheDocument();
});
it.each([[undefined], [0]])('shows expected children when interval is 0 or undefined', (interval) => {
setUp({ enabled: true, interval });
expect(screen.queryByText(/^Updates will be reflected in the UI every/)).not.toBeInTheDocument();
expect(screen.getByText('Updates will be reflected in the UI as soon as they happen.')).toBeInTheDocument();
});
it('updates real time updates when typing on input', async () => {
const { user } = setUp({ enabled: true });
expect(setRealTimeUpdatesInterval).not.toHaveBeenCalled();
await user.type(screen.getByLabelText('Real-time updates frequency (in minutes):'), '5');
expect(setRealTimeUpdatesInterval).toHaveBeenCalledWith(5);
});
it('toggles real time updates on switch change', async () => {
const { user } = setUp({ enabled: true });
expect(toggleRealTimeUpdates).not.toHaveBeenCalled();
await user.click(screen.getByText(/^Enable or disable real-time updates./));
expect(toggleRealTimeUpdates).toHaveBeenCalled();
});
});

View file

@ -1,52 +1,14 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { SettingsFactory } from '../../src/settings/Settings';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { Settings } from '../../src/settings/Settings';
import { checkAccessibility } from '../__helpers__/accessibility';
describe('<Settings />', () => {
const Settings = SettingsFactory(fromPartial({
RealTimeUpdatesSettings: () => <span>RealTimeUpdates</span>,
ShortUrlCreationSettings: () => <span>ShortUrlCreation</span>,
ShortUrlsListSettings: () => <span>ShortUrlsList</span>,
UserInterfaceSettings: () => <span>UserInterface</span>,
VisitsSettings: () => <span>Visits</span>,
TagsSettings: () => <span>Tags</span>,
}));
const setUp = (activeRoute = '/') => {
const history = createMemoryHistory();
history.push(activeRoute);
return render(<Router location={history.location} navigator={history}><Settings /></Router>);
};
const setUp = () => render(
<MemoryRouter>
<Settings settings={{}} setSettings={vi.fn()} />
</MemoryRouter>,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it.each([
['/general', {
visibleComps: ['UserInterface', 'RealTimeUpdates'],
hiddenComps: ['ShortUrlCreation', 'ShortUrlsList', 'Tags', 'Visits'],
}],
['/short-urls', {
visibleComps: ['ShortUrlCreation', 'ShortUrlsList'],
hiddenComps: ['UserInterface', 'RealTimeUpdates', 'Tags', 'Visits'],
}],
['/other-items', {
visibleComps: ['Tags', 'Visits'],
hiddenComps: ['UserInterface', 'RealTimeUpdates', 'ShortUrlCreation', 'ShortUrlsList'],
}],
])('renders expected sections based on route', (activeRoute, { visibleComps, hiddenComps }) => {
setUp(activeRoute);
visibleComps.forEach((comp) => expect(screen.getByText(comp)).toBeInTheDocument());
hiddenComps.forEach((comp) => expect(screen.queryByText(comp)).not.toBeInTheDocument());
});
it('renders expected menu', () => {
setUp();
expect(screen.getByRole('link', { name: 'General' })).toHaveAttribute('href', '/general');
expect(screen.getByRole('link', { name: 'Short URLs' })).toHaveAttribute('href', '/short-urls');
expect(screen.getByRole('link', { name: 'Other items' })).toHaveAttribute('href', '/other-items');
});
});

View file

@ -1,115 +0,0 @@
import type { ShortUrlCreationSettings as ShortUrlsSettings } from '@shlinkio/shlink-web-component';
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { ShortUrlCreationSettings } from '../../src/settings/ShortUrlCreationSettings';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ShortUrlCreationSettings />', () => {
const setShortUrlCreationSettings = vi.fn();
const setUp = (shortUrlCreation?: ShortUrlsSettings) => renderWithEvents(
<ShortUrlCreationSettings
settings={fromPartial({ shortUrlCreation })}
setShortUrlCreationSettings={setShortUrlCreationSettings}
/>,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it.each([
[{ validateUrls: true }, true],
[{ validateUrls: false }, false],
[undefined, false],
])('URL validation switch has proper initial state', (shortUrlCreation, expectedChecked) => {
const matcher = /^Request validation on long URLs when creating new short URLs/;
setUp(shortUrlCreation);
const checkbox = screen.getByLabelText(matcher);
const label = screen.getByText(matcher);
if (expectedChecked) {
expect(checkbox).toBeChecked();
expect(label).toHaveTextContent('Validate URL checkbox will be checked');
expect(label).not.toHaveTextContent('Validate URL checkbox will be unchecked');
} else {
expect(checkbox).not.toBeChecked();
expect(label).toHaveTextContent('Validate URL checkbox will be unchecked');
expect(label).not.toHaveTextContent('Validate URL checkbox will be checked');
}
});
it.each([
[{ forwardQuery: true }, true],
[{ forwardQuery: false }, false],
[{}, true],
])('forward query switch is toggled if option is true', (shortUrlCreation, expectedChecked) => {
const matcher = /^Make all new short URLs forward their query params to the long URL/;
setUp({ validateUrls: true, ...shortUrlCreation });
const checkbox = screen.getByLabelText(matcher);
const label = screen.getByText(matcher);
if (expectedChecked) {
expect(checkbox).toBeChecked();
expect(label).toHaveTextContent('Forward query params on redirect checkbox will be checked');
expect(label).not.toHaveTextContent('Forward query params on redirect checkbox will be unchecked');
} else {
expect(checkbox).not.toBeChecked();
expect(label).toHaveTextContent('Forward query params on redirect checkbox will be unchecked');
expect(label).not.toHaveTextContent('Forward query params on redirect checkbox will be checked');
}
});
it.each([
[{ tagFilteringMode: 'includes' } as ShortUrlsSettings, 'Suggest tags including input', 'including'],
[
{ tagFilteringMode: 'startsWith' } as ShortUrlsSettings,
'Suggest tags starting with input',
'starting with',
],
[undefined, 'Suggest tags starting with input', 'starting with'],
])('shows expected texts for tags suggestions', (shortUrlCreation, expectedText, expectedHint) => {
setUp(shortUrlCreation);
expect(screen.getByRole('button', { name: expectedText })).toBeInTheDocument();
expect(screen.getByText(/^The list of suggested tags will contain those/)).toHaveTextContent(expectedHint);
});
it.each([[true], [false]])('invokes setShortUrlCreationSettings when URL validation toggle value changes', async (validateUrls) => {
const { user } = setUp({ validateUrls });
expect(setShortUrlCreationSettings).not.toHaveBeenCalled();
await user.click(screen.getByLabelText(/^Request validation on long URLs when creating new short URLs/));
expect(setShortUrlCreationSettings).toHaveBeenCalledWith({ validateUrls: !validateUrls });
});
it.each([[true], [false]])('invokes setShortUrlCreationSettings when forward query toggle value changes', async (forwardQuery) => {
const { user } = setUp({ validateUrls: true, forwardQuery });
expect(setShortUrlCreationSettings).not.toHaveBeenCalled();
await user.click(screen.getByLabelText(/^Make all new short URLs forward their query params to the long URL/));
expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining({ forwardQuery: !forwardQuery }));
});
it('invokes setShortUrlCreationSettings when dropdown value changes', async () => {
const { user } = setUp();
const clickItem = async (name: string) => {
await user.click(screen.getByRole('button', { name: 'Suggest tags starting with input' }));
await user.click(await screen.findByRole('menuitem', { name }));
};
expect(setShortUrlCreationSettings).not.toHaveBeenCalled();
await clickItem('Suggest tags including input');
expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining(
{ tagFilteringMode: 'includes' },
));
await clickItem('Suggest tags starting with input');
expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining(
{ tagFilteringMode: 'startsWith' },
));
});
});

View file

@ -1,40 +0,0 @@
import type { ShortUrlsListSettings as ShortUrlsSettings } from '@shlinkio/shlink-web-component';
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { ShortUrlsListSettings } from '../../src/settings/ShortUrlsListSettings';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ShortUrlsListSettings />', () => {
const setSettings = vi.fn();
const setUp = (shortUrlsList?: ShortUrlsSettings) => renderWithEvents(
<ShortUrlsListSettings settings={fromPartial({ shortUrlsList })} setShortUrlsListSettings={setSettings} />,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it.each([
[undefined, 'Order by: Created at - DESC'],
[fromPartial<ShortUrlsSettings>({}), 'Order by: Created at - DESC'],
[fromPartial<ShortUrlsSettings>({ defaultOrdering: {} }), 'Order by...'],
[fromPartial<ShortUrlsSettings>({ defaultOrdering: { field: 'longUrl', dir: 'DESC' } }), 'Order by: Long URL - DESC'],
[fromPartial<ShortUrlsSettings>({ defaultOrdering: { field: 'visits', dir: 'ASC' } }), 'Order by: Visits - ASC'],
])('shows expected ordering', (shortUrlsList, expectedOrder) => {
setUp(shortUrlsList);
expect(screen.getByRole('button')).toHaveTextContent(expectedOrder);
});
it.each([
['Clear selection', undefined, undefined],
['Long URL', 'longUrl', 'ASC'],
['Visits', 'visits', 'ASC'],
['Title', 'title', 'ASC'],
])('invokes setSettings when ordering changes', async (name, field, dir) => {
const { user } = setUp();
expect(setSettings).not.toHaveBeenCalled();
await user.click(screen.getByRole('button'));
await user.click(screen.getByRole('menuitem', { name }));
expect(setSettings).toHaveBeenCalledWith({ defaultOrdering: { field, dir } });
});
});

View file

@ -1,47 +0,0 @@
import type { TagsSettings as TagsSettingsOptions } from '@shlinkio/shlink-web-component';
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { TagsOrder } from '../../src/settings/TagsSettings';
import { TagsSettings } from '../../src/settings/TagsSettings';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<TagsSettings />', () => {
const setTagsSettings = vi.fn();
const setUp = (tags?: TagsSettingsOptions) => renderWithEvents(
<TagsSettings settings={fromPartial({ tags })} setTagsSettings={setTagsSettings} />,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders expected amount of groups', () => {
setUp();
expect(screen.getByText('Default ordering for tags list:')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Order by...' })).toBeInTheDocument();
});
it.each([
[undefined, 'Order by...'],
[{}, 'Order by...'],
[{ defaultOrdering: {} }, 'Order by...'],
[{ defaultOrdering: { field: 'tag', dir: 'DESC' } as TagsOrder }, 'Order by: Tag - DESC'],
[{ defaultOrdering: { field: 'visits', dir: 'ASC' } as TagsOrder }, 'Order by: Visits - ASC'],
])('shows expected ordering', (tags, expectedOrder) => {
setUp(tags);
expect(screen.getByRole('button', { name: expectedOrder })).toBeInTheDocument();
});
it.each([
['Tag', 'tag', 'ASC'],
['Visits', 'visits', 'ASC'],
['Short URLs', 'shortUrls', 'ASC'],
])('invokes setTagsSettings when ordering changes', async (name, field, dir) => {
const { user } = setUp();
expect(setTagsSettings).not.toHaveBeenCalled();
await user.click(screen.getByText('Order by...'));
await user.click(screen.getByRole('menuitem', { name }));
expect(setTagsSettings).toHaveBeenCalledWith({ defaultOrdering: { field, dir } });
});
});

View file

@ -1,56 +0,0 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { UiSettings } from '../../src/settings/reducers/settings';
import { UserInterfaceSettings } from '../../src/settings/UserInterfaceSettings';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<UserInterfaceSettings />', () => {
const setUiSettings = vi.fn();
const setUp = (ui?: UiSettings, defaultDarkTheme = false) => renderWithEvents(
<UserInterfaceSettings
settings={fromPartial({ ui })}
setUiSettings={setUiSettings}
_matchMedia={vi.fn().mockReturnValue({ matches: defaultDarkTheme })}
/>,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it.each([
[{ theme: 'dark' as const }, true, true],
[{ theme: 'dark' as const }, false, true],
[{ theme: 'light' as const }, true, false],
[{ theme: 'light' as const }, false, false],
[undefined, false, false],
[undefined, true, true],
])('toggles switch if theme is dark', (ui, defaultDarkTheme, expectedChecked) => {
setUp(ui, defaultDarkTheme);
if (expectedChecked) {
expect(screen.getByLabelText('Use dark theme.')).toBeChecked();
} else {
expect(screen.getByLabelText('Use dark theme.')).not.toBeChecked();
}
});
it.each([
[{ theme: 'dark' as const }],
[{ theme: 'light' as const }],
[undefined],
])('shows different icons based on theme', (ui) => {
setUp(ui);
expect(screen.getByRole('img', { hidden: true })).toMatchSnapshot();
});
it.each([
['light' as const, 'dark' as const],
['dark' as const, 'light' as const],
])('invokes setUiSettings when theme toggle value changes', async (initialTheme, expectedTheme) => {
const { user } = setUp({ theme: initialTheme });
expect(setUiSettings).not.toHaveBeenCalled();
await user.click(screen.getByLabelText('Use dark theme.'));
expect(setUiSettings).toHaveBeenCalledWith({ theme: expectedTheme });
});
});

View file

@ -1,129 +0,0 @@
import type { Settings } from '@shlinkio/shlink-web-component';
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { VisitsSettings } from '../../src/settings/VisitsSettings';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<VisitsSettings />', () => {
const setVisitsSettings = vi.fn();
const setUp = (settings: Partial<Settings> = {}) => renderWithEvents(
<VisitsSettings settings={fromPartial(settings)} setVisitsSettings={setVisitsSettings} />,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders expected components', () => {
setUp();
expect(screen.getByRole('heading')).toHaveTextContent('Visits');
expect(screen.getByText('Default interval to load on visits sections:')).toBeInTheDocument();
expect(screen.getByText(/^Exclude bots wherever possible/)).toBeInTheDocument();
expect(screen.getByText('Compare visits with previous period.')).toBeInTheDocument();
});
it.each([
[fromPartial<Settings>({}), 'Last 30 days'],
[fromPartial<Settings>({ visits: {} }), 'Last 30 days'],
[
fromPartial<Settings>({
visits: {
defaultInterval: 'last7Days',
},
}),
'Last 7 days',
],
[
fromPartial<Settings>({
visits: {
defaultInterval: 'today',
},
}),
'Today',
],
])('sets expected interval as active', (settings, expectedInterval) => {
setUp(settings);
expect(screen.getByRole('button')).toHaveTextContent(expectedInterval);
});
it('invokes setVisitsSettings when interval changes', async () => {
const { user } = setUp();
const selectOption = async (name: string) => {
await user.click(screen.getByRole('button'));
await user.click(screen.getByRole('menuitem', { name }));
};
await selectOption('Last 7 days');
await selectOption('Last 180 days');
await selectOption('Yesterday');
expect(setVisitsSettings).toHaveBeenCalledTimes(3);
expect(setVisitsSettings).toHaveBeenNthCalledWith(1, { defaultInterval: 'last7Days' });
expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' });
expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' });
});
it.each([
[
fromPartial<Settings>({}),
/The visits coming from potential bots will be included.$/,
/The visits coming from potential bots will be excluded.$/,
],
[
fromPartial<Settings>({ visits: { excludeBots: false } }),
/The visits coming from potential bots will be included.$/,
/The visits coming from potential bots will be excluded.$/,
],
[
fromPartial<Settings>({ visits: { excludeBots: true } }),
/The visits coming from potential bots will be excluded.$/,
/The visits coming from potential bots will be included.$/,
],
])('displays expected helper text for exclude bots control', (settings, expectedText, notExpectedText) => {
setUp(settings);
const visitsComponent = screen.getByText(/^Exclude bots wherever possible/);
expect(visitsComponent).toHaveTextContent(expectedText);
expect(visitsComponent).not.toHaveTextContent(notExpectedText);
});
it('invokes setVisitsSettings when bot exclusion is toggled', async () => {
const { user } = setUp();
await user.click(screen.getByText(/^Exclude bots wherever possible/));
expect(setVisitsSettings).toHaveBeenCalledWith(expect.objectContaining({ excludeBots: true }));
});
it.each([
[
fromPartial<Settings>({}),
/When loading visits, previous period won't be loaded by default.$/,
/When loading visits, previous period will be loaded by default.$/,
],
[
fromPartial<Settings>({ visits: { loadPrevInterval: false } }),
/When loading visits, previous period won't be loaded by default.$/,
/When loading visits, previous period will be loaded by default.$/,
],
[
fromPartial<Settings>({ visits: { loadPrevInterval: true } }),
/When loading visits, previous period will be loaded by default.$/,
/When loading visits, previous period won't be loaded by default.$/,
],
])('displays expected helper text for prev interval control', (settings, expectedText, notExpectedText) => {
setUp(settings);
const visitsComponent = screen.getByText('Compare visits with previous period.');
expect(visitsComponent).toHaveTextContent(expectedText);
expect(visitsComponent).not.toHaveTextContent(notExpectedText);
});
it('invokes setVisitsSettings when loading prev visits is toggled', async () => {
const { user } = setUp();
await user.click(screen.getByText('Compare visits with previous period.'));
expect(setVisitsSettings).toHaveBeenCalledWith(expect.objectContaining({ loadPrevInterval: true }));
});
});

View file

@ -1,55 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<UserInterfaceSettings /> > shows different icons based on theme 1`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-moon user-interface__theme-icon"
data-icon="moon"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 384 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
fill="currentColor"
/>
</svg>
`;
exports[`<UserInterfaceSettings /> > shows different icons based on theme 2`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-sun user-interface__theme-icon"
data-icon="sun"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z"
fill="currentColor"
/>
</svg>
`;
exports[`<UserInterfaceSettings /> > shows different icons based on theme 3`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-sun user-interface__theme-icon"
data-icon="sun"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z"
fill="currentColor"
/>
</svg>
`;

View file

@ -1,75 +1,25 @@
import {
DEFAULT_SHORT_URLS_ORDERING,
setRealTimeUpdatesInterval,
setShortUrlCreationSettings,
setShortUrlsListSettings,
setTagsSettings,
settingsReducer,
setUiSettings,
setVisitsSettings,
toggleRealTimeUpdates,
} from '../../../src/settings/reducers/settings';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import { fromPartial } from '@total-typescript/shoehorn';
import { DEFAULT_SHORT_URLS_ORDERING, setSettings, settingsReducer } from '../../../src/settings/reducers/settings';
describe('settingsReducer', () => {
const realTimeUpdates = { enabled: true };
const shortUrlCreation = { validateUrls: false };
const ui = { theme: 'light' };
const visits = { defaultInterval: 'last30Days' };
const ui = { theme: 'light' as const };
const visits = { defaultInterval: 'last30Days' as const };
const shortUrlsList = { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING };
const settings = { realTimeUpdates, shortUrlCreation, ui, visits, shortUrlsList };
const settings = fromPartial<Settings>({ realTimeUpdates, shortUrlCreation, ui, visits, shortUrlsList });
describe('reducer', () => {
it('returns realTimeUpdates when action is SET_SETTINGS', () => {
expect(settingsReducer(undefined, toggleRealTimeUpdates(realTimeUpdates.enabled))).toEqual(settings);
it('can update settings', () => {
expect(settingsReducer(undefined, setSettings(settings))).toEqual(settings);
});
});
describe('toggleRealTimeUpdates', () => {
it.each([[true], [false]])('updates settings with provided value and then loads updates again', (enabled) => {
const { payload } = toggleRealTimeUpdates(enabled);
expect(payload).toEqual({ realTimeUpdates: { enabled } });
});
});
describe('setRealTimeUpdatesInterval', () => {
it.each([[0], [1], [2], [10]])('updates settings with provided value and then loads updates again', (interval) => {
const { payload } = setRealTimeUpdatesInterval(interval);
expect(payload).toEqual({ realTimeUpdates: { interval } });
});
});
describe('setShortUrlCreationSettings', () => {
it('creates action to set shortUrlCreation settings', () => {
const { payload } = setShortUrlCreationSettings({ validateUrls: true });
expect(payload).toEqual({ shortUrlCreation: { validateUrls: true } });
});
});
describe('setUiSettings', () => {
it('creates action to set ui settings', () => {
const { payload } = setUiSettings({ theme: 'dark' });
expect(payload).toEqual({ ui: { theme: 'dark' } });
});
});
describe('setVisitsSettings', () => {
it('creates action to set visits settings', () => {
const { payload } = setVisitsSettings({ defaultInterval: 'last180Days' });
expect(payload).toEqual({ visits: { defaultInterval: 'last180Days' } });
});
});
describe('setTagsSettings', () => {
it('creates action to set tags settings', () => {
const { payload } = setTagsSettings({ defaultMode: 'list' });
expect(payload).toEqual({ tags: { defaultMode: 'list' } });
});
});
describe('setShortUrlsListSettings', () => {
it('creates action to set short URLs list settings', () => {
const { payload } = setShortUrlsListSettings({ defaultOrdering: DEFAULT_SHORT_URLS_ORDERING });
expect(payload).toEqual({ shortUrlsList: { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING } });
describe('setSettings', () => {
it('creates action to set settings', () => {
const { payload } = setSettings(settings);
expect(payload).toEqual(settings);
});
});
});

View file

@ -46,7 +46,7 @@ export default defineConfig({
// Required code coverage. Lower than this will make the check fail
thresholds: {
statements: 95,
branches: 95,
branches: 90,
functions: 90,
lines: 95,
},