Extract logic to determine if a list of servers contains duplicates

This commit is contained in:
Alejandro Celaya 2024-10-31 09:30:14 +01:00
parent 913264b0db
commit 9134d07969
5 changed files with 90 additions and 45 deletions

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

@ -9,6 +9,7 @@ import { componentFactory, useDependencies } from '../../container/utils';
import type { ServerData, ServersMap } from '../data'; import type { ServerData, ServersMap } from '../data';
import type { ServersImporter } from '../services/ServersImporter'; import type { ServersImporter } from '../services/ServersImporter';
import { DuplicatedServersModal } from './DuplicatedServersModal'; import { DuplicatedServersModal } from './DuplicatedServersModal';
import { dedupServers } from './index';
export type ImportServersBtnProps = PropsWithChildren<{ export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void; onImport?: () => void;
@ -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,7 +41,9 @@ 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<ServerData[]>([]);
const newServersRef = useRef<ServerData[]>([]);
const create = useCallback((serversData: ServerData[]) => { const create = useCallback((serversData: ServerData[]) => {
createServers(serversData); createServers(serversData);
onImport(); onImport();
@ -51,22 +51,21 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
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 = importedServers;
const dupServers = newServers.filter((server) => serversInclude(existingServers, server)); newServersRef.current = newServers;
const hasDuplicatedServers = !!dupServers.length;
if (!hasDuplicatedServers) { if (duplicatedServers.length === 0) {
create(newServers); create(importedServers);
} 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,50 @@
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 {
const url = new URL(server.url);
return `${server.name} ${url.host}`.toLowerCase().replace(/[^a-zA-Z0-9-_.~]/g, '-');
}
export function serverWithId(server: ServerWithId | ServerData): ServerWithId {
if ('id' in server) {
return server;
}
const id = idForServer(server);
return { ...server, id };
}
export function serversListToMap(servers: ServerWithId[]): ServersMap {
return servers.reduce<ServersMap>(
(acc, server) => ({ ...acc, [server.id]: server }),
{},
);
}
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 };
}

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, serverWithId } 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,

View file

@ -1,4 +1,4 @@
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 { ServersMap, ServerWithId } from '../../../src/servers/data';
import type { import type {
@ -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,14 +55,13 @@ 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([
@ -70,15 +70,14 @@ describe('<ImportServersBtn />', () => {
])('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 existingServer = fromPartial<ServerWithId>({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' });
const newServer = fromPartial<ServerWithId>({ url: 'newUrl', apiKey: 'newApiKey' }); const newServer = fromPartial<ServerWithId>({ url: 'newUrl', apiKey: 'newApiKey' });
const { container, user } = setUp({}, { abc: existingServer }); const { user } = setUp({}, { abc: existingServer });
const input = container.querySelector('[type=file]'); const input = screen.getByTestId('csv-file-input');
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(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 ? [existingServer, newServer] : [newServer]);