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:
patterns:
- '@fortawesome/*'
eslint:
coding-styles:
patterns:
- '@shlinkio/eslint-config-js-coding-standard'
- 'typescript-eslint'
- '*eslint-plugin*'
- 'eslint'
- 'oxfmt'
- 'oxlint'
- 'oxlint-tsgolint'
shlink:
patterns:
- '@shlinkio/*'

View File

@@ -14,3 +14,4 @@ jobs:
node-version: 24.x
publish-coverage: 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).
## [Unreleased]
### Added
* *Nothing*
### Changed
* Migrate from ESLint to Oxlint and Oxfmt.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.8.0] - 2026-06-20
### Added
* [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
ENV NODE_VERSION 24.14
ENV TINI_VERSION v0.19.0
# Install Node.js
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
\. "$HOME/.nvm/nvm.sh" && \
nvm install ${NODE_VERSION}
# Install tini
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini
RUN chmod +x /sbin/tini

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",
"type": "module",
"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",
"format": "oxfmt src test config/test ./*.config.ts",
"format:check": "node --run format -- --check",
"types": "tsc",
"start": "vite serve --host=0.0.0.0",
"preview": "vite preview --host=0.0.0.0",
@@ -51,8 +55,7 @@
"workbox-strategies": "^7.4.0"
},
"devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~3.7.0",
"@stylistic/eslint-plugin": "^5.7.1",
"@shlinkio/eslint-config-js-coding-standard": "~3.9.2",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
@@ -67,18 +70,13 @@
"adm-zip": "^0.6.0",
"axe-core": "^4.12.1",
"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",
"oxfmt": "^0.60.0",
"oxlint": "^1.75.0",
"oxlint-tsgolint": "^7.0.2001",
"playwright": "^1.62.0",
"tailwindcss": "^4.1.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^8.1.5",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.0.3"

View File

@@ -9,14 +9,18 @@ const apiClients: Map<string, ShlinkApiClient> = new Map();
const getSelectedServerFromState = (getState: GetState): ServerWithId => {
const { selectedServer } = getState();
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;
};
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)
: getStateOrSelectedServer;
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;

View File

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

View File

@@ -1,6 +1,6 @@
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card, CloseButton,useToggle } from '@shlinkio/shlink-frontend-kit';
import { Button, Card, CloseButton, useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useCallback } from 'react';
@@ -25,10 +25,7 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, for
return (
<Card
role="alert"
className={clsx(
'w-[700px] max-w-[calc(100%-30px)]',
'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]',
)}
className={clsx('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">
<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">
Restart it to enjoy the new features.
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
{!isUpdating && (
<>
Restart now <FontAwesomeIcon icon={reloadIcon} />
</>
)}
{isUpdating && <>Restarting...</>}
</Button>
</Card.Body>

View File

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

View File

@@ -36,15 +36,12 @@ export const Home: FC = withoutSelectedServer(() => {
</div>
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow">
<h1
className={clsx(
'p-4 text-center border-lm-border dark:border-dm-border',
{ 'border-b': !hasServers },
)}
>
<h1 className={clsx('p-4 text-center border-lm-border dark:border-dm-border', { 'border-b': !hasServers })}>
Welcome!
</h1>
{hasServers ? <ServersListGroup servers={serversList} /> : (
{hasServers ? (
<ServersListGroup servers={serversList} />
) : (
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,16 +20,13 @@ export type ShlinkWebComponentContainerProps = {
buildShlinkApiClient: ShlinkApiClientBuilder;
};
const ShlinkWebComponentContainerBase: FC<
ShlinkWebComponentContainerProps
// 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.
// This should be revisited at some point.
> = withSelectedServer(memo(({
buildShlinkApiClient,
TagColorsStorage: tagColorsStorage,
}) => {
const ShlinkWebComponentContainerBase: FC<ShlinkWebComponentContainerProps> =
// 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.
// This should be revisited at some point.
withSelectedServer(
memo(({ buildShlinkApiClient, TagColorsStorage: tagColorsStorage }) => {
const { selectedServer } = useSelectedServer();
const { settings } = useSettings();
@@ -54,7 +51,8 @@ const ShlinkWebComponentContainerBase: FC<
/>
</ShlinkSidebarVisibilityProvider>
);
}));
}),
);
export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [
'buildShlinkApiClient',

View File

@@ -8,18 +8,10 @@ export interface 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">
<g fill={color}>
<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"
/>
<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
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"
/>
<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" />
<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 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>
</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.
* All dependencies become optional props so that they can still be explicitly set in tests if desired.
*/
export function withDependencies<
Props extends Record<string, unknown>,
DependencyName extends string & keyof Props,
>(
export function withDependencies<Props extends Record<string, unknown>, DependencyName extends string & keyof Props>(
Component: ComponentType<Props>,
dependencyNames: 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 { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle();
const [serverData, setServerData] = useState<ServerData>();
const saveNewServer = useCallback((newServerData: ServerData) => {
const saveNewServer = useCallback(
(newServerData: ServerData) => {
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
createServers([newServerWithUniqueId]);
navigate(`/server/${newServerWithUniqueId.id}`);
}, [createServers, navigate, servers]);
const onSubmit = useCallback((newServerData: ServerData) => {
},
[createServers, navigate, servers],
);
const onSubmit = useCallback(
(newServerData: ServerData) => {
setServerData(newServerData);
const serverExists = Object.values(servers).some(
@@ -56,7 +60,9 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
} else {
saveNewServer(newServerData);
}
}, [saveNewServer, servers, toggleConfirmModal]);
},
[saveNewServer, servers, toggleConfirmModal],
);
return (
<NoMenuLayout>
@@ -64,7 +70,11 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
{!hasServers && (
<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>
</ServerForm>

View File

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

View File

@@ -13,11 +13,14 @@ export type DeleteServerModalProps = {
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
const { deleteServer } = useServers();
const onClosed = useCallback((exitAction: ExitAction) => {
const onClosed = useCallback(
(exitAction: ExitAction) => {
if (exitAction === 'confirm') {
deleteServer(server);
}
}, [deleteServer, server]);
},
[deleteServer, server],
);
return (
<CardModal
@@ -30,11 +33,13 @@ export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose,
confirmText="Delete"
>
<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>
<i>
No data will be deleted, only the access to this server will be removed from this device.
You can create it again at any moment.
No data will be deleted, only the access to this server will be removed from this device. You can create it
again at any moment.
</i>
</p>
</div>

View File

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

View File

@@ -19,10 +19,8 @@ export type ManageServersProps = {
const SHOW_IMPORT_MSG_TIME = 4000;
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
ServersExporter: serversExporter,
useTimeoutToggle,
}) => {
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(
({ ServersExporter: serversExporter, useTimeoutToggle }) => {
const { servers } = useServers();
const [searchTerm, setSearchTerm] = useState('');
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 gap-2">
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>
Import servers
</ImportServersBtn>
{filteredServers.length > 0 && (
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
@@ -53,18 +53,26 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
</div>
<SimpleCard className="card">
<Table header={(
<Table
header={
<Table.Row>
{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>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>
)}>
}
>
{!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) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
@@ -79,6 +87,7 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
)}
</NoMenuLayout>
);
});
},
);
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">
<Link to={`/server/${server.id}`}>{server.name}</Link>
</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">
<ManageServersRowDropdown server={server} />
</Table.Cell>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,7 @@ import { hasServerData } from '../data';
import { ensureUniqueIds } from '../helpers';
import { createServers, useServers } from './servers';
const responseToServersList = (data: any) => ensureUniqueIds(
{},
(Array.isArray(data) ? data.filter(hasServerData) : []),
);
const responseToServersList = (data: any) => ensureUniqueIds({}, Array.isArray(data) ? data.filter(hasServerData) : []);
export const fetchServers = createAsyncThunk(
'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 LATEST_VERSION_CONSTRAINT = 'latest';
const versionToSemVer = (version: string) => toSemVer(
version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
MIN_FALLBACK_VERSION,
);
const versionToSemVer = (version: string) =>
toSemVer(version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version, MIN_FALLBACK_VERSION);
const getServerVersion = memoizeWith(
(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),
printableVersion: versionToPrintable(version),
})),

View File

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

View File

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

View File

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

View File

@@ -14,13 +14,14 @@ const localStorageConfig: RLSOptions = {
const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig));
const isProduction = process.env.NODE_ENV === 'production';
export const setUpStore = (preloadedState = getStateFromLocalStorage()) => configureStore({
export const setUpStore = (preloadedState = getStateFromLocalStorage()) =>
configureStore({
devTools: !isProduction,
reducer: initReducers(),
preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
defaultMiddlewaresIncludingReduxThunk().concat(save(localStorageConfig)),
});
});
export type StoreType = ReturnType<typeof setUpStore>;
export type AppDispatch = StoreType['dispatch'];

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import { Parser } from '@json2csv/plainjs';
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);
});
});
export type CsvToJson = typeof csvToJson;

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ export class LocalStorage {
public readonly get = <T>(key: string): T | undefined => {
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));

View File

@@ -2,8 +2,7 @@ import type { TagColorsStorage as BaseTagColorsStorage } from '@shlinkio/shlink-
import type { LocalStorage } from './LocalStorage';
export class TagColorsStorage implements BaseTagColorsStorage {
constructor(private readonly storage: LocalStorage) {
}
constructor(private readonly storage: LocalStorage) {}
getTagColors(): Record<string, string> {
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
*/
export const usePreventDefault = <Event extends SyntheticEvent = SyntheticEvent>(handler: (e: Event) => void) =>
useCallback((e: Event) => {
useCallback(
(e: Event) => {
e.preventDefault();
handler(e);
}, [handler]);
},
[handler],
);

View File

@@ -1,14 +1,12 @@
import type { FC, ReactElement } from 'react';
import { useCallback, useEffect , useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
export type RenderModalArgs = {
open: boolean;
onClose: () => void;
};
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = (
{ renderModal },
) => {
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = ({ renderModal }) => {
const [open, setOpen] = useState(true);
const onClose = useCallback(() => setOpen(false), []);

View File

@@ -44,12 +44,15 @@ describe('ShlinkApiClientBuilder', () => {
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,
headers: {
'X-Api-Key': apiKey,
},
}));
}),
);
});
it('includes credentials when forwarding is enabled', async () => {
@@ -61,11 +64,14 @@ describe('ShlinkApiClientBuilder', () => {
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',
headers: {
'X-Api-Key': apiKey,
},
}));
}),
);
});
});

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,8 @@ describe('<MainHeader />', () => {
])('sets link to settings as active only when current path is settings', (currentPath, isActive) => {
setUp(currentPath);
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';
describe('<NotFound />', () => {
const setUp = (props = {}) => render(<MemoryRouter><NotFound {...props} /></MemoryRouter>);
const setUp = (props = {}) =>
render(
<MemoryRouter>
<NotFound {...props} />
</MemoryRouter>,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
@@ -15,9 +20,11 @@ describe('<NotFound />', () => {
it('shows expected error message', () => {
setUp();
expect(screen.getByText(
'Use your browser\'s back button to navigate to the page you have previously come from, or just press this button.',
)).toBeInTheDocument();
expect(
screen.getByText(
"Use your browser's back button to navigate to the page you have previously come from, or just press this button.",
),
).toBeInTheDocument();
});
it.each([

View File

@@ -4,15 +4,16 @@ import { ScrollToTop } from '../../src/common/ScrollToTop';
import { checkAccessibility } from '../__helpers__/accessibility';
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.each([
['Foobar'],
['Barfoo'],
['Something'],
])('just renders children', (children) => {
it.each([['Foobar'], ['Barfoo'], ['Something']])('just renders children', (children) => {
setUp(children);
expect(screen.getByText(children)).toBeInTheDocument();
});

View File

@@ -8,9 +8,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
describe('<ShlinkVersions />', () => {
const setUp = (props: ShlinkVersionsProps) => render(<ShlinkVersions {...props} />);
it('passes a11y checks', () => checkAccessibility(
setUp({ selectedServer: fromPartial({ version: '1.0.0', printableVersion: '1.0.0' }) }),
));
it('passes a11y checks', () =>
checkAccessibility(setUp({ selectedServer: fromPartial({ version: '1.0.0', printableVersion: '1.0.0' }) })));
it.each([
['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';
describe('<ShlinkVersionsContainer />', () => {
const setUp = (selectedServer: SelectedServer = null) => renderWithStore(<ShlinkVersionsContainer />, {
const setUp = (selectedServer: SelectedServer = null) =>
renderWithStore(<ShlinkVersionsContainer />, {
initialState: { selectedServer },
});

View File

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

View File

@@ -6,7 +6,7 @@ describe('context', () => {
describe('useDependencies', () => {
let lastDependencies: unknown[];
function TestComponent({ name}: { name: string }) {
function TestComponent({ name }: { name: string }) {
// eslint-disable-next-line react-compiler/react-compiler
lastDependencies = useDependencies(name);
return null;
@@ -19,11 +19,13 @@ describe('context', () => {
});
it('throws when requested dependency is not found in container', () => {
expect(() => render(
expect(() =>
render(
<ContainerProvider value={fromPartial({})}>
<TestComponent name="foo" />
</ContainerProvider>,
)).toThrowError('Dependency with name "foo" not found in container');
),
).toThrowError('Dependency with name "foo" not found in container');
});
it('gets dependency from container', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,9 +8,12 @@ import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DuplicatedServersModal />', () => {
const onClose = 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} />,
));
),
);
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
it('passes a11y checks', () => checkAccessibility(setUp()));
@@ -60,10 +63,7 @@ describe('<DuplicatedServersModal />', () => {
it.each([
[[]],
[[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) => {
await setUp(duplicatedServers);

View File

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

View File

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

View File

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

View File

@@ -21,13 +21,16 @@ describe('selectedServerReducer', () => {
it('returns selected server when action is SELECT_SERVER', () => {
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', () => {
const version = '1.19.0';
const createGetStateMock = (id: string) => vi.fn().mockReturnValue({
const createGetStateMock = (id: string) =>
vi.fn().mockReturnValue({
servers: {
[id]: { id },
},

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest';
describe('<Settings />', () => {
const setUp = () => renderWithStore(
const setUp = () =>
renderWithStore(
<MemoryRouter>
<Settings />
</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({
visits: {
defaultInterval: 'last180Days',
},
}),
}));
}),
);
});
});
});

View File

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

View File

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