diff --git a/scripts/docker/servers_from_env.sh b/scripts/docker/servers_from_env.sh index 4275f591..8c49f674 100755 --- a/scripts/docker/servers_from_env.sh +++ b/scripts/docker/servers_from_env.sh @@ -4,6 +4,8 @@ set -e 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() { [ -n "$SHLINK_SERVER_URL" ] || return 0 [ -n "$SHLINK_SERVER_API_KEY" ] || return 0 diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index 161e49cd..f77faba1 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -9,6 +9,7 @@ import { componentFactory, useDependencies } from '../../container/utils'; import type { ServerData, ServersMap } from '../data'; import type { ServersImporter } from '../services/ServersImporter'; import { DuplicatedServersModal } from './DuplicatedServersModal'; +import { dedupServers } from './index'; export type ImportServersBtnProps = PropsWithChildren<{ onImport?: () => void; @@ -26,9 +27,6 @@ type ImportServersBtnDeps = { ServersImporter: ServersImporter }; -const serversInclude = (servers: ServerData[], { url, apiKey }: ServerData) => - servers.some((server) => server.url === url && server.apiKey === apiKey); - const ImportServersBtn: FCWithDeps = ({ createServers, servers, @@ -43,7 +41,9 @@ const ImportServersBtn: FCWithDeps([]); const [isModalOpen,, showModal, hideModal] = useToggle(); - const serversToCreate = useRef([]); + const importedServersRef = useRef([]); + const newServersRef = useRef([]); + const create = useCallback((serversData: ServerData[]) => { createServers(serversData); onImport(); @@ -51,22 +51,21 @@ const ImportServersBtn: FCWithDeps) => serversImporter.importServersFromFile(target.files?.[0]) - .then((newServers) => { - serversToCreate.current = newServers; + .then((importedServers) => { + const { duplicatedServers, newServers } = dedupServers(servers, importedServers); - const existingServers = Object.values(servers); - const dupServers = newServers.filter((server) => serversInclude(existingServers, server)); - const hasDuplicatedServers = !!dupServers.length; + importedServersRef.current = importedServers; + newServersRef.current = newServers; - if (!hasDuplicatedServers) { - create(newServers); + if (duplicatedServers.length === 0) { + create(importedServers); } else { - setDuplicatedServers(dupServers); + setDuplicatedServers(duplicatedServers); showModal(); } }) .then(() => { - // Reset input after processing file + // Reset file input after processing file (target as { value: string | null }).value = null; }) .catch(onImportError), @@ -74,13 +73,13 @@ const ImportServersBtn: FCWithDeps { - create(serversToCreate.current); + create(importedServersRef.current); hideModal(); - }, [create, hideModal, serversToCreate]); + }, [create, hideModal]); const createNonDuplicatedServers = useCallback(() => { - create(serversToCreate.current.filter((server) => !serversInclude(duplicatedServers, server))); + create(newServersRef.current); hideModal(); - }, [create, duplicatedServers, hideModal]); + }, [create, hideModal]); return ( <> @@ -91,7 +90,15 @@ const ImportServersBtn: FCWithDepsname, apiKey and url columns. - + ( + (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 }; +} diff --git a/src/servers/reducers/servers.ts b/src/servers/reducers/servers.ts index fe8220e7..7db3d41d 100644 --- a/src/servers/reducers/servers.ts +++ b/src/servers/reducers/servers.ts @@ -1,7 +1,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; -import { randomUUID } from '../../utils/utils'; import type { ServerData, ServersMap, ServerWithId } from '../data'; +import { serversListToMap, serverWithId } from '../helpers'; interface EditServer { serverId: string; @@ -15,19 +15,6 @@ interface SetAutoConnect { 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( - (acc, server) => ({ ...acc, [server.id]: server }), - {}, -); - export const { actions, reducer } = createSlice({ name: 'shlink/servers', initialState, diff --git a/test/servers/helpers/ImportServersBtn.test.tsx b/test/servers/helpers/ImportServersBtn.test.tsx index b3ba60c5..2a2ddfa0 100644 --- a/test/servers/helpers/ImportServersBtn.test.tsx +++ b/test/servers/helpers/ImportServersBtn.test.tsx @@ -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 type { ServersMap, ServerWithId } from '../../../src/servers/data'; import type { @@ -9,6 +9,7 @@ import { checkAccessibility } from '../../__helpers__/accessibility'; import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { + const csvFile = new File([''], 'servers.csv', { type: 'text/csv' }); const onImportMock = vi.fn(); const createServersMock = vi.fn(); const importServersFromFile = vi.fn().mockResolvedValue([]); @@ -54,14 +55,13 @@ describe('', () => { }); it('imports servers when file input changes', async () => { - const { container } = setUp(); - const input = container.querySelector('[type=file]'); + const { user } = setUp(); + + const input = screen.getByTestId('csv-file-input'); + await user.upload(input, csvFile); - if (input) { - fireEvent.change(input, { target: { files: [''] } }); - } expect(importServersFromFile).toHaveBeenCalledTimes(1); - await waitFor(() => expect(createServersMock).toHaveBeenCalledTimes(1)); + expect(createServersMock).toHaveBeenCalledTimes(1); }); it.each([ @@ -70,15 +70,14 @@ describe('', () => { ])('creates expected servers depending on selected option in modal', async (btnName, savesDuplicatedServers) => { const existingServer = fromPartial({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' }); const newServer = fromPartial({ url: 'newUrl', apiKey: 'newApiKey' }); - const { container, user } = setUp({}, { abc: existingServer }); - const input = container.querySelector('[type=file]'); + const { user } = setUp({}, { abc: existingServer }); + const input = screen.getByTestId('csv-file-input'); + importServersFromFile.mockResolvedValue([existingServer, newServer]); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - if (input) { - fireEvent.change(input, { target: { files: [''] } }); - } - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.upload(input, csvFile); + expect(screen.getByRole('dialog')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: btnName })); expect(createServersMock).toHaveBeenCalledWith(savesDuplicatedServers ? [existingServer, newServer] : [newServer]);