Merge pull request #1361 from acelaya-forks/feature/predictable-server-ids

Feature/predictable server ids
This commit is contained in:
Alejandro Celaya 2024-11-01 12:51:47 +01:00 committed by GitHub
commit 238cefde73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 281 additions and 136 deletions

View file

@ -4,6 +4,27 @@ 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
* [#1360](https://github.com/shlinkio/shlink-web-client/issues/1360) Added ability for server IDs to be generated based on the server name and URL, instead of generating a random UUID.
This can improve sharing a predefined set of servers cia servers.json, env vars, or simply export and import your servers in some other device, and then be able to share server URLs which continue working.
All existing servers will keep their generated IDs in existing devices for backwards compatibility, but newly created servers will use the new approach.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.2.2] - 2024-10-19 ## [4.2.2] - 2024-10-19
### Added ### Added
* *Nothing* * *Nothing*

View file

@ -7,7 +7,7 @@
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE) [![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio) [![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social) [![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlink.io)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate) [![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
A ReactJS-based progressive web application for [Shlink](https://shlink.io). A ReactJS-based progressive web application for [Shlink](https://shlink.io).

19
package-lock.json generated
View file

@ -32,7 +32,6 @@
"react-router-dom": "^6.27.0", "react-router-dom": "^6.27.0",
"reactstrap": "^9.2.3", "reactstrap": "^9.2.3",
"redux-localstorage-simple": "^2.5.1", "redux-localstorage-simple": "^2.5.1",
"uuid": "^10.0.0",
"workbox-core": "^7.1.0", "workbox-core": "^7.1.0",
"workbox-expiration": "^7.1.0", "workbox-expiration": "^7.1.0",
"workbox-precaching": "^7.1.0", "workbox-precaching": "^7.1.0",
@ -11580,19 +11579,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/validate-npm-package-license": { "node_modules/validate-npm-package-license": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
@ -20190,11 +20176,6 @@
"version": "1.0.2", "version": "1.0.2",
"dev": true "dev": true
}, },
"uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="
},
"validate-npm-package-license": { "validate-npm-package-license": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",

View file

@ -49,7 +49,6 @@
"react-router-dom": "^6.27.0", "react-router-dom": "^6.27.0",
"reactstrap": "^9.2.3", "reactstrap": "^9.2.3",
"redux-localstorage-simple": "^2.5.1", "redux-localstorage-simple": "^2.5.1",
"uuid": "^10.0.0",
"workbox-core": "^7.1.0", "workbox-core": "^7.1.0",
"workbox-expiration": "^7.1.0", "workbox-expiration": "^7.1.0",
"workbox-precaching": "^7.1.0", "workbox-precaching": "^7.1.0",

View file

@ -4,6 +4,8 @@ set -e
ME=$(basename $0) ME=$(basename $0)
# In order to allow people to pre-configure a server in their shlink-web-client instance via env vars, this function
# dumps a servers.json file based on the values provided via env vars
setup_single_shlink_server() { setup_single_shlink_server() {
[ -n "$SHLINK_SERVER_URL" ] || return 0 [ -n "$SHLINK_SERVER_URL" ] || return 0
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0 [ -n "$SHLINK_SERVER_API_KEY" ] || return 0

View file

@ -50,8 +50,8 @@ const App: FCWithDeps<AppProps, AppDeps> = (
const isHome = location.pathname === '/'; const isHome = location.pathname === '/';
useEffect(() => { useEffect(() => {
// Try to fetch the remote servers if the list is empty at first // Try to fetch the remote servers if the list is empty during first render.
// We use a ref because we don't care if the servers list becomes empty later // We use a ref because we don't care if the servers list becomes empty later.
if (Object.keys(initialServers.current).length === 0) { if (Object.keys(initialServers.current).length === 0) {
fetchServers(); fetchServers();
} }

View file

@ -8,8 +8,8 @@ import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import { randomUUID } from '../utils/utils';
import type { ServerData, ServersMap, ServerWithId } from './data'; import type { ServerData, ServersMap, ServerWithId } from './data';
import { ensureUniqueIds } from './helpers';
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal'; import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
import type { ImportServersBtnProps } from './helpers/ImportServersBtn'; import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
@ -44,12 +44,12 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME); const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [isConfirmModalOpen, toggleConfirmModal] = useToggle(); const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [serverData, setServerData] = useState<ServerData>(); const [serverData, setServerData] = useState<ServerData>();
const saveNewServer = useCallback((theServerData: ServerData) => { const saveNewServer = useCallback((newServerData: ServerData) => {
const id = randomUUID(); const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
createServers([{ ...theServerData, id }]); createServers([newServerWithUniqueId]);
navigate(`/server/${id}`); navigate(`/server/${newServerWithUniqueId.id}`);
}, [createServers, navigate]); }, [createServers, navigate, servers]);
const onSubmit = useCallback((newServerData: ServerData) => { const onSubmit = useCallback((newServerData: ServerData) => {
setServerData(newServerData); setServerData(newServerData);

View file

@ -6,9 +6,10 @@ import { useCallback, useRef, useState } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../../container/utils'; import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils'; import { componentFactory, useDependencies } from '../../container/utils';
import type { ServerData, ServersMap } from '../data'; import type { ServerData, ServersMap, ServerWithId } from '../data';
import type { ServersImporter } from '../services/ServersImporter'; import type { ServersImporter } from '../services/ServersImporter';
import { DuplicatedServersModal } from './DuplicatedServersModal'; import { DuplicatedServersModal } from './DuplicatedServersModal';
import { dedupServers, ensureUniqueIds } from './index';
export type ImportServersBtnProps = PropsWithChildren<{ export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void; onImport?: () => void;
@ -18,7 +19,7 @@ export type ImportServersBtnProps = PropsWithChildren<{
}>; }>;
type ImportServersBtnConnectProps = ImportServersBtnProps & { type ImportServersBtnConnectProps = ImportServersBtnProps & {
createServers: (servers: ServerData[]) => void; createServers: (servers: ServerWithId[]) => void;
servers: ServersMap; servers: ServersMap;
}; };
@ -26,9 +27,6 @@ type ImportServersBtnDeps = {
ServersImporter: ServersImporter ServersImporter: ServersImporter
}; };
const serversInclude = (servers: ServerData[], { url, apiKey }: ServerData) =>
servers.some((server) => server.url === url && server.apiKey === apiKey);
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
createServers, createServers,
servers, servers,
@ -43,30 +41,31 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]); const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const serversToCreate = useRef<ServerData[]>([]); const importedServersRef = useRef<ServerWithId[]>([]);
const create = useCallback((serversData: ServerData[]) => { const newServersRef = useRef<ServerWithId[]>([]);
const create = useCallback((serversData: ServerWithId[]) => {
createServers(serversData); createServers(serversData);
onImport(); onImport();
}, [createServers, onImport]); }, [createServers, onImport]);
const onFile = useCallback( const onFile = useCallback(
async ({ target }: ChangeEvent<HTMLInputElement>) => async ({ target }: ChangeEvent<HTMLInputElement>) =>
serversImporter.importServersFromFile(target.files?.[0]) serversImporter.importServersFromFile(target.files?.[0])
.then((newServers) => { .then((importedServers) => {
serversToCreate.current = newServers; const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
const existingServers = Object.values(servers); importedServersRef.current = ensureUniqueIds(servers, importedServers);
const dupServers = newServers.filter((server) => serversInclude(existingServers, server)); newServersRef.current = ensureUniqueIds(servers, newServers);
const hasDuplicatedServers = !!dupServers.length;
if (!hasDuplicatedServers) { if (duplicatedServers.length === 0) {
create(newServers); create(importedServersRef.current);
} else { } else {
setDuplicatedServers(dupServers); setDuplicatedServers(duplicatedServers);
showModal(); showModal();
} }
}) })
.then(() => { .then(() => {
// Reset input after processing file // Reset file input after processing file
(target as { value: string | null }).value = null; (target as { value: string | null }).value = null;
}) })
.catch(onImportError), .catch(onImportError),
@ -74,13 +73,13 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
); );
const createAllServers = useCallback(() => { const createAllServers = useCallback(() => {
create(serversToCreate.current); create(importedServersRef.current);
hideModal(); hideModal();
}, [create, hideModal, serversToCreate]); }, [create, hideModal]);
const createNonDuplicatedServers = useCallback(() => { const createNonDuplicatedServers = useCallback(() => {
create(serversToCreate.current.filter((server) => !serversInclude(duplicatedServers, server))); create(newServersRef.current);
hideModal(); hideModal();
}, [create, duplicatedServers, hideModal]); }, [create, hideModal]);
return ( return (
<> <>
@ -91,7 +90,15 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns. You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
</UncontrolledTooltip> </UncontrolledTooltip>
<input type="file" accept=".csv" className="d-none" ref={ref} onChange={onFile} aria-hidden /> <input
type="file"
accept=".csv"
className="d-none"
aria-hidden
ref={ref}
onChange={onFile}
data-testid="csv-file-input"
/>
<DuplicatedServersModal <DuplicatedServersModal
isOpen={isModalOpen} isOpen={isModalOpen}

View file

@ -0,0 +1,85 @@
import { groupBy } from '@shlinkio/data-manipulation';
import type { ServerData, ServersMap, ServerWithId } from '../data';
/**
* Builds a potentially unique ID for a server, based on concatenating their name and the hostname of their domain, all
* in lowercase and replacing invalid URL characters with hyphens.
*/
function idForServer(server: ServerData): string {
let urlSegment = server.url;
try {
const { host, pathname } = new URL(urlSegment);
urlSegment = host;
// Remove leading slash from pathname
const normalizedPathname = pathname.substring(1);
// Include pathname in the ID, if not empty
if (normalizedPathname.length > 0) {
urlSegment = `${urlSegment} ${normalizedPathname}`;
}
} catch {
// If the server URL is not valid, use the value as is
}
return `${server.name} ${urlSegment}`.toLowerCase().replace(/[^a-zA-Z0-9-_.~]/g, '-');
}
export function serversListToMap(servers: ServerWithId[]): ServersMap {
const serversMap: ServersMap = {};
servers.forEach((server) => {
serversMap[server.id] = server;
});
return serversMap;
}
const serversInclude = (serversList: ServerData[], { url, apiKey }: ServerData) =>
serversList.some((server) => server.url === url && server.apiKey === apiKey);
export type DedupServersResult = {
/** Servers which already exist in the reference list */
duplicatedServers: ServerData[];
/** Servers which are new based on a reference list */
newServers: ServerData[];
};
/**
* Given a list of new servers, checks which of them already exist in a servers map, and which don't
*/
export function dedupServers(servers: ServersMap, serversToAdd: ServerData[]): DedupServersResult {
const serversList = Object.values(servers);
const { duplicatedServers = [], newServers = [] } = groupBy(
serversToAdd,
(server) => serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers',
);
return { duplicatedServers, newServers };
}
/**
* Given a servers map and a list of servers, return the same list of servers but all with an ID, ensuring the ID is
* unique both among all those servers and existing ones
*/
export function ensureUniqueIds(existingServers: ServersMap, serversList: ServerData[]): ServerWithId[] {
const existingIds = new Set(Object.keys(existingServers));
const serversWithId: ServerWithId[] = [];
serversList.forEach((server) => {
const baseId = idForServer(server);
let id = baseId;
let iterations = 1;
while (existingIds.has(id)) {
id = `${baseId}-${iterations}`;
iterations++;
}
serversWithId.push({ ...server, id });
// Add this server's ID to the list, so that it is taken into consideration for the next ones
existingIds.add(id);
});
return serversWithId;
}

View file

@ -1,11 +1,14 @@
import type { HttpClient } from '@shlinkio/shlink-js-sdk'; import type { HttpClient } from '@shlinkio/shlink-js-sdk';
import pack from '../../../package.json'; import pack from '../../../package.json';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
import type { ServerData } from '../data';
import { hasServerData } from '../data'; import { hasServerData } from '../data';
import { ensureUniqueIds } from '../helpers';
import { createServers } from './servers'; import { createServers } from './servers';
const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []); const responseToServersList = (data: any) => ensureUniqueIds(
{},
(Array.isArray(data) ? data.filter(hasServerData) : []),
);
export const fetchServers = (httpClient: HttpClient) => createAsyncThunk( export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
'shlink/remoteServers/fetchServers', 'shlink/remoteServers/fetchServers',

View file

@ -1,7 +1,7 @@
import type { PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { randomUUID } from '../../utils/utils';
import type { ServerData, ServersMap, ServerWithId } from '../data'; import type { ServerData, ServersMap, ServerWithId } from '../data';
import { serversListToMap } from '../helpers';
interface EditServer { interface EditServer {
serverId: string; serverId: string;
@ -15,19 +15,6 @@ interface SetAutoConnect {
const initialState: ServersMap = {}; const initialState: ServersMap = {};
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
if ('id' in server) {
return server;
}
return { ...server, id: randomUUID() };
};
const serversListToMap = (servers: ServerWithId[]): ServersMap => servers.reduce<ServersMap>(
(acc, server) => ({ ...acc, [server.id]: server }),
{},
);
export const { actions, reducer } = createSlice({ export const { actions, reducer } = createSlice({
name: 'shlink/servers', name: 'shlink/servers',
initialState, initialState,
@ -70,10 +57,7 @@ export const { actions, reducer } = createSlice({
}, },
}, },
createServers: { createServers: {
prepare: (servers: ServerData[]) => { prepare: (servers: ServerWithId[]) => ({ payload: serversListToMap(servers) }),
const payload = serversListToMap(servers.map(serverWithId));
return { payload };
},
reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }), reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }),
}, },
}, },

View file

@ -1,5 +0,0 @@
import type { FC, PropsWithChildren } from 'react';
export const FormText: FC<PropsWithChildren<unknown>> = ({ children }) => (
<small className="form-text text-muted d-block">{children}</small>
);

View file

@ -1,9 +1,6 @@
import type { SyntheticEvent } from 'react'; import type { SyntheticEvent } from 'react';
import { v4 } from 'uuid';
export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => { export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
handler(); handler();
}; };
export const randomUUID = () => v4();

View file

@ -1,6 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import type { ServersMap, ServerWithId } from '../../../src/servers/data'; import type { ServerData, ServersMap, ServerWithId } from '../../../src/servers/data';
import type { import type {
ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn'; ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn';
import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn'; import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn';
@ -9,6 +9,7 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithEvents } from '../../__helpers__/setUpTest'; import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<ImportServersBtn />', () => { describe('<ImportServersBtn />', () => {
const csvFile = new File([''], 'servers.csv', { type: 'text/csv' });
const onImportMock = vi.fn(); const onImportMock = vi.fn();
const createServersMock = vi.fn(); const createServersMock = vi.fn();
const importServersFromFile = vi.fn().mockResolvedValue([]); const importServersFromFile = vi.fn().mockResolvedValue([]);
@ -54,34 +55,43 @@ describe('<ImportServersBtn />', () => {
}); });
it('imports servers when file input changes', async () => { it('imports servers when file input changes', async () => {
const { container } = setUp(); const { user } = setUp();
const input = container.querySelector('[type=file]');
const input = screen.getByTestId('csv-file-input');
await user.upload(input, csvFile);
if (input) {
fireEvent.change(input, { target: { files: [''] } });
}
expect(importServersFromFile).toHaveBeenCalledTimes(1); expect(importServersFromFile).toHaveBeenCalledTimes(1);
await waitFor(() => expect(createServersMock).toHaveBeenCalledTimes(1)); expect(createServersMock).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
['Save anyway', true], { btnName: 'Save anyway',savesDuplicatedServers: true },
['Discard', false], { btnName: 'Discard', savesDuplicatedServers: false },
])('creates expected servers depending on selected option in modal', async (btnName, savesDuplicatedServers) => { ])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
const existingServer = fromPartial<ServerWithId>({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' }); const existingServerData: ServerData = {
const newServer = fromPartial<ServerWithId>({ url: 'newUrl', apiKey: 'newApiKey' }); name: 'existingServer',
const { container, user } = setUp({}, { abc: existingServer }); url: 'http://s.test/existingUrl',
const input = container.querySelector('[type=file]'); apiKey: 'existingApiKey',
};
const existingServer: ServerWithId = {
...existingServerData,
id: 'existingserver-s.test',
};
const newServer: ServerData = { name: 'newServer', url: 'http://s.test/newUrl', apiKey: 'newApiKey' };
const { user } = setUp({}, { [existingServer.id]: existingServer });
importServersFromFile.mockResolvedValue([existingServer, newServer]); importServersFromFile.mockResolvedValue([existingServer, newServer]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
if (input) { await user.upload(screen.getByTestId('csv-file-input'), csvFile);
fireEvent.change(input, { target: { files: [''] } }); expect(screen.getByRole('dialog')).toBeInTheDocument();
}
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: btnName })); await user.click(screen.getByRole('button', { name: btnName }));
expect(createServersMock).toHaveBeenCalledWith(savesDuplicatedServers ? [existingServer, newServer] : [newServer]); expect(createServersMock).toHaveBeenCalledWith(
savesDuplicatedServers
? [expect.objectContaining(existingServerData), expect.objectContaining(newServer)]
: [expect.objectContaining(newServer)],
);
expect(onImportMock).toHaveBeenCalledTimes(1); expect(onImportMock).toHaveBeenCalledTimes(1);
}); });
}); });

View file

@ -0,0 +1,69 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ServersMap } from '../../../src/servers/data';
import { ensureUniqueIds } from '../../../src/servers/helpers';
describe('index', () => {
describe('ensureUniqueIds', () => {
const servers: ServersMap = {
'the-name-example.com': fromPartial({}),
'another-name-example.com': fromPartial({}),
'short-domain-s.test': fromPartial({}),
};
it('returns expected list of servers when existing IDs conflict', () => {
const result = ensureUniqueIds(servers, [
fromPartial({ name: 'The name', url: 'https://example.com' }),
fromPartial({ name: 'Short domain', url: 'https://s.test' }),
fromPartial({ name: 'The name', url: 'https://example.com' }),
]);
expect(result).toEqual([
expect.objectContaining({ id: 'the-name-example.com-1' }),
expect.objectContaining({ id: 'short-domain-s.test-1' }),
expect.objectContaining({ id: 'the-name-example.com-2' }),
]);
});
it('returns expected list of servers when IDs conflict in provided list of servers', () => {
const result = ensureUniqueIds(servers, [
fromPartial({ name: 'Foo', url: 'https://example.com' }),
fromPartial({ name: 'Bar', url: 'https://s.test' }),
fromPartial({ name: 'Foo', url: 'https://example.com' }),
fromPartial({ name: 'Baz', url: 'https://s.test' }),
]);
expect(result).toEqual([
expect.objectContaining({ id: 'foo-example.com' }),
expect.objectContaining({ id: 'bar-s.test' }),
expect.objectContaining({ id: 'foo-example.com-1' }),
expect.objectContaining({ id: 'baz-s.test' }),
]);
});
it('includes server paths when not empty', () => {
const result = ensureUniqueIds({}, [
fromPartial({ name: 'Foo', url: 'https://example.com' }),
fromPartial({ name: 'Bar', url: 'https://s.test/some/path' }),
fromPartial({ name: 'Baz', url: 'https://s.test/some/other-path-here/123' }),
]);
expect(result).toEqual([
expect.objectContaining({ id: 'foo-example.com' }),
expect.objectContaining({ id: 'bar-s.test-some-path' }),
expect.objectContaining({ id: 'baz-s.test-some-other-path-here-123' }),
]);
});
it('uses server URL verbatim when it is not a valid URL', () => {
const result = ensureUniqueIds({}, [
fromPartial({ name: 'Foo', url: 'invalid' }),
fromPartial({ name: 'Bar', url: 'this is not a URL' }),
]);
expect(result).toEqual([
expect.objectContaining({ id: 'foo-invalid' }),
expect.objectContaining({ id: 'bar-this-is-not-a-url' }),
]);
});
});
});

View file

@ -9,74 +9,76 @@ describe('remoteServersReducer', () => {
const httpClient = fromPartial<HttpClient>({ jsonRequest }); const httpClient = fromPartial<HttpClient>({ jsonRequest });
it.each([ it.each([
[ {
[ serversArray: [
{ {
id: '111',
name: 'acel.me from servers.json', name: 'acel.me from servers.json',
url: 'https://acel.me', url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
}, },
{ {
id: '222',
name: 'Local from servers.json', name: 'Local from servers.json',
url: 'http://localhost:8000', url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
}, },
], ],
{ expectedNewServers: {
111: { 'acel.me-from-servers.json-acel.me': {
id: '111', id: 'acel.me-from-servers.json-acel.me',
name: 'acel.me from servers.json', name: 'acel.me from servers.json',
url: 'https://acel.me', url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
}, },
222: { 'local-from-servers.json-localhost-8000': {
id: '222', id: 'local-from-servers.json-localhost-8000',
name: 'Local from servers.json', name: 'Local from servers.json',
url: 'http://localhost:8000', url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
}, },
}, },
], },
[ {
[ serversArray: [
{ {
id: '111',
name: 'acel.me from servers.json', name: 'acel.me from servers.json',
url: 'https://acel.me', url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
}, },
{ {
id: '222',
name: 'Invalid', name: 'Invalid',
}, },
{ {
id: '333',
name: 'Local from servers.json', name: 'Local from servers.json',
url: 'http://localhost:8000', url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
}, },
], ],
{ expectedNewServers: {
111: { 'acel.me-from-servers.json-acel.me': {
id: '111', id: 'acel.me-from-servers.json-acel.me',
name: 'acel.me from servers.json', name: 'acel.me from servers.json',
url: 'https://acel.me', url: 'https://acel.me',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
}, },
333: { 'local-from-servers.json-localhost-8000': {
id: '333', id: 'local-from-servers.json-localhost-8000',
name: 'Local from servers.json', name: 'Local from servers.json',
url: 'http://localhost:8000', url: 'http://localhost:8000',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
}, },
}, },
], },
['<html></html>', {}], {
[{}, {}], serversArray: '<html></html>',
])('tries to fetch servers from remote', async (mockedValue, expectedNewServers) => { expectedNewServers: {},
jsonRequest.mockResolvedValue(mockedValue); },
{
serversArray: {},
expectedNewServers: {},
},
])('tries to fetch servers from remote', async ({ serversArray, expectedNewServers }) => {
jsonRequest.mockResolvedValue(serversArray);
const doFetchServers = fetchServers(httpClient); const doFetchServers = fetchServers(httpClient);
await doFetchServers()(dispatch, vi.fn(), {}); await doFetchServers()(dispatch, vi.fn(), {});

View file

@ -9,7 +9,6 @@ import {
selectedServerReducerCreator, selectedServerReducerCreator,
selectServer as selectServerCreator, selectServer as selectServerCreator,
} from '../../../src/servers/reducers/selectedServer'; } from '../../../src/servers/reducers/selectedServer';
import { randomUUID } from '../../../src/utils/utils';
describe('selectedServerReducer', () => { describe('selectedServerReducer', () => {
const dispatch = vi.fn(); const dispatch = vi.fn();
@ -41,7 +40,7 @@ describe('selectedServerReducer', () => {
['latest', MAX_FALLBACK_VERSION, 'latest'], ['latest', MAX_FALLBACK_VERSION, 'latest'],
['%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%'], ['%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%'],
])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => { ])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
const id = randomUUID(); const id = crypto.randomUUID();
const getState = createGetStateMock(id); const getState = createGetStateMock(id);
const expectedSelectedServer = { const expectedSelectedServer = {
id, id,
@ -60,7 +59,7 @@ describe('selectedServerReducer', () => {
}); });
it('dispatches error when health endpoint fails', async () => { it('dispatches error when health endpoint fails', async () => {
const id = randomUUID(); const id = crypto.randomUUID();
const getState = createGetStateMock(id); const getState = createGetStateMock(id);
const expectedSelectedServer = fromPartial<NonReachableServer>({ id, serverNotReachable: true }); const expectedSelectedServer = fromPartial<NonReachableServer>({ id, serverNotReachable: true });
@ -73,7 +72,7 @@ describe('selectedServerReducer', () => {
}); });
it('dispatches error when server is not found', async () => { it('dispatches error when server is not found', async () => {
const id = randomUUID(); const id = crypto.randomUUID();
const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} })); const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} }));
const expectedSelectedServer: NotFoundServer = { serverNotFound: true }; const expectedSelectedServer: NotFoundServer = { serverNotFound: true };

View file

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