Merge pull request #1955 from acelaya-forks/oxlint-oxfmt

Migrate from ESLint to Oxlint and Oxfmt
This commit is contained in:
Alejandro Celaya
2026-07-26 09:15:14 +02:00
committed by GitHub
81 changed files with 2004 additions and 3790 deletions

View File

@@ -12,12 +12,12 @@ updates:
fontawesome: fontawesome:
patterns: patterns:
- '@fortawesome/*' - '@fortawesome/*'
eslint: coding-styles:
patterns: patterns:
- '@shlinkio/eslint-config-js-coding-standard' - '@shlinkio/eslint-config-js-coding-standard'
- 'typescript-eslint' - 'oxfmt'
- '*eslint-plugin*' - 'oxlint'
- 'eslint' - 'oxlint-tsgolint'
shlink: shlink:
patterns: patterns:
- '@shlinkio/*' - '@shlinkio/*'

View File

@@ -14,3 +14,4 @@ jobs:
node-version: 24.x node-version: 24.x
publish-coverage: true publish-coverage: true
install-playwright: true install-playwright: true
cs-script: true

View File

@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
### Added
* *Nothing*
### Changed
* Migrate from ESLint to Oxlint and Oxfmt.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.8.0] - 2026-06-20 ## [4.8.0] - 2026-06-20
### Added ### Added
* [shlink-web-client] Add support for Shlink 5.1.0, by supporting browser redirect conditions. * [shlink-web-client] Add support for Shlink 5.1.0, by supporting browser redirect conditions.

View File

@@ -1,13 +1,7 @@
FROM mcr.microsoft.com/playwright:v1.61.1-noble FROM mcr.microsoft.com/playwright:v1.61.1-noble
ENV NODE_VERSION 24.14
ENV TINI_VERSION v0.19.0 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 # Install tini
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini
RUN chmod +x /sbin/tini RUN chmod +x /sbin/tini

View File

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

4
oxfmt.config.ts Normal file
View File

@@ -0,0 +1,4 @@
// @ts-expect-error This lib does not expose type definitions
import shlink from '@shlinkio/eslint-config-js-coding-standard/oxc/oxfmt';
export default shlink;

24
oxlint.config.ts Normal file
View File

@@ -0,0 +1,24 @@
// @ts-expect-error This lib does not expose type definitions
import shlink from '@shlinkio/eslint-config-js-coding-standard/oxc/oxlint';
import { defineConfig } from 'oxlint';
export default defineConfig({
extends: [shlink],
rules: {
'typescript/no-floating-promises': 'off', // FIXME Enable this
},
overrides: [
{
files: ['./*.config.ts'],
rules: {
'eslint/no-restricted-exports': 'off',
},
},
{
files: ['**/*.test.*'],
rules: {
'typescript/unbound-method': 'off',
},
},
],
});

4509
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,12 @@
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"lint": "eslint src test config/test *.config.{js,ts}", "cs": "node --run lint && node --run format:check",
"cs:fix": "node --run lint:fix && node --run format",
"lint": "oxlint src test config/test ./*.config.ts",
"lint:fix": "node --run lint -- --fix", "lint:fix": "node --run lint -- --fix",
"format": "oxfmt src test config/test ./*.config.ts",
"format:check": "node --run format -- --check",
"types": "tsc", "types": "tsc",
"start": "vite serve --host=0.0.0.0", "start": "vite serve --host=0.0.0.0",
"preview": "vite preview --host=0.0.0.0", "preview": "vite preview --host=0.0.0.0",
@@ -51,8 +55,7 @@
"workbox-strategies": "^7.4.0" "workbox-strategies": "^7.4.0"
}, },
"devDependencies": { "devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~3.7.0", "@shlinkio/eslint-config-js-coding-standard": "~3.9.2",
"@stylistic/eslint-plugin": "^5.7.1",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.0", "@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@@ -67,18 +70,13 @@
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"axe-core": "^4.12.1", "axe-core": "^4.12.1",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"eslint": "^9.39.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"history": "^5.3.0", "history": "^5.3.0",
"oxfmt": "^0.60.0",
"oxlint": "^1.75.0",
"oxlint-tsgolint": "^7.0.2001",
"playwright": "^1.62.0", "playwright": "^1.62.0",
"tailwindcss": "^4.1.3", "tailwindcss": "^4.1.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^8.1.5", "vite": "^8.1.5",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.0.3" "vitest": "^4.0.3"

View File

@@ -9,14 +9,18 @@ const apiClients: Map<string, ShlinkApiClient> = new Map();
const getSelectedServerFromState = (getState: GetState): ServerWithId => { const getSelectedServerFromState = (getState: GetState): ServerWithId => {
const { selectedServer } = getState(); const { selectedServer } = getState();
if (!hasServerData(selectedServer)) { if (!hasServerData(selectedServer)) {
throw new Error('There\'s no selected server or it is not found'); throw new Error("There's no selected server or it is not found");
} }
return selectedServer; return selectedServer;
}; };
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => { export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
const { url: baseUrl, apiKey, forwardCredentials } = typeof getStateOrSelectedServer === 'function' const {
url: baseUrl,
apiKey,
forwardCredentials,
} = typeof getStateOrSelectedServer === 'function'
? getSelectedServerFromState(getStateOrSelectedServer) ? getSelectedServerFromState(getStateOrSelectedServer)
: getStateOrSelectedServer; : getStateOrSelectedServer;
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`; const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;

View File

@@ -47,13 +47,17 @@ export const App: FC = () => {
<Routes> <Routes>
<Route index element={<Home />} /> <Route index element={<Home />} />
<Route path="/settings"> <Route path="/settings">
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)} {['', '*'].map((path) => (
<Route key={path} path={path} element={<Settings />} />
))}
</Route> </Route>
<Route path="/manage-servers" element={<ManageServers />} /> <Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} /> <Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} /> <Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId"> <Route path="/server/:serverId">
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)} {['', '*'].map((path) => (
<Route key={path} path={path} element={<ShlinkWebComponentContainer />} />
))}
</Route> </Route>
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@@ -25,10 +25,7 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, for
return ( return (
<Card <Card
role="alert" role="alert"
className={clsx( className={clsx('w-[700px] max-w-[calc(100%-30px)]', 'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]')}
'w-[700px] max-w-[calc(100%-30px)]',
'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]',
)}
> >
<Card.Header className="flex items-center justify-between"> <Card.Header className="flex items-center justify-between">
<h5>This app has just been updated!</h5> <h5>This app has just been updated!</h5>
@@ -37,7 +34,11 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, for
<Card.Body className="flex gap-4 items-center justify-between max-md:flex-col"> <Card.Body className="flex gap-4 items-center justify-between max-md:flex-col">
Restart it to enjoy the new features. Restart it to enjoy the new features.
<Button disabled={isUpdating} variant="secondary" solid onClick={update}> <Button disabled={isUpdating} variant="secondary" solid onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>} {!isUpdating && (
<>
Restart now <FontAwesomeIcon icon={reloadIcon} />
</>
)}
{isUpdating && <>Restarting...</>} {isUpdating && <>Restarting...</>}
</Button> </Button>
</Card.Body> </Card.Body>

View File

@@ -36,7 +36,9 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
<ErrorLayout title="Oops! This is awkward :S"> <ErrorLayout title="Oops! This is awkward :S">
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p> <p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<br /> <br />
<Button size="lg" onClick={() => location.reload()}>Take me back</Button> <Button size="lg" onClick={() => location.reload()}>
Take me back
</Button>
</ErrorLayout> </ErrorLayout>
); );
} }

View File

@@ -36,15 +36,12 @@ export const Home: FC = withoutSelectedServer(() => {
</div> </div>
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow"> <div className="md:border-l border-lm-border dark:border-dm-border flex-grow">
<h1 <h1 className={clsx('p-4 text-center border-lm-border dark:border-dm-border', { 'border-b': !hasServers })}>
className={clsx(
'p-4 text-center border-lm-border dark:border-dm-border',
{ 'border-b': !hasServers },
)}
>
Welcome! Welcome!
</h1> </h1>
{hasServers ? <ServersListGroup servers={serversList} /> : ( {hasServers ? (
<ServersListGroup servers={serversList} />
) : (
<div className="p-6 text-center flex flex-col gap-12 text-xl"> <div className="p-6 text-center flex flex-col gap-12 text-xl">
<p>This application will help you manage your Shlink servers.</p> <p>This application will help you manage your Shlink servers.</p>
<p> <p>

View File

@@ -14,11 +14,11 @@ export const MainHeader: FC = () => {
return ( return (
<NavBar <NavBar
className="[&]:fixed top-0 z-900" className="[&]:fixed top-0 z-900"
brand={( brand={
<Link to="/" className="[&]:text-white no-underline flex items-center gap-2"> <Link to="/" className="[&]:text-white no-underline flex items-center gap-2">
<ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small> <ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small>
</Link> </Link>
)} }
> >
<NavBar.MenuItem <NavBar.MenuItem
to={settingsPath} to={settingsPath}

View File

@@ -6,7 +6,5 @@ export type NoMenuLayoutProps = PropsWithChildren & {
}; };
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => ( export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
<div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}> <div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}>{children}</div>
{children}
</div>
); );

View File

@@ -11,6 +11,8 @@ export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) =>
button. button.
</p> </p>
<br /> <br />
<Button inline to={to} size="lg">{children}</Button> <Button inline to={to} size="lg">
{children}
</Button>
</ErrorLayout> </ErrorLayout>
); );

View File

@@ -23,7 +23,9 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
return ( return (
<small className="text-gray-500"> <small className="text-gray-500">
{isReachableServer(selectedServer) && ( {isReachableServer(selectedServer) && (
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </> <>
Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> -{' '}
</>
)} )}
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} /> Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
</small> </small>

View File

@@ -6,9 +6,7 @@ import { ShlinkVersions } from './ShlinkVersions';
export const ShlinkVersionsContainer = () => { export const ShlinkVersionsContainer = () => {
const { selectedServer } = useSelectedServer(); const { selectedServer } = useSelectedServer();
return ( return (
<div <div className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}>
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
>
<ShlinkVersions selectedServer={selectedServer} /> <ShlinkVersions selectedServer={selectedServer} />
</div> </div>
); );

View File

@@ -20,16 +20,13 @@ export type ShlinkWebComponentContainerProps = {
buildShlinkApiClient: ShlinkApiClientBuilder; buildShlinkApiClient: ShlinkApiClientBuilder;
}; };
const ShlinkWebComponentContainerBase: FC< const ShlinkWebComponentContainerBase: FC<ShlinkWebComponentContainerProps> =
ShlinkWebComponentContainerProps
// FIXME Using `memo` here to solve a flickering effect in charts. // 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 // memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
// extra rendering there. // extra rendering there.
// This should be revisited at some point. // This should be revisited at some point.
> = withSelectedServer(memo(({ withSelectedServer(
buildShlinkApiClient, memo(({ buildShlinkApiClient, TagColorsStorage: tagColorsStorage }) => {
TagColorsStorage: tagColorsStorage,
}) => {
const { selectedServer } = useSelectedServer(); const { selectedServer } = useSelectedServer();
const { settings } = useSettings(); const { settings } = useSettings();
@@ -54,7 +51,8 @@ const ShlinkWebComponentContainerBase: FC<
/> />
</ShlinkSidebarVisibilityProvider> </ShlinkSidebarVisibilityProvider>
); );
})); }),
);
export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [ export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [
'buildShlinkApiClient', 'buildShlinkApiClient',

View File

@@ -8,18 +8,10 @@ export interface ShlinkLogoProps {
export const ShlinkLogo = ({ color = brandColor(), className }: ShlinkLogoProps) => ( export const ShlinkLogo = ({ color = brandColor(), className }: ShlinkLogoProps) => (
<svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg"> <svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g fill={color}> <g fill={color}>
<path <path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" />
d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" <path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z" />
/> <path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z" />
<path <path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z" />
d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z"
/>
<path
d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z"
/>
<path
d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z"
/>
</g> </g>
</svg> </svg>
); );

View File

@@ -36,10 +36,7 @@ type Optionalize<P, K extends keyof P> = Omit<P, K> & Partial<Pick<P, K>>;
* Higher Order Component used to inject services into components as props. * Higher Order Component used to inject services into components as props.
* All dependencies become optional props so that they can still be explicitly set in tests if desired. * All dependencies become optional props so that they can still be explicitly set in tests if desired.
*/ */
export function withDependencies< export function withDependencies<Props extends Record<string, unknown>, DependencyName extends string & keyof Props>(
Props extends Record<string, unknown>,
DependencyName extends string & keyof Props,
>(
Component: ComponentType<Props>, Component: ComponentType<Props>,
dependencyNames: DependencyName[], dependencyNames: DependencyName[],
): ComponentType<Optionalize<Props, DependencyName>> { ): ComponentType<Optionalize<Props, DependencyName>> {

View File

@@ -38,13 +38,17 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME }); const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle(); const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle();
const [serverData, setServerData] = useState<ServerData>(); const [serverData, setServerData] = useState<ServerData>();
const saveNewServer = useCallback((newServerData: ServerData) => { const saveNewServer = useCallback(
(newServerData: ServerData) => {
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]); const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
createServers([newServerWithUniqueId]); createServers([newServerWithUniqueId]);
navigate(`/server/${newServerWithUniqueId.id}`); navigate(`/server/${newServerWithUniqueId.id}`);
}, [createServers, navigate, servers]); },
const onSubmit = useCallback((newServerData: ServerData) => { [createServers, navigate, servers],
);
const onSubmit = useCallback(
(newServerData: ServerData) => {
setServerData(newServerData); setServerData(newServerData);
const serverExists = Object.values(servers).some( const serverExists = Object.values(servers).some(
@@ -56,7 +60,9 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
} else { } else {
saveNewServer(newServerData); saveNewServer(newServerData);
} }
}, [saveNewServer, servers, toggleConfirmModal]); },
[saveNewServer, servers, toggleConfirmModal],
);
return ( return (
<NoMenuLayout> <NoMenuLayout>
@@ -64,7 +70,11 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
{!hasServers && ( {!hasServers && (
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} /> <ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
)} )}
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>} {hasServers && (
<Button variant="secondary" onClick={goBack}>
Cancel
</Button>
)}
<Button type="submit">Create server</Button> <Button type="submit">Create server</Button>
</ServerForm> </ServerForm>

View File

@@ -12,12 +12,15 @@ export type DeleteServerButtonProps = PropsWithChildren<{
export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => { export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => {
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle(); const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
const navigate = useNavigate(); const navigate = useNavigate();
const onClose = useCallback((confirmed: boolean) => { const onClose = useCallback(
(confirmed: boolean) => {
hideModal(); hideModal();
if (confirmed) { if (confirmed) {
navigate('/'); navigate('/');
} }
}, [hideModal, navigate]); },
[hideModal, navigate],
);
return ( return (
<> <>

View File

@@ -13,11 +13,14 @@ export type DeleteServerModalProps = {
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => { export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
const { deleteServer } = useServers(); const { deleteServer } = useServers();
const onClosed = useCallback((exitAction: ExitAction) => { const onClosed = useCallback(
(exitAction: ExitAction) => {
if (exitAction === 'confirm') { if (exitAction === 'confirm') {
deleteServer(server); deleteServer(server);
} }
}, [deleteServer, server]); },
[deleteServer, server],
);
return ( return (
<CardModal <CardModal
@@ -30,11 +33,13 @@ export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose,
confirmText="Delete" confirmText="Delete"
> >
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p> <p>
Are you sure you want to remove <b>{server ? server.name : ''}</b>?
</p>
<p> <p>
<i> <i>
No data will be deleted, only the access to this server will be removed from this device. No data will be deleted, only the access to this server will be removed from this device. You can create it
You can create it again at any moment. again at any moment.
</i> </i>
</p> </p>
</div> </div>

View File

@@ -34,7 +34,9 @@ export const EditServer: FC = withSelectedServer(() => {
initialValues={selectedServer} initialValues={selectedServer}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<Button variant="secondary" onClick={goBack}>Cancel</Button> <Button variant="secondary" onClick={goBack}>
Cancel
</Button>
<Button type="submit">Save</Button> <Button type="submit">Save</Button>
</ServerForm> </ServerForm>
</NoMenuLayout> </NoMenuLayout>

View File

@@ -19,10 +19,8 @@ export type ManageServersProps = {
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(
ServersExporter: serversExporter, ({ ServersExporter: serversExporter, useTimeoutToggle }) => {
useTimeoutToggle,
}) => {
const { servers } = useServers(); const { servers } = useServers();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const allServers = useMemo(() => Object.values(servers), [servers]); const allServers = useMemo(() => Object.values(servers), [servers]);
@@ -40,7 +38,9 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
<div className="flex flex-col md:flex-row gap-2"> <div className="flex flex-col md:flex-row gap-2">
<div className="flex gap-2"> <div className="flex gap-2">
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn> <ImportServersBtn className="flex-grow" onError={setErrorImporting}>
Import servers
</ImportServersBtn>
{filteredServers.length > 0 && ( {filteredServers.length > 0 && (
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}> <Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers <FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
@@ -53,18 +53,26 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
</div> </div>
<SimpleCard className="card"> <SimpleCard className="card">
<Table header={( <Table
header={
<Table.Row> <Table.Row>
{hasAutoConnect && ( {hasAutoConnect && (
<Table.Cell className="w-[35px]"><span className="sr-only">Auto-connect</span></Table.Cell> <Table.Cell className="w-[35px]">
<span className="sr-only">Auto-connect</span>
</Table.Cell>
)} )}
<Table.Cell>Name</Table.Cell> <Table.Cell>Name</Table.Cell>
<Table.Cell>Base URL</Table.Cell> <Table.Cell>Base URL</Table.Cell>
<Table.Cell><span className="sr-only">Options</span></Table.Cell> <Table.Cell>
<span className="sr-only">Options</span>
</Table.Cell>
</Table.Row> </Table.Row>
)}> }
>
{!filteredServers.length && ( {!filteredServers.length && (
<Table.Row className="text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row> <Table.Row className="text-center">
<Table.Cell colSpan={4}>No servers found.</Table.Cell>
</Table.Row>
)} )}
{filteredServers.map((server) => ( {filteredServers.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} /> <ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
@@ -79,6 +87,7 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
)} )}
</NoMenuLayout> </NoMenuLayout>
); );
}); },
);
export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']); export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']);

View File

@@ -34,7 +34,9 @@ export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoCon
<Table.Cell className="font-bold" columnName="Name"> <Table.Cell className="font-bold" columnName="Name">
<Link to={`/server/${server.id}`}>{server.name}</Link> <Link to={`/server/${server.id}`}>{server.name}</Link>
</Table.Cell> </Table.Cell>
<Table.Cell columnName="Base URL" className="max-lg:border-b-0">{server.url}</Table.Cell> <Table.Cell columnName="Base URL" className="max-lg:border-b-0">
{server.url}
</Table.Cell>
<Table.Cell className="text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0"> <Table.Cell className="text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0">
<ManageServersRowDropdown server={server} /> <ManageServersRowDropdown server={server} />
</Table.Cell> </Table.Cell>

View File

@@ -12,11 +12,13 @@ export const ServersDropdown: FC = () => {
const { selectedServer } = useSelectedServer(); const { selectedServer } = useSelectedServer();
return ( return (
<NavBar.Dropdown buttonContent={( <NavBar.Dropdown
buttonContent={
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<FontAwesomeIcon icon={serverIcon} /> Servers <FontAwesomeIcon icon={serverIcon} /> Servers
</span> </span>
)}> }
>
{serversList.length === 0 ? ( {serversList.length === 0 ? (
<Dropdown.Item to="/server/create"> <Dropdown.Item to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> Add a server <FontAwesomeIcon icon={plusIcon} /> Add a server

View File

@@ -25,7 +25,7 @@ const ServerListItem = ({ id, name }: { id: string; name: string }) => (
</Link> </Link>
); );
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => ( export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) =>
servers.length > 0 && ( servers.length > 0 && (
<div <div
data-testid="list" data-testid="list"
@@ -35,7 +35,8 @@ export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderles
{ 'border-y': !borderless }, { 'border-y': !borderless },
)} )}
> >
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)} {servers.map(({ id, name }) => (
<ServerListItem key={id} id={id} name={name} />
))}
</div> </div>
)
); );

View File

@@ -10,9 +10,12 @@ export type DuplicatedServersModalProps = {
onConfirm: () => void; onConfirm: () => void;
}; };
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = ( export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = ({
{ open, duplicatedServers, onClose, onConfirm }, open,
) => { duplicatedServers,
onClose,
onConfirm,
}) => {
const hasMultipleServers = duplicatedServers.length > 1; const hasMultipleServers = duplicatedServers.length > 1;
return ( return (
@@ -27,16 +30,24 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
> >
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p> <p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul className="list-disc my-4 pl-5"> <ul className="list-disc my-4 pl-5">
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? ( {duplicatedServers.map(({ url, apiKey }, index) =>
!hasMultipleServers ? (
<Fragment key={index}> <Fragment key={index}>
<li>URL: <b>{url}</b></li> <li>
<li>API key: <b>{apiKey}</b></li> URL: <b>{url}</b>
</li>
<li>
API key: <b>{apiKey}</b>
</li>
</Fragment> </Fragment>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))} ) : (
<li key={index}>
<b>{url}</b> - <b>{apiKey}</b>
</li>
),
)}
</ul> </ul>
<span> <span>{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?</span>
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
</span>
</CardModal> </CardModal>
); );
}; };

View File

@@ -17,7 +17,7 @@ export type ImportServersBtnProps = PropsWithChildren<{
className?: string; className?: string;
// Injected // Injected
ServersImporter: ServersImporter ServersImporter: ServersImporter;
}>; }>;
const ImportServersBtnBase: FC<ImportServersBtnProps> = ({ const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
@@ -37,7 +37,8 @@ const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
const onFile = useCallback( const onFile = useCallback(
async ({ target }: ChangeEvent<HTMLInputElement>) => async ({ target }: ChangeEvent<HTMLInputElement>) =>
serversImporter.importServersFromFile(target.files?.[0]) serversImporter
.importServersFromFile(target.files?.[0])
.then((importedServers) => { .then((importedServers) => {
const { duplicatedServers, newServers } = dedupServers(servers, importedServers); const { duplicatedServers, newServers } = dedupServers(servers, importedServers);

View File

@@ -26,8 +26,8 @@ export const ServerError: FC = () => {
</Message> </Message>
<p className="text-xl"> <p className="text-xl">
These are the Shlink servers currently configured. Choose one of These are the Shlink servers currently configured. Choose one of them or{' '}
them or <Link to="/server/create">add a new one</Link>. <Link to="/server/create">add a new one</Link>.
</p> </p>
<Card className="w-full max-w-100 overflow-hidden"> <Card className="w-full max-w-100 overflow-hidden">
<ServersListGroup borderless servers={Object.values(servers)} /> <ServersListGroup borderless servers={Object.values(servers)} />
@@ -35,9 +35,8 @@ export const ServerError: FC = () => {
{isServerWithId(selectedServer) && ( {isServerWithId(selectedServer) && (
<p className="text-xl"> <p className="text-xl">
Alternatively, if you think you may have misconfigured this server, you Alternatively, if you think you may have misconfigured this server, you can{' '}
can <DeleteServerButton server={selectedServer}>remove <DeleteServerButton server={selectedServer}>remove it</DeleteServerButton> or&nbsp;
it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>. <Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</p> </p>
)} )}

View File

@@ -45,13 +45,13 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
Forward credentials to this server on every request. Forward credentials to this server on every request.
</Label> </Label>
<small className="pl-5.5 text-gray-600 dark:text-gray-400 mt-0.5"> <small className="pl-5.5 text-gray-600 dark:text-gray-400 mt-0.5">
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a username {'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a
and password. username and password.
</small> </small>
<small className="pl-5.5 text-gray-600 dark:text-gray-400"> <small className="pl-5.5 text-gray-600 dark:text-gray-400">
<b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will <b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will make
make all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict value for{' '}
value for <code className="whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>. <code className="whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
</small> </small>
</div> </div>
</Details> </Details>

View File

@@ -49,9 +49,9 @@ export type DedupServersResult = {
*/ */
export function dedupServers(servers: ServersMap, serversToAdd: ServerData[]): DedupServersResult { export function dedupServers(servers: ServersMap, serversToAdd: ServerData[]): DedupServersResult {
const serversList = Object.values(servers); const serversList = Object.values(servers);
const { duplicatedServers = [], newServers = [] } = groupBy( // oxlint-disable-next-line typescript/no-useless-default-assignment - Type definition is wrong
serversToAdd, const { duplicatedServers = [], newServers = [] } = groupBy(serversToAdd, (server) =>
(server) => serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers', serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers',
); );
return { duplicatedServers, newServers }; return { duplicatedServers, newServers };

View File

@@ -8,10 +8,7 @@ import { hasServerData } from '../data';
import { ensureUniqueIds } from '../helpers'; import { ensureUniqueIds } from '../helpers';
import { createServers, useServers } from './servers'; import { createServers, useServers } from './servers';
const responseToServersList = (data: any) => ensureUniqueIds( const responseToServersList = (data: any) => ensureUniqueIds({}, Array.isArray(data) ? data.filter(hasServerData) : []);
{},
(Array.isArray(data) ? data.filter(hasServerData) : []),
);
export const fetchServers = createAsyncThunk( export const fetchServers = createAsyncThunk(
'shlink/remoteServers/fetchServers', 'shlink/remoteServers/fetchServers',

View File

@@ -15,14 +15,13 @@ export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999'; export const MAX_FALLBACK_VERSION = '999.999.999';
export const LATEST_VERSION_CONSTRAINT = 'latest'; export const LATEST_VERSION_CONSTRAINT = 'latest';
const versionToSemVer = (version: string) => toSemVer( const versionToSemVer = (version: string) =>
version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version, toSemVer(version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version, MIN_FALLBACK_VERSION);
MIN_FALLBACK_VERSION,
);
const getServerVersion = memoizeWith( const getServerVersion = memoizeWith(
(server: ServerWithId) => `${server.id}_${server.url}_${server.apiKey}`, (server: ServerWithId) => `${server.id}_${server.url}_${server.apiKey}`,
async (_server: ServerWithId, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({ async (_server: ServerWithId, health: () => Promise<ShlinkHealth>) =>
health().then(({ version }) => ({
version: versionToSemVer(version), version: versionToSemVer(version),
printableVersion: versionToPrintable(version), printableVersion: versionToPrintable(version),
})), })),

View File

@@ -27,9 +27,7 @@ export const { actions, reducer: serversReducer } = createSlice({
}), }),
reducer: (state, { payload }: PayloadAction<EditServer>) => { reducer: (state, { payload }: PayloadAction<EditServer>) => {
const { serverId, serverData } = payload; const { serverId, serverData } = payload;
return ( return !state[serverId] ? state : { ...state, [serverId]: { ...state[serverId], ...serverData } };
!state[serverId] ? state : { ...state, [serverId]: { ...state[serverId], ...serverData } }
);
}, },
}, },
deleteServer: (state, { payload }) => { deleteServer: (state, { payload }) => {

View File

@@ -110,10 +110,7 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
.then((response) => { .then((response) => {
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
if ( if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) {
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => { registration.unregister().then(() => {

View File

@@ -4,9 +4,5 @@ import type { RootState } from '.';
export const createAsyncThunk = <Returned, ThunkArg>( export const createAsyncThunk = <Returned, ThunkArg>(
typePrefix: string, typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: RootState, serializedErrorType: any }>, payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: RootState; serializedErrorType: any }>,
) => baseCreateAsyncThunk( ) => baseCreateAsyncThunk(typePrefix, payloadCreator, { serializeError: (e) => e });
typePrefix,
payloadCreator,
{ serializeError: (e) => e },
);

View File

@@ -14,7 +14,8 @@ const localStorageConfig: RLSOptions = {
const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig)); const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig));
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
export const setUpStore = (preloadedState = getStateFromLocalStorage()) => configureStore({ export const setUpStore = (preloadedState = getStateFromLocalStorage()) =>
configureStore({
devTools: !isProduction, devTools: !isProduction,
reducer: initReducers(), reducer: initReducers(),
preloadedState, preloadedState,

View File

@@ -4,7 +4,8 @@ import { selectedServerReducer } from '../servers/reducers/selectedServer';
import { serversReducer } from '../servers/reducers/servers'; import { serversReducer } from '../servers/reducers/servers';
import { settingsReducer } from '../settings/reducers/settings'; import { settingsReducer } from '../settings/reducers/settings';
export const initReducers = () => combineReducers({ export const initReducers = () =>
combineReducers({
appUpdated: appUpdatesReducer, appUpdated: appUpdatesReducer,
servers: serversReducer, servers: serversReducer,
selectedServer: selectedServerReducer, selectedServer: selectedServerReducer,

View File

@@ -7,6 +7,6 @@
@layer base { @layer base {
:root { :root {
--footer-height: 2.3rem; --footer-height: 2.3rem;
--footer-margin: .8rem; --footer-margin: 0.8rem;
} }
} }

View File

@@ -1,7 +1,8 @@
import { Parser } from '@json2csv/plainjs'; import { Parser } from '@json2csv/plainjs';
import csv from 'csvtojson'; import csv from 'csvtojson';
export const csvToJson = <T>(csvContent: string) => new Promise<T[]>((resolve) => { export const csvToJson = <T>(csvContent: string) =>
new Promise<T[]>((resolve) => {
csv().fromString(csvContent).then(resolve); csv().fromString(csvContent).then(resolve);
}); });

View File

@@ -1,5 +1,5 @@
export const forceUpdate = async () => { export const forceUpdate = async () => {
const registrations = await navigator.serviceWorker?.getRegistrations() ?? []; const registrations = (await navigator.serviceWorker?.getRegistrations()) ?? [];
registrations.forEach(({ waiting }) => { registrations.forEach(({ waiting }) => {
waiting?.addEventListener('statechange', (event) => { waiting?.addEventListener('statechange', (event) => {

View File

@@ -3,17 +3,17 @@ import { compare } from 'compare-versions';
export type Empty = null | undefined | '' | never[]; export type Empty = null | undefined | '' | never[];
const isEmpty = (value: Exclude<any, undefined | null>): boolean => ( const isEmpty = (value: Exclude<any, undefined | null>): boolean =>
(Array.isArray(value) && value.length === 0) (Array.isArray(value) && value.length === 0) ||
|| (typeof value === 'string' && value === '') (typeof value === 'string' && value === '') ||
|| (typeof value === 'object' && Object.keys(value).length === 0) (typeof value === 'object' && Object.keys(value).length === 0);
);
export const hasValue = <T>(value: T | Empty): value is T => value !== undefined && value !== null && !isEmpty(value); export const hasValue = <T>(value: T | Empty): value is T => value !== undefined && value !== null && !isEmpty(value);
type SemVerPatternFragment = `${bigint | '*'}`; type SemVerPatternFragment = `${bigint | '*'}`;
type SemVerPattern = SemVerPatternFragment type SemVerPattern =
| SemVerPatternFragment
| `${SemVerPatternFragment}.${SemVerPatternFragment}` | `${SemVerPatternFragment}.${SemVerPatternFragment}`
| `${SemVerPatternFragment}.${SemVerPatternFragment}.${SemVerPatternFragment}`; | `${SemVerPatternFragment}.${SemVerPatternFragment}.${SemVerPatternFragment}`;
@@ -35,16 +35,18 @@ export const versionMatch = (versionToMatch: SemVer | Empty, { maxVersion, minVe
return matchesMaxVersion && matchesMinVersion; return matchesMaxVersion && matchesMinVersion;
}; };
const versionIsValidSemVer = memoizeWith((v) => v, (version: string): version is SemVer => { const versionIsValidSemVer = memoizeWith(
(v) => v,
(version: string): version is SemVer => {
try { try {
return compare(version, version, '='); return compare(version, version, '=');
} catch { } catch {
return false; return false;
} }
}); },
);
export const versionToPrintable = (version: string) => (!versionIsValidSemVer(version) ? version : `v${version}`); export const versionToPrintable = (version: string) => (!versionIsValidSemVer(version) ? version : `v${version}`);
export const versionToSemVer = (version: string, fallback: SemVer = 'latest'): SemVer => ( export const versionToSemVer = (version: string, fallback: SemVer = 'latest'): SemVer =>
versionIsValidSemVer(version) ? version : fallback versionIsValidSemVer(version) ? version : fallback;
);

View File

@@ -6,7 +6,7 @@ export class LocalStorage {
public readonly get = <T>(key: string): T | undefined => { public readonly get = <T>(key: string): T | undefined => {
const item = this.localStorage.getItem(buildPath(key)); const item = this.localStorage.getItem(buildPath(key));
return item ? JSON.parse(item) as T : undefined; return item ? (JSON.parse(item) as T) : undefined;
}; };
public readonly set = (key: string, value: any) => this.localStorage.setItem(buildPath(key), JSON.stringify(value)); public readonly set = (key: string, value: any) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));

View File

@@ -2,8 +2,7 @@ import type { TagColorsStorage as BaseTagColorsStorage } from '@shlinkio/shlink-
import type { LocalStorage } from './LocalStorage'; import type { LocalStorage } from './LocalStorage';
export class TagColorsStorage implements BaseTagColorsStorage { export class TagColorsStorage implements BaseTagColorsStorage {
constructor(private readonly storage: LocalStorage) { constructor(private readonly storage: LocalStorage) {}
}
getTagColors(): Record<string, string> { getTagColors(): Record<string, string> {
return this.storage.get<Record<string, string>>('colors') ?? {}; return this.storage.get<Record<string, string>>('colors') ?? {};

View File

@@ -5,7 +5,10 @@ import { useCallback } from 'react';
* Wraps an event handler so that it calls e.preventDefault() before invoking the event 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) => export const usePreventDefault = <Event extends SyntheticEvent = SyntheticEvent>(handler: (e: Event) => void) =>
useCallback((e: Event) => { useCallback(
(e: Event) => {
e.preventDefault(); e.preventDefault();
handler(e); handler(e);
}, [handler]); },
[handler],
);

View File

@@ -6,9 +6,7 @@ export type RenderModalArgs = {
onClose: () => void; onClose: () => void;
}; };
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = ( export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = ({ renderModal }) => {
{ renderModal },
) => {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const onClose = useCallback(() => setOpen(false), []); const onClose = useCallback(() => setOpen(false), []);

View File

@@ -44,12 +44,15 @@ describe('ShlinkApiClientBuilder', () => {
await apiClient.health(); await apiClient.health();
expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({ expect(jsonRequest).toHaveBeenCalledWith(
expect.stringMatching(new RegExp(`^${url}`)),
expect.objectContaining({
credentials: undefined, credentials: undefined,
headers: { headers: {
'X-Api-Key': apiKey, 'X-Api-Key': apiKey,
}, },
})); }),
);
}); });
it('includes credentials when forwarding is enabled', async () => { it('includes credentials when forwarding is enabled', async () => {
@@ -61,11 +64,14 @@ describe('ShlinkApiClientBuilder', () => {
await apiClient.health(); await apiClient.health();
expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({ expect(jsonRequest).toHaveBeenCalledWith(
expect.stringMatching(new RegExp(`^${url}`)),
expect.objectContaining({
credentials: 'include', credentials: 'include',
headers: { headers: {
'X-Api-Key': apiKey, 'X-Api-Key': apiKey,
}, },
})); }),
);
}); });
}); });

View File

@@ -13,7 +13,9 @@ vi.mock(import('../../src/common/ShlinkWebComponentContainer'), () => ({
})); }));
describe('<App />', () => { describe('<App />', () => {
const setUp = async (activeRoute = '/') => act(() => renderWithStore( const setUp = async (activeRoute = '/') =>
act(() =>
renderWithStore(
<MemoryRouter initialEntries={[{ pathname: activeRoute }]}> <MemoryRouter initialEntries={[{ pathname: activeRoute }]}>
<ContainerProvider <ContainerProvider
value={fromPartial({ value={fromPartial({
@@ -35,7 +37,8 @@ describe('<App />', () => {
appUpdated: false, appUpdated: false,
}, },
}, },
)); ),
);
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));

View File

@@ -7,8 +7,8 @@ describe('<AppUpdateBanner />', () => {
const onClose = vi.fn(); const onClose = vi.fn();
const forceUpdate = vi.fn(); const forceUpdate = vi.fn();
const setUp = async () => { const setUp = async () => {
const result = await act( const result = await act(() =>
() => renderWithEvents(<AppUpdateBanner isOpen onClose={onClose} forceUpdate={forceUpdate} />), renderWithEvents(<AppUpdateBanner isOpen onClose={onClose} forceUpdate={forceUpdate} />),
); );
await waitFor(() => screen.getByRole('alert')); await waitFor(() => screen.getByRole('alert'));

View File

@@ -7,7 +7,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<Home />', () => { describe('<Home />', () => {
const setUp = (servers: ServersMap = {}) => renderWithStore( const setUp = (servers: ServersMap = {}) =>
renderWithStore(
<MemoryRouter> <MemoryRouter>
<Home /> <Home />
</MemoryRouter>, </MemoryRouter>,
@@ -16,9 +17,8 @@ describe('<Home />', () => {
}, },
); );
it('passes a11y checks', () => checkAccessibility( it('passes a11y checks', () =>
setUp({ '1a': fromPartial<ServerWithId>({ name: 'foo', id: '1' }) }), checkAccessibility(setUp({ '1a': fromPartial<ServerWithId>({ name: 'foo', id: '1' }) })));
));
it('renders title', () => { it('renders title', () => {
setUp(); setUp();

View File

@@ -33,7 +33,8 @@ describe('<MainHeader />', () => {
])('sets link to settings as active only when current path is settings', (currentPath, isActive) => { ])('sets link to settings as active only when current path is settings', (currentPath, isActive) => {
setUp(currentPath); setUp(currentPath);
expect(screen.getByRole('menuitem', { name: /Settings$/ })).toHaveAttribute( expect(screen.getByRole('menuitem', { name: /Settings$/ })).toHaveAttribute(
'data-active', isActive ? 'true' : 'false', 'data-active',
isActive ? 'true' : 'false',
); );
}); });
}); });

View File

@@ -4,7 +4,12 @@ import { NotFound } from '../../src/common/NotFound';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
describe('<NotFound />', () => { describe('<NotFound />', () => {
const setUp = (props = {}) => render(<MemoryRouter><NotFound {...props} /></MemoryRouter>); const setUp = (props = {}) =>
render(
<MemoryRouter>
<NotFound {...props} />
</MemoryRouter>,
);
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
@@ -15,9 +20,11 @@ describe('<NotFound />', () => {
it('shows expected error message', () => { it('shows expected error message', () => {
setUp(); setUp();
expect(screen.getByText( expect(
'Use your browser\'s back button to navigate to the page you have previously come from, or just press this button.', screen.getByText(
)).toBeInTheDocument(); "Use your browser's back button to navigate to the page you have previously come from, or just press this button.",
),
).toBeInTheDocument();
}); });
it.each([ it.each([

View File

@@ -4,15 +4,16 @@ import { ScrollToTop } from '../../src/common/ScrollToTop';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
describe('<ScrollToTop />', () => { describe('<ScrollToTop />', () => {
const setUp = (children = 'Foo') => render(<MemoryRouter><ScrollToTop>{children}</ScrollToTop></MemoryRouter>); const setUp = (children = 'Foo') =>
render(
<MemoryRouter>
<ScrollToTop>{children}</ScrollToTop>
</MemoryRouter>,
);
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it.each([ it.each([['Foobar'], ['Barfoo'], ['Something']])('just renders children', (children) => {
['Foobar'],
['Barfoo'],
['Something'],
])('just renders children', (children) => {
setUp(children); setUp(children);
expect(screen.getByText(children)).toBeInTheDocument(); expect(screen.getByText(children)).toBeInTheDocument();
}); });

View File

@@ -8,9 +8,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
describe('<ShlinkVersions />', () => { describe('<ShlinkVersions />', () => {
const setUp = (props: ShlinkVersionsProps) => render(<ShlinkVersions {...props} />); const setUp = (props: ShlinkVersionsProps) => render(<ShlinkVersions {...props} />);
it('passes a11y checks', () => checkAccessibility( it('passes a11y checks', () =>
setUp({ selectedServer: fromPartial({ version: '1.0.0', printableVersion: '1.0.0' }) }), checkAccessibility(setUp({ selectedServer: fromPartial({ version: '1.0.0', printableVersion: '1.0.0' }) })));
));
it.each([ it.each([
['1.2.3', fromPartial<ReachableServer>({ version: '1.0.0', printableVersion: 'foo' }), 'v1.2.3', 'foo'], ['1.2.3', fromPartial<ReachableServer>({ version: '1.0.0', printableVersion: 'foo' }), 'v1.2.3', 'foo'],

View File

@@ -5,7 +5,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<ShlinkVersionsContainer />', () => { describe('<ShlinkVersionsContainer />', () => {
const setUp = (selectedServer: SelectedServer = null) => renderWithStore(<ShlinkVersionsContainer />, { const setUp = (selectedServer: SelectedServer = null) =>
renderWithStore(<ShlinkVersionsContainer />, {
initialState: { selectedServer }, initialState: { selectedServer },
}); });

View File

@@ -13,7 +13,8 @@ vi.mock('@shlinkio/shlink-web-component', () => ({
})); }));
describe('<ShlinkWebComponentContainer />', () => { describe('<ShlinkWebComponentContainer />', () => {
const setUp = (selectedServer: SelectedServer) => renderWithStore( const setUp = (selectedServer: SelectedServer) =>
renderWithStore(
<MemoryRouter> <MemoryRouter>
<ShlinkWebComponentContainer TagColorsStorage={fromPartial({})} /> <ShlinkWebComponentContainer TagColorsStorage={fromPartial({})} />
</MemoryRouter>, </MemoryRouter>,

View File

@@ -19,11 +19,13 @@ describe('context', () => {
}); });
it('throws when requested dependency is not found in container', () => { it('throws when requested dependency is not found in container', () => {
expect(() => render( expect(() =>
render(
<ContainerProvider value={fromPartial({})}> <ContainerProvider value={fromPartial({})}>
<TestComponent name="foo" /> <TestComponent name="foo" />
</ContainerProvider>, </ContainerProvider>,
)).toThrowError('Dependency with name "foo" not found in container'); ),
).toThrowError('Dependency with name "foo" not found in container');
}); });
it('gets dependency from container', () => { it('gets dependency from container', () => {

View File

@@ -70,12 +70,14 @@ describe('<CreateServer />', () => {
expect(store.getState().servers[expectedServerId]).not.toBeDefined(); expect(store.getState().servers[expectedServerId]).not.toBeDefined();
fireEvent.submit(screen.getByRole('form')); fireEvent.submit(screen.getByRole('form'));
expect(store.getState().servers[expectedServerId]).toEqual(expect.objectContaining({ expect(store.getState().servers[expectedServerId]).toEqual(
expect.objectContaining({
id: expectedServerId, id: expectedServerId,
name: 'the_name', name: 'the_name',
url: 'https://the_url.com', url: 'https://the_url.com',
apiKey: 'the_api_key', apiKey: 'the_api_key',
})); }),
);
expect(history.location.pathname).toEqual(`/server/${expectedServerId}`); expect(history.location.pathname).toEqual(`/server/${expectedServerId}`);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();

View File

@@ -21,11 +21,7 @@ describe('<DeleteServerButton />', () => {
it('passes a11y checks', () => checkAccessibility(setUp('Delete me'))); it('passes a11y checks', () => checkAccessibility(setUp('Delete me')));
it.each([ it.each([['Foo bar'], ['baz'], ['something']])('renders expected content', (children) => {
['Foo bar'],
['baz'],
['something'],
])('renders expected content', (children) => {
const { container } = setUp(children); const { container } = setUp(children);
expect(container.firstChild).toBeTruthy(); expect(container.firstChild).toBeTruthy();
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();

View File

@@ -9,16 +9,12 @@ import { TestModalWrapper } from '../__helpers__/TestModalWrapper';
describe('<DeleteServerModal />', () => { describe('<DeleteServerModal />', () => {
const serverName = 'the_server_name'; const serverName = 'the_server_name';
const server = fromPartial<ServerWithId>({ id: 'foo', name: serverName }); const server = fromPartial<ServerWithId>({ id: 'foo', name: serverName });
const setUp = () => renderWithStore( const setUp = () =>
<TestModalWrapper renderWithStore(<TestModalWrapper renderModal={(args) => <DeleteServerModal {...args} server={server} />} />, {
renderModal={(args) => <DeleteServerModal {...args} server={server} />}
/>,
{
initialState: { initialState: {
servers: { foo: server }, servers: { foo: server },
}, },
}, });
);
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
@@ -36,10 +32,9 @@ describe('<DeleteServerModal />', () => {
expect(screen.getByText(serverName)).toBeInTheDocument(); expect(screen.getByText(serverName)).toBeInTheDocument();
}); });
it.each([ it.each([[() => screen.getByRole('button', { name: 'Cancel' })], [() => screen.getByLabelText('Close dialog')]])(
[() => screen.getByRole('button', { name: 'Cancel' })], 'closes dialog when clicking cancel button',
[() => screen.getByLabelText('Close dialog')], async (getButton) => {
])('closes dialog when clicking cancel button', async (getButton) => {
const { user, store } = setUp(); const { user, store } = setUp();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
@@ -48,7 +43,8 @@ describe('<DeleteServerModal />', () => {
// No server has been deleted // No server has been deleted
expect(Object.keys(store.getState().servers)).toHaveLength(1); expect(Object.keys(store.getState().servers)).toHaveLength(1);
}); },
);
it('deletes server when clicking accept button', async () => { it('deletes server when clicking accept button', async () => {
const { user, store } = setUp(); const { user, store } = setUp();

View File

@@ -65,27 +65,33 @@ describe('<EditServer />', () => {
// await user.click(screen.getByRole('button', { name: 'Save' })); // await user.click(screen.getByRole('button', { name: 'Save' }));
fireEvent.submit(screen.getByRole('form')); fireEvent.submit(screen.getByRole('form'));
expect(store.getState().servers[defaultSelectedServer.id]).toEqual(expect.objectContaining({ expect(store.getState().servers[defaultSelectedServer.id]).toEqual(
expect.objectContaining({
name: 'the_name edited', name: 'the_name edited',
url: 'the_url edited', url: 'the_url edited',
})); }),
);
// After saving we go back, to the first route from history's initialEntries // After saving we go back, to the first route from history's initialEntries
expect(history.location.pathname).toEqual('/foo'); expect(history.location.pathname).toEqual('/foo');
}); });
it.each([ it.each([{ forwardCredentials: true }, { forwardCredentials: false }])(
{ forwardCredentials: true }, 'edits advanced options - forward credentials',
{ forwardCredentials: false }, async ({ forwardCredentials }) => {
])('edits advanced options - forward credentials', async ({ forwardCredentials }) => {
const { user, store } = setUp({ ...defaultSelectedServer, forwardCredentials }); const { user, store } = setUp({ ...defaultSelectedServer, forwardCredentials });
await user.click(screen.getByText('Advanced options')); await user.click(screen.getByText('Advanced options'));
await user.click(screen.getByLabelText('Forward credentials to this server on every request.')); await user.click(screen.getByLabelText('Forward credentials to this server on every request.'));
fireEvent.submit(screen.getByRole('form')); fireEvent.submit(screen.getByRole('form'));
await waitFor(() => expect(store.getState().servers[defaultSelectedServer.id]).toEqual(expect.objectContaining({ await waitFor(() =>
expect(store.getState().servers[defaultSelectedServer.id]).toEqual(
expect.objectContaining({
forwardCredentials: !forwardCredentials, forwardCredentials: !forwardCredentials,
}))); }),
}); ),
);
},
);
}); });

View File

@@ -11,10 +11,10 @@ describe('<ManageServers />', () => {
const exportServers = vi.fn(); const exportServers = vi.fn();
const serversExporter = fromPartial<ServersExporter>({ exportServers }); const serversExporter = fromPartial<ServersExporter>({ exportServers });
const useTimeoutToggle = vi.fn().mockReturnValue([false, vi.fn()]); const useTimeoutToggle = vi.fn().mockReturnValue([false, vi.fn()]);
const createServerMock = (value: string, autoConnect = false) => fromPartial<ServerWithId>( const createServerMock = (value: string, autoConnect = false) =>
{ id: value, name: value, url: value, autoConnect }, fromPartial<ServerWithId>({ id: value, name: value, url: value, autoConnect });
); const setUp = (servers: ServersMap = {}) =>
const setUp = (servers: ServersMap = {}) => renderWithStore( renderWithStore(
<MemoryRouter> <MemoryRouter>
<ManageServers useTimeoutToggle={useTimeoutToggle} ServersExporter={serversExporter} /> <ManageServers useTimeoutToggle={useTimeoutToggle} ServersExporter={serversExporter} />
</MemoryRouter>, </MemoryRouter>,
@@ -23,11 +23,14 @@ describe('<ManageServers />', () => {
}, },
); );
it('passes a11y checks', () => checkAccessibility(setUp({ it('passes a11y checks', () =>
checkAccessibility(
setUp({
foo: createServerMock('foo'), foo: createServerMock('foo'),
bar: createServerMock('bar'), bar: createServerMock('bar'),
baz: createServerMock('baz'), baz: createServerMock('baz'),
}))); }),
));
it('shows search field which allows searching servers, affecting te amount of rendered rows', async () => { it('shows search field which allows searching servers, affecting te amount of rendered rows', async () => {
const { user } = setUp({ const { user } = setUp({

View File

@@ -13,7 +13,8 @@ describe('<ManageServersRow />', () => {
apiKey: '123', apiKey: '123',
id: 'abc', id: 'abc',
}; };
const setUp = (hasAutoConnect = false, autoConnect = false) => renderWithStore( const setUp = (hasAutoConnect = false, autoConnect = false) =>
renderWithStore(
<MemoryRouter> <MemoryRouter>
<Table header={<Table.Row />}> <Table header={<Table.Row />}>
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} /> <ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
@@ -36,10 +37,7 @@ describe('<ManageServersRow />', () => {
expect(screen.getByRole('button', { name: 'Options' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Options' })).toBeInTheDocument();
}); });
it.each([ it.each([[true], [false]])('renders auto-connect icon only if server is autoConnect', (autoConnect) => {
[true],
[false],
])('renders auto-connect icon only if server is autoConnect', (autoConnect) => {
const { container } = setUp(true, autoConnect); const { container } = setUp(true, autoConnect);
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });

View File

@@ -12,7 +12,8 @@ describe('<ServersDropdown />', () => {
'2b': fromPartial({ name: 'bar', id: '2b' }), '2b': fromPartial({ name: 'bar', id: '2b' }),
'3c': fromPartial({ name: 'baz', id: '3c' }), '3c': fromPartial({ name: 'baz', id: '3c' }),
}; };
const setUp = (servers: ServersMap = fallbackServers) => renderWithStore( const setUp = (servers: ServersMap = fallbackServers) =>
renderWithStore(
<MemoryRouter> <MemoryRouter>
<ul role="menu"> <ul role="menu">
<ServersDropdown /> <ServersDropdown />

View File

@@ -6,10 +6,7 @@ import { ServersListGroup } from '../../src/servers/ServersListGroup';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
describe('<ServersListGroup />', () => { describe('<ServersListGroup />', () => {
const servers: ServerWithId[] = [ const servers: ServerWithId[] = [fromPartial({ name: 'foo', id: '123' }), fromPartial({ name: 'bar', id: '456' })];
fromPartial({ name: 'foo', id: '123' }),
fromPartial({ name: 'bar', id: '456' }),
];
const setUp = (params: { servers?: ServerWithId[]; borderless?: boolean } = {}) => { const setUp = (params: { servers?: ServerWithId[]; borderless?: boolean } = {}) => {
const { servers = [], borderless } = params; const { servers = [], borderless } = params;
@@ -22,21 +19,14 @@ describe('<ServersListGroup />', () => {
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it.each([ it.each([[servers], [[]]])('shows servers list', (servers) => {
[servers],
[[]],
])('shows servers list', (servers) => {
setUp({ servers }); setUp({ servers });
expect(screen.queryAllByTestId('list')).toHaveLength(servers.length ? 1 : 0); expect(screen.queryAllByTestId('list')).toHaveLength(servers.length ? 1 : 0);
expect(screen.queryAllByRole('link')).toHaveLength(servers.length); expect(screen.queryAllByRole('link')).toHaveLength(servers.length);
}); });
it.each([ it.each([[true], [false], [undefined]])('renders proper classes for embedded', (borderless) => {
[true],
[false],
[undefined],
])('renders proper classes for embedded', (borderless) => {
setUp({ servers, borderless }); setUp({ servers, borderless });
const list = screen.getByTestId('list'); const list = screen.getByTestId('list');

View File

@@ -8,9 +8,12 @@ import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DuplicatedServersModal />', () => { describe('<DuplicatedServersModal />', () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onConfirm = vi.fn(); const onConfirm = vi.fn();
const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents( const setUp = (duplicatedServers: ServerData[] = []) =>
act(() =>
renderWithEvents(
<DuplicatedServersModal open duplicatedServers={duplicatedServers} onClose={onClose} onConfirm={onConfirm} />, <DuplicatedServersModal open duplicatedServers={duplicatedServers} onClose={onClose} onConfirm={onConfirm} />,
)); ),
);
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data); const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
@@ -60,10 +63,7 @@ describe('<DuplicatedServersModal />', () => {
it.each([ it.each([
[[]], [[]],
[[mockServer({ url: 'url', apiKey: 'apiKey' })]], [[mockServer({ url: 'url', apiKey: 'apiKey' })]],
[[ [[mockServer({ url: 'url_1', apiKey: 'apiKey_1' }), mockServer({ url: 'url_2', apiKey: 'apiKey_2' })]],
mockServer({ url: 'url_1', apiKey: 'apiKey_1' }),
mockServer({ url: 'url_2', apiKey: 'apiKey_2' }),
]],
])('displays provided server data', async (duplicatedServers) => { ])('displays provided server data', async (duplicatedServers) => {
await setUp(duplicatedServers); await setUp(duplicatedServers);

View File

@@ -12,12 +12,10 @@ describe('<ImportServersBtn />', () => {
const onImportMock = vi.fn(); const onImportMock = vi.fn();
const importServersFromFile = vi.fn().mockResolvedValue([]); const importServersFromFile = vi.fn().mockResolvedValue([]);
const serversImporterMock = fromPartial<ServersImporter>({ importServersFromFile }); const serversImporterMock = fromPartial<ServersImporter>({ importServersFromFile });
const setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) => renderWithStore( const setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) =>
<ImportServersBtn {...props} onImport={onImportMock} ServersImporter={serversImporterMock} />, renderWithStore(<ImportServersBtn {...props} onImport={onImportMock} ServersImporter={serversImporterMock} />, {
{
initialState: { servers }, initialState: { servers },
}, });
);
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
@@ -26,8 +24,8 @@ describe('<ImportServersBtn />', () => {
expect(screen.queryByText(/^You can create servers by importing a CSV file/)).not.toBeInTheDocument(); expect(screen.queryByText(/^You can create servers by importing a CSV file/)).not.toBeInTheDocument();
await user.hover(screen.getByRole('button')); await user.hover(screen.getByRole('button'));
await waitFor( await waitFor(() =>
() => expect(screen.getByText(/^You can create servers by importing a CSV file/)).toBeInTheDocument(), expect(screen.getByText(/^You can create servers by importing a CSV file/)).toBeInTheDocument(),
); );
}); });
@@ -59,7 +57,9 @@ describe('<ImportServersBtn />', () => {
it.each([ it.each([
{ btnName: 'Save duplicate', savesDuplicatedServers: true }, { btnName: 'Save duplicate', savesDuplicatedServers: true },
{ btnName: 'Discard', savesDuplicatedServers: false }, { btnName: 'Discard', savesDuplicatedServers: false },
])('creates duplicated 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 = { const existingServerData: ServerData = {
name: 'existingServer', name: 'existingServer',
url: 'http://s.test/existingUrl', url: 'http://s.test/existingUrl',
@@ -92,5 +92,6 @@ describe('<ImportServersBtn />', () => {
// On import is called only once, no matter what // On import is called only once, no matter what
expect(onImportMock).toHaveBeenCalledOnce(); expect(onImportMock).toHaveBeenCalledOnce();
}); },
);
}); });

View File

@@ -7,7 +7,8 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithStore } from '../../__helpers__/setUpTest'; import { renderWithStore } from '../../__helpers__/setUpTest';
describe('<ServerError />', () => { describe('<ServerError />', () => {
const setUp = (selectedServer: SelectedServer) => renderWithStore( const setUp = (selectedServer: SelectedServer) =>
renderWithStore(
<MemoryRouter> <MemoryRouter>
<ServerError /> <ServerError />
</MemoryRouter>, </MemoryRouter>,
@@ -16,10 +17,10 @@ describe('<ServerError />', () => {
}, },
); );
it.each([ it.each([[fromPartial<NotFoundServer>({})], [fromPartial<NonReachableServer>({ id: 'abc123' })]])(
[fromPartial<NotFoundServer>({})], 'passes a11y checks',
[fromPartial<NonReachableServer>({ id: 'abc123' })], (selectedServer) => checkAccessibility(setUp(selectedServer)),
])('passes a11y checks', (selectedServer) => checkAccessibility(setUp(selectedServer))); );
it.each([ it.each([
[ [

View File

@@ -66,7 +66,6 @@ describe('remoteServersReducer', () => {
url: 'http://localhost:8000', url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
}, },
}, },
}, },
{ {

View File

@@ -21,13 +21,16 @@ describe('selectedServerReducer', () => {
it('returns selected server when action is SELECT_SERVER', () => { it('returns selected server when action is SELECT_SERVER', () => {
const payload = fromPartial<RegularServer>({ id: 'abc123' }); const payload = fromPartial<RegularServer>({ id: 'abc123' });
expect(reducer(null, selectServer.fulfilled(payload, '', { serverId: '', buildShlinkApiClient }))).toEqual(payload); expect(reducer(null, selectServer.fulfilled(payload, '', { serverId: '', buildShlinkApiClient }))).toEqual(
payload,
);
}); });
}); });
describe('selectServer', () => { describe('selectServer', () => {
const version = '1.19.0'; const version = '1.19.0';
const createGetStateMock = (id: string) => vi.fn().mockReturnValue({ const createGetStateMock = (id: string) =>
vi.fn().mockReturnValue({
servers: { servers: {
[id]: { id }, [id]: { id },
}, },

View File

@@ -39,14 +39,16 @@ describe('serversReducer', () => {
ghi789: { id: 'ghi789' }, ghi789: { id: 'ghi789' },
})); }));
it.each([ it.each([[true], [false]])(
[true], 'returns state as it is when trying to set auto-connect on invalid server',
[false], (autoConnect) =>
])('returns state as it is when trying to set auto-connect on invalid server', (autoConnect) => expect(serversReducer(list, setAutoConnect(fromPartial<ServerWithId>({ id: 'invalid' }), autoConnect))).toEqual(
expect(serversReducer(list, setAutoConnect(fromPartial<ServerWithId>({ id: 'invalid' }), autoConnect))).toEqual({ {
abc123: { id: 'abc123' }, abc123: { id: 'abc123' },
def456: { id: 'def456' }, def456: { id: 'def456' },
})); },
),
);
it('disables auto-connect on a server which is already set to auto-connect', () => { it('disables auto-connect on a server which is already set to auto-connect', () => {
const listWithDisabledAutoConnect = { const listWithDisabledAutoConnect = {
@@ -54,10 +56,9 @@ describe('serversReducer', () => {
abc123: { ...list.abc123, autoConnect: true }, abc123: { ...list.abc123, autoConnect: true },
}; };
expect(serversReducer( expect(
listWithDisabledAutoConnect, serversReducer(listWithDisabledAutoConnect, setAutoConnect(fromPartial<ServerWithId>({ id: 'abc123' }), false)),
setAutoConnect(fromPartial<ServerWithId>({ id: 'abc123' }), false), ).toEqual({
)).toEqual({
abc123: { id: 'abc123', autoConnect: false }, abc123: { id: 'abc123', autoConnect: false },
def456: { id: 'def456' }, def456: { id: 'def456' },
}); });
@@ -69,10 +70,9 @@ describe('serversReducer', () => {
abc123: { ...list.abc123, autoConnect: true }, abc123: { ...list.abc123, autoConnect: true },
}; };
expect(serversReducer( expect(
listWithEnabledAutoConnect, serversReducer(listWithEnabledAutoConnect, setAutoConnect(fromPartial<ServerWithId>({ id: 'def456' }), true)),
setAutoConnect(fromPartial<ServerWithId>({ id: 'def456' }), true), ).toEqual({
)).toEqual({
abc123: { id: 'abc123', autoConnect: false }, abc123: { id: 'abc123', autoConnect: false },
def456: { id: 'def456', autoConnect: true }, def456: { id: 'def456', autoConnect: true },
}); });
@@ -108,10 +108,7 @@ describe('serversReducer', () => {
}); });
describe('setAutoConnect', () => { describe('setAutoConnect', () => {
it.each([ it.each([[true], [false]])('returns expected action', (autoConnect) => {
[true],
[false],
])('returns expected action', (autoConnect) => {
const serverToEdit = fromPartial<RegularServer>({ id: 'abc123' }); const serverToEdit = fromPartial<RegularServer>({ id: 'abc123' });
const { payload } = setAutoConnect(serverToEdit, autoConnect); const { payload } = setAutoConnect(serverToEdit, autoConnect);

View File

@@ -54,7 +54,9 @@ describe('ServersExporter', () => {
it('makes use of download link API', () => { it('makes use of download link API', () => {
const jsonToCsvMock = createJsonToCsvMock(); const jsonToCsvMock = createJsonToCsvMock();
const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock); const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock);
const { document: { createElement } } = windowMock; const {
document: { createElement },
} = windowMock;
exporter.exportServers(); exporter.exportServers();

View File

@@ -11,9 +11,7 @@ describe('ServersImporter', () => {
describe('importServersFromFile', () => { describe('importServersFromFile', () => {
it.each([[null], [undefined]])('rejects with error if no file was provided', async (file) => { it.each([[null], [undefined]])('rejects with error if no file was provided', async (file) => {
await expect(importer.importServersFromFile(file)).rejects.toEqual( await expect(importer.importServersFromFile(file)).rejects.toEqual(new Error('No file provided'));
new Error('No file provided'),
);
}); });
it('rejects with error if parsing the file fails', async () => { it('rejects with error if parsing the file fails', async () => {
@@ -52,13 +50,13 @@ describe('ServersImporter', () => {
], ],
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties', 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, 'rejects with error if provided file does not parse to valid list of servers',
expectedError, async ({ parsedObject, expectedError }) => {
}) => {
csvjsonMock.mockResolvedValue(parsedObject); csvjsonMock.mockResolvedValue(parsedObject);
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(new Error(expectedError)); await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(new Error(expectedError));
}); },
);
it('reads file when a CSV containing valid servers is provided', async () => { it('reads file when a CSV containing valid servers is provided', async () => {
const expectedServers: Required<ServerData>[] = [ const expectedServers: Required<ServerData>[] = [

View File

@@ -4,7 +4,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<Settings />', () => { describe('<Settings />', () => {
const setUp = () => renderWithStore( const setUp = () =>
renderWithStore(
<MemoryRouter> <MemoryRouter>
<Settings /> <Settings />
</MemoryRouter>, </MemoryRouter>,

View File

@@ -17,13 +17,15 @@ describe('settings-helpers', () => {
}, },
}); });
expect(migrateDeprecatedSettings(state)).toEqual(expect.objectContaining({ expect(migrateDeprecatedSettings(state)).toEqual(
expect.objectContaining({
settings: expect.objectContaining({ settings: expect.objectContaining({
visits: { visits: {
defaultInterval: 'last180Days', defaultInterval: 'last180Days',
}, },
}), }),
})); }),
);
}); });
}); });
}); });

View File

@@ -15,7 +15,10 @@ describe('TagColorsStorage', () => {
describe('getTagColors', () => { describe('getTagColors', () => {
it.each([ it.each([
[undefined, {}], [undefined, {}],
[{ foo: 'red', var: 'green' }, { foo: 'red', var: 'green' }], [
{ foo: 'red', var: 'green' },
{ foo: 'red', var: 'green' },
],
])('returns colors from local storage', (colorsFromStorage, expectedValue) => { ])('returns colors from local storage', (colorsFromStorage, expectedValue) => {
get.mockReturnValue(colorsFromStorage); get.mockReturnValue(colorsFromStorage);

View File

@@ -8,9 +8,11 @@ import pack from './package.json' with { type: 'json' };
const homepage = pack.homepage?.trim(); const homepage = pack.homepage?.trim();
/* eslint-disable-next-line no-restricted-exports */
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss(), VitePWA({ plugins: [
react(),
tailwindcss(),
VitePWA({
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production', mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
strategies: 'injectManifest', strategies: 'injectManifest',
srcDir: './src', srcDir: './src',
@@ -18,7 +20,8 @@ export default defineConfig({
injectRegister: false, injectRegister: false,
manifestFilename: 'manifest.json', manifestFilename: 'manifest.json',
manifest, manifest,
})], }),
],
build: { build: {
outDir: 'build', outDir: 'build',