mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-17 13:03:50 +00:00
Migrate DuplicatedServersModal to tailwind
This commit is contained in:
@@ -71,7 +71,7 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
|
|||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm title="Add new server" onSubmit={onSubmit}>
|
<ServerForm title="Add new server" onSubmit={onSubmit}>
|
||||||
{!hasServers && (
|
{!hasServers && (
|
||||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
|
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
|
||||||
)}
|
)}
|
||||||
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
|
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
|
||||||
<Button type="submit">Create server</Button>
|
<Button type="submit">Create server</Button>
|
||||||
@@ -81,10 +81,10 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
|
|||||||
{errorImporting && <ImportResult variant="error" />}
|
{errorImporting && <ImportResult variant="error" />}
|
||||||
|
|
||||||
<DuplicatedServersModal
|
<DuplicatedServersModal
|
||||||
isOpen={isConfirmModalOpen}
|
open={isConfirmModalOpen}
|
||||||
duplicatedServers={serverData ? [serverData] : []}
|
duplicatedServers={serverData ? [serverData] : []}
|
||||||
onDiscard={goBack}
|
onClose={goBack}
|
||||||
onSave={() => serverData && saveNewServer(serverData)}
|
onConfirm={() => serverData && saveNewServer(serverData)}
|
||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
|
|||||||
|
|
||||||
<div className="tw:flex tw:flex-col tw:md:flex-row tw:gap-2">
|
<div className="tw:flex tw:flex-col tw:md:flex-row tw:gap-2">
|
||||||
<div className="tw:flex tw:gap-2">
|
<div className="tw:flex tw:gap-2">
|
||||||
<ImportServersBtn className="tw:flex-grow" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
<ImportServersBtn className="tw:flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||||
{filteredServers.length > 0 && (
|
{filteredServers.length > 0 && (
|
||||||
<Button variant="secondary" className="tw:flex-grow" onClick={async () => serversExporter.exportServers()}>
|
<Button variant="secondary" className="tw:flex-grow" onClick={async () => serversExporter.exportServers()}>
|
||||||
<FontAwesomeIcon icon={exportIcon} /> Export servers
|
<FontAwesomeIcon icon={exportIcon} /> Export servers
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
|
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
|
||||||
import type { ServerData } from '../data';
|
import type { ServerData } from '../data';
|
||||||
|
|
||||||
interface DuplicatedServersModalProps {
|
export type DuplicatedServersModalProps = {
|
||||||
duplicatedServers: ServerData[];
|
duplicatedServers: ServerData[];
|
||||||
isOpen: boolean;
|
open: boolean;
|
||||||
onDiscard: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onConfirm: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||||
{ isOpen, duplicatedServers, onDiscard, onSave },
|
{ open, duplicatedServers, onClose, onConfirm },
|
||||||
) => {
|
) => {
|
||||||
const hasMultipleServers = duplicatedServers.length > 1;
|
const hasMultipleServers = duplicatedServers.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal centered isOpen={isOpen}>
|
<CardModal
|
||||||
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
|
size="lg"
|
||||||
<ModalBody>
|
title={`Duplicated server${hasMultipleServers ? 's' : ''}`}
|
||||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
open={open}
|
||||||
<ul>
|
onClose={onClose}
|
||||||
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
onConfirm={onConfirm}
|
||||||
<Fragment key={index}>
|
confirmText={`Save duplicate${hasMultipleServers ? 's' : ''}`}
|
||||||
<li>URL: <b>{url}</b></li>
|
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
|
||||||
<li>API key: <b>{apiKey}</b></li>
|
>
|
||||||
</Fragment>
|
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
<ul className="tw:list-disc tw:mt-4">
|
||||||
</ul>
|
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
||||||
<span>
|
<Fragment key={index}>
|
||||||
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
<li>URL: <b>{url}</b></li>
|
||||||
</span>
|
<li>API key: <b>{apiKey}</b></li>
|
||||||
</ModalBody>
|
</Fragment>
|
||||||
<ModalFooter>
|
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
||||||
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button>
|
</ul>
|
||||||
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
<span>
|
||||||
</ModalFooter>
|
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
|
||||||
</Modal>
|
</span>
|
||||||
|
</CardModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
|
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { ChangeEvent, PropsWithChildren } from 'react';
|
import type { ChangeEvent, PropsWithChildren } from 'react';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef , useState } from 'react';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { 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';
|
||||||
@@ -14,7 +14,7 @@ import { dedupServers, ensureUniqueIds } from './index';
|
|||||||
|
|
||||||
export type ImportServersBtnProps = PropsWithChildren<{
|
export type ImportServersBtnProps = PropsWithChildren<{
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
onImportError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
tooltipPlacement?: 'top' | 'bottom';
|
tooltipPlacement?: 'top' | 'bottom';
|
||||||
className?: string;
|
className?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -32,8 +32,8 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||||||
createServers,
|
createServers,
|
||||||
servers,
|
servers,
|
||||||
children,
|
children,
|
||||||
onImport = () => {},
|
onImport,
|
||||||
onImportError = () => {},
|
onError = () => {},
|
||||||
tooltipPlacement = 'bottom',
|
tooltipPlacement = 'bottom',
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
@@ -41,41 +41,46 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||||||
const ref = useElementRef<HTMLInputElement>();
|
const ref = useElementRef<HTMLInputElement>();
|
||||||
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||||
|
const newServersCreatedRef = useRef(false);
|
||||||
|
|
||||||
const importedServersRef = useRef<ServerWithId[]>([]);
|
|
||||||
const newServersRef = useRef<ServerWithId[]>([]);
|
|
||||||
|
|
||||||
const create = useCallback((serversData: ServerWithId[]) => {
|
|
||||||
createServers(serversData);
|
|
||||||
hideModal();
|
|
||||||
onImport();
|
|
||||||
}, [createServers, hideModal, 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((importedServers) => {
|
.then((importedServers) => {
|
||||||
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
||||||
|
|
||||||
importedServersRef.current = ensureUniqueIds(servers, importedServers);
|
// Immediately create new servers
|
||||||
newServersRef.current = ensureUniqueIds(servers, newServers);
|
newServersCreatedRef.current = newServers.length > 0;
|
||||||
|
createServers(ensureUniqueIds(servers, newServers));
|
||||||
|
|
||||||
if (duplicatedServers.length === 0) {
|
// For duplicated servers, ask for confirmation
|
||||||
create(importedServersRef.current);
|
if (duplicatedServers.length > 0) {
|
||||||
} else {
|
|
||||||
setDuplicatedServers(duplicatedServers);
|
setDuplicatedServers(duplicatedServers);
|
||||||
showModal();
|
showModal();
|
||||||
|
} else {
|
||||||
|
onImport?.();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Reset file 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(onError),
|
||||||
[create, onImportError, servers, serversImporter, showModal],
|
[createServers, onError, onImport, servers, serversImporter, showModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createAllServers = useCallback(() => create(importedServersRef.current), [create]);
|
const createDuplicatedServers = useCallback(() => {
|
||||||
const createNonDuplicatedServers = useCallback(() => create(newServersRef.current), [create]);
|
createServers(ensureUniqueIds(servers, duplicatedServers));
|
||||||
|
hideModal();
|
||||||
|
onImport?.();
|
||||||
|
}, [createServers, duplicatedServers, hideModal, onImport, servers]);
|
||||||
|
const discardDuplicatedServers = useCallback(() => {
|
||||||
|
hideModal();
|
||||||
|
// If duplicated servers were discarded but some non-duplicated servers were created, call onImport
|
||||||
|
if (newServersCreatedRef.current) {
|
||||||
|
onImport?.();
|
||||||
|
}
|
||||||
|
}, [hideModal, onImport]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -97,10 +102,10 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DuplicatedServersModal
|
<DuplicatedServersModal
|
||||||
isOpen={isModalOpen}
|
open={isModalOpen}
|
||||||
duplicatedServers={duplicatedServers}
|
duplicatedServers={duplicatedServers}
|
||||||
onDiscard={createNonDuplicatedServers}
|
onClose={discardDuplicatedServers}
|
||||||
onSave={createAllServers}
|
onConfirm={createDuplicatedServers}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
|
|||||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<DuplicatedServersModal />', () => {
|
describe('<DuplicatedServersModal />', () => {
|
||||||
const onDiscard = vi.fn();
|
const onClose = vi.fn();
|
||||||
const onSave = vi.fn();
|
const onConfirm = vi.fn();
|
||||||
const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents(
|
const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents(
|
||||||
<DuplicatedServersModal isOpen duplicatedServers={duplicatedServers} onDiscard={onDiscard} onSave={onSave} />,
|
<DuplicatedServersModal open duplicatedServers={duplicatedServers} onClose={onClose} onConfirm={onConfirm} />,
|
||||||
));
|
));
|
||||||
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
|
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
|
||||||
|
|
||||||
@@ -32,8 +32,9 @@ describe('<DuplicatedServersModal />', () => {
|
|||||||
{
|
{
|
||||||
header: 'Duplicated server',
|
header: 'Duplicated server',
|
||||||
firstParagraph: 'There is already a server with:',
|
firstParagraph: 'There is already a server with:',
|
||||||
lastParagraph: 'Do you want to save this server anyway?',
|
lastParagraph: 'Do you want to save this server?',
|
||||||
discardBtn: 'Discard',
|
discardBtn: 'Discard',
|
||||||
|
confirmButton: 'Save duplicate',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -41,8 +42,9 @@ describe('<DuplicatedServersModal />', () => {
|
|||||||
{
|
{
|
||||||
header: 'Duplicated servers',
|
header: 'Duplicated servers',
|
||||||
firstParagraph: 'The next servers already exist:',
|
firstParagraph: 'The next servers already exist:',
|
||||||
lastParagraph: 'Do you want to ignore duplicated servers?',
|
lastParagraph: 'Do you want to save duplicated servers?',
|
||||||
discardBtn: 'Ignore duplicates',
|
discardBtn: 'Ignore duplicates',
|
||||||
|
confirmButton: 'Save duplicates',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
])('renders expected texts based on amount of servers', async (duplicatedServers, assertions) => {
|
])('renders expected texts based on amount of servers', async (duplicatedServers, assertions) => {
|
||||||
@@ -52,6 +54,7 @@ describe('<DuplicatedServersModal />', () => {
|
|||||||
expect(screen.getByText(assertions.firstParagraph)).toBeInTheDocument();
|
expect(screen.getByText(assertions.firstParagraph)).toBeInTheDocument();
|
||||||
expect(screen.getByText(assertions.lastParagraph)).toBeInTheDocument();
|
expect(screen.getByText(assertions.lastParagraph)).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: assertions.confirmButton })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -80,19 +83,19 @@ describe('<DuplicatedServersModal />', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invokes onDiscard when appropriate button is clicked', async () => {
|
it('invokes onClose when appropriate button is clicked', async () => {
|
||||||
const { user } = await setUp();
|
const { user } = await setUp();
|
||||||
|
|
||||||
expect(onDiscard).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
||||||
expect(onDiscard).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invokes onSave when appropriate button is clicked', async () => {
|
it('invokes onConfirm when appropriate button is clicked', async () => {
|
||||||
const { user } = await setUp();
|
const { user } = await setUp();
|
||||||
|
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
await user.click(screen.getByRole('button', { name: 'Save anyway' }));
|
await user.click(screen.getByRole('button', { name: 'Save duplicate' }));
|
||||||
expect(onSave).toHaveBeenCalled();
|
expect(onConfirm).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ describe('<ImportServersBtn />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ btnName: 'Save anyway',savesDuplicatedServers: true },
|
{ btnName: 'Save duplicate', savesDuplicatedServers: true },
|
||||||
{ btnName: 'Discard', savesDuplicatedServers: false },
|
{ btnName: 'Discard', savesDuplicatedServers: false },
|
||||||
])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
|
])('creates duplicated servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
|
||||||
const existingServerData: ServerData = {
|
const existingServerData: ServerData = {
|
||||||
name: 'existingServer',
|
name: 'existingServer',
|
||||||
url: 'http://s.test/existingUrl',
|
url: 'http://s.test/existingUrl',
|
||||||
@@ -84,14 +84,20 @@ describe('<ImportServersBtn />', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
||||||
|
|
||||||
|
// Once the file is uploaded, non-duplicated servers are immediately created
|
||||||
|
expect(createServersMock).toHaveBeenCalledExactlyOnceWith([expect.objectContaining(newServer)]);
|
||||||
|
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
await user.click(screen.getByRole('button', { name: btnName }));
|
await user.click(screen.getByRole('button', { name: btnName }));
|
||||||
|
|
||||||
expect(createServersMock).toHaveBeenCalledWith(
|
// If duplicated servers are saved, there's one extra call
|
||||||
savesDuplicatedServers
|
if (savesDuplicatedServers) {
|
||||||
? [expect.objectContaining(existingServerData), expect.objectContaining(newServer)]
|
expect(createServersMock).toHaveBeenLastCalledWith([expect.objectContaining(existingServerData)]);
|
||||||
: [expect.objectContaining(newServer)],
|
}
|
||||||
);
|
|
||||||
expect(onImportMock).toHaveBeenCalledTimes(1);
|
// On import is called only once, no matter what
|
||||||
|
expect(onImportMock).toHaveBeenCalledOnce();
|
||||||
|
expect(createServersMock).toHaveBeenCalledTimes(savesDuplicatedServers ? 2 : 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user