Replace reactstrap nav bar with tailwind-based one

This commit is contained in:
Alejandro Celaya 2025-06-16 11:18:03 +02:00
parent 9c0c2fc3f9
commit d10d7fd96d
9 changed files with 81 additions and 139 deletions

View File

@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Changed
* Update to `@shlinkio/shlink-frontend-kit` 0.9 and `@shlinkio/shlink-web-component` 0.14 to add initial support to the new light theme brand color.
* Replace reactstrap nav bar with `NavBar` component from `@shlinkio/shlink-frontend-kit`
### Deprecated
* *Nothing*

50
package-lock.json generated
View File

@ -16,9 +16,9 @@
"@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.8.2",
"@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.9.10",
"@shlinkio/shlink-frontend-kit": "^0.9.13",
"@shlinkio/shlink-js-sdk": "^2.1.0",
"@shlinkio/shlink-web-component": "^0.14.2",
"@shlinkio/shlink-web-component": "^0.14.3",
"bootstrap": "5.2.3",
"bottlejs": "^2.0.1",
"clsx": "^2.1.1",
@ -3516,9 +3516,9 @@
}
},
"node_modules/@shlinkio/shlink-frontend-kit": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.9.10.tgz",
"integrity": "sha512-L1+z3imvoSXHYWaO+H39JXGg40eQW1ytY3hMIE8JUuqJYNmWWLrafmfj1MHenCWGZEhymbQnpGD1yyziy6a9Lw==",
"version": "0.9.13",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.9.13.tgz",
"integrity": "sha512-qiEDmrzYA/rmHPdLI9znaYqMKD16ITWon/vG66LlUeDL3zR0Psppy79FjM5k3CmIGsCCZdekXQle6U19YSOQLA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.12",
@ -3550,9 +3550,9 @@
"license": "MIT"
},
"node_modules/@shlinkio/shlink-web-component": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.14.2.tgz",
"integrity": "sha512-4GRT1nLuhVCGuKP8fwRv1EBtgQ2wCvpJqJ6ipYM/QKwA2uJIXChom4TDia+s4X8mESIOjV0++aoPOEr6y6H2iA==",
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.14.3.tgz",
"integrity": "sha512-IgTHJYkxp6Pqo4E8waouBXbpytiRasqPyMAMQ5vYfGXU5Y53D4QNkRwPsILkEVTb7B+qwQegyNax4YKjRi/hgA==",
"license": "MIT",
"dependencies": {
"@formkit/drag-and-drop": "^0.5.3",
@ -3569,7 +3569,6 @@
"react-external-link": "^2.5.0",
"react-leaflet": "^4.2.1 || ^5.0",
"react-swipeable": "^7.0.2",
"react-tag-autocomplete": "^7.5.0",
"recharts": "^2.15.3"
},
"peerDependencies": {
@ -3579,7 +3578,7 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.5.0",
"@shlinkio/shlink-frontend-kit": "^0.9.10",
"@shlinkio/shlink-frontend-kit": "^0.9.11",
"@shlinkio/shlink-js-sdk": "^2.0.0",
"react": "^18.3 || ^19.0",
"react-dom": "^18.3 || ^19.0",
@ -9400,18 +9399,6 @@
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/react-tag-autocomplete": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.5.0.tgz",
"integrity": "sha512-uy6ncusMKr6p3ip7xb4DTYtF22g7cSRyZq0IeFpgmrQipTbKz4RVFDB5QnnqstN6HTs9cdTtIb3vVuOuOdzH3w==",
"license": "ISC",
"engines": {
"node": ">= 16.12.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@ -14073,9 +14060,9 @@
"requires": {}
},
"@shlinkio/shlink-frontend-kit": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.9.10.tgz",
"integrity": "sha512-L1+z3imvoSXHYWaO+H39JXGg40eQW1ytY3hMIE8JUuqJYNmWWLrafmfj1MHenCWGZEhymbQnpGD1yyziy6a9Lw==",
"version": "0.9.13",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.9.13.tgz",
"integrity": "sha512-qiEDmrzYA/rmHPdLI9znaYqMKD16ITWon/vG66LlUeDL3zR0Psppy79FjM5k3CmIGsCCZdekXQle6U19YSOQLA==",
"requires": {
"@floating-ui/react": "^0.27.12",
"clsx": "^2.1.1"
@ -14087,9 +14074,9 @@
"integrity": "sha512-K6zmA/A7Ux9hTn+ZjAm85YmMl7/v5XgZBM62syCxCsK7Tdw7Gg4+C06cZ2gUv+HWrHtv5IXsi4ax00++8Kg5vw=="
},
"@shlinkio/shlink-web-component": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.14.2.tgz",
"integrity": "sha512-4GRT1nLuhVCGuKP8fwRv1EBtgQ2wCvpJqJ6ipYM/QKwA2uJIXChom4TDia+s4X8mESIOjV0++aoPOEr6y6H2iA==",
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.14.3.tgz",
"integrity": "sha512-IgTHJYkxp6Pqo4E8waouBXbpytiRasqPyMAMQ5vYfGXU5Y53D4QNkRwPsILkEVTb7B+qwQegyNax4YKjRi/hgA==",
"requires": {
"@formkit/drag-and-drop": "^0.5.3",
"@json2csv/plainjs": "^7.0.6",
@ -14105,7 +14092,6 @@
"react-external-link": "^2.5.0",
"react-leaflet": "^4.2.1 || ^5.0",
"react-swipeable": "^7.0.2",
"react-tag-autocomplete": "^7.5.0",
"recharts": "^2.15.3"
},
"dependencies": {
@ -18014,12 +18000,6 @@
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
"requires": {}
},
"react-tag-autocomplete": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.5.0.tgz",
"integrity": "sha512-uy6ncusMKr6p3ip7xb4DTYtF22g7cSRyZq0IeFpgmrQipTbKz4RVFDB5QnnqstN6HTs9cdTtIb3vVuOuOdzH3w==",
"requires": {}
},
"react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",

View File

@ -29,9 +29,9 @@
"@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.8.2",
"@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.9.10",
"@shlinkio/shlink-frontend-kit": "^0.9.13",
"@shlinkio/shlink-js-sdk": "^2.1.0",
"@shlinkio/shlink-web-component": "^0.14.2",
"@shlinkio/shlink-web-component": "^0.14.3",
"bootstrap": "5.2.3",
"bottlejs": "^2.0.1",
"clsx": "^2.1.1",

View File

@ -64,7 +64,7 @@ const App: FCWithDeps<AppProps, AppDeps> = (
<div className="tw:h-full">
<MainHeader />
<div className="tw:h-full tw:pt-(--header-height)">
<div className="tw:h-full tw:pt-(--tw-header-height)">
<div
data-testid="shlink-wrapper"
className={clsx(

View File

@ -1,11 +1,8 @@
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import { NavBar } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { useEffect } from 'react';
import { Link, useLocation } from 'react-router';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { ShlinkLogo } from './img/ShlinkLogo';
@ -16,39 +13,28 @@ type MainHeaderDeps = {
const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
const { ServersDropdown } = useDependencies(MainHeader);
const { flag: isNotCollapsed, toggle: toggleCollapse, setToFalse: collapse } = useToggle(false, true);
const location = useLocation();
const { pathname } = location;
// In mobile devices, collapse the navbar when location changes
useEffect(collapse, [location, collapse]);
const { pathname } = useLocation();
const settingsPath = '/settings';
return (
<Navbar color="primary" dark fixed="top" expand="md" className="tw:text-white tw:bg-lm-main tw:dark:bg-dm-main">
<NavbarBrand tag={Link} to="/">
<ShlinkLogo className="tw:inline tw:w-7 tw:mr-1" color="white" /> Shlink
</NavbarBrand>
<NavbarToggler onClick={toggleCollapse}>
<FontAwesomeIcon
icon={arrowIcon}
className={clsx('tw:transition-transform tw:duration-300', { 'tw:rotate-180': isNotCollapsed })}
/>
</NavbarToggler>
<Collapse navbar isOpen={isNotCollapsed}>
<Nav navbar className="tw:ml-auto">
<NavItem>
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>
<NavBar
className="tw:[&]:fixed tw:top-0 tw:z-900"
brand={(
<Link to="/" className="tw:[&]:text-white tw:no-underline tw:flex tw:items-center tw:gap-2">
<ShlinkLogo className="tw:w-7" color="white" /> <small className="tw:font-normal">Shlink</small>
</Link>
)}
>
<NavBar.MenuItem
to={settingsPath}
active={pathname.startsWith(settingsPath)}
className="tw:flex tw:items-center tw:gap-1.5"
>
<FontAwesomeIcon icon={cogsIcon} /> Settings
</NavBar.MenuItem>
<ServersDropdown />
</Nav>
</Collapse>
</Navbar>
</NavBar>
);
};

View File

@ -1,7 +1,6 @@
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { SelectedServer, ServersMap } from './data';
import { getServerId } from './data';
@ -14,29 +13,28 @@ export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProp
const serversList = Object.values(servers);
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Servers</span>
</DropdownToggle>
<DropdownMenu end className="tw:right-0">
<NavBar.Dropdown buttonContent={(
<span className="tw:flex tw:items-center tw:gap-1.5">
<FontAwesomeIcon icon={serverIcon} fixedWidth /> Servers
</span>
)}>
{serversList.length === 0 ? (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="tw:ml-1">Add a server</span>
</DropdownItem>
<Dropdown.Item to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> Add a server
</Dropdown.Item>
) : (
<>
{serversList.map(({ name, id }) => (
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
<Dropdown.Item key={id} to={`/server/${id}`} selected={getServerId(selectedServer) === id}>
{name}
</DropdownItem>
</Dropdown.Item>
))}
<DropdownItem divider tag="hr" />
<DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Manage servers</span>
</DropdownItem>
<Dropdown.Separator />
<Dropdown.Item to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> Manage servers
</Dropdown.Item>
</>
)}
</DropdownMenu>
</UncontrolledDropdown>
</NavBar.Dropdown>
);
};

View File

@ -8,5 +8,7 @@
:root {
--footer-height: 2.3rem;
--footer-margin: .8rem;
/* Temp alias fo header-height to tw-header-height, so that shlink-web-component uses the right value */
--header-height: var(--tw-header-height);
}
}

View File

@ -1,4 +1,4 @@
import { screen, waitFor } from '@testing-library/react';
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router';
@ -8,8 +8,8 @@ import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<MainHeader />', () => {
const MainHeader = MainHeaderFactory(fromPartial({
// Fake this component as a li, as it gets rendered inside a ul
ServersDropdown: () => <li>ServersDropdown</li>,
// Fake this component as a li[role="menuitem"], as it gets rendered inside a ul[role="menu"]
ServersDropdown: () => <li role="menuitem">ServersDropdown</li>,
}));
const setUp = (pathname = '') => {
const history = createMemoryHistory();
@ -37,35 +37,8 @@ describe('<MainHeader />', () => {
['/settings/bar', true],
])('sets link to settings as active only when current path is settings', (currentPath, isActive) => {
setUp(currentPath);
if (isActive) {
expect(screen.getByText(/Settings$/).getAttribute('class')).toContain('active');
} else {
expect(screen.getByText(/Settings$/).getAttribute('class')).not.toContain('active');
}
});
it('renders expected class based on the nav bar state', async () => {
const { user } = setUp();
const toggle = screen.getByLabelText('Toggle navigation');
const icon = toggle.firstChild;
expect(icon).not.toHaveClass('tw:rotate-180');
await user.click(toggle);
expect(icon).toHaveClass('tw:rotate-180');
await user.click(toggle);
expect(icon).not.toHaveClass('tw:rotate-180');
});
it('opens Collapse when clicking toggle', async () => {
const { container, user } = setUp();
const collapse = container.querySelector('.collapse');
const toggle = screen.getByLabelText('Toggle navigation');
expect(collapse).not.toHaveAttribute('class', expect.stringContaining('show'));
await user.click(toggle);
await waitFor(() => expect(collapse).toHaveAttribute('class', expect.stringContaining('show')));
expect(screen.getByRole('menuitem', { name: /Settings$/ })).toHaveAttribute(
'data-active', isActive ? 'true' : 'false',
);
});
});

View File

@ -14,7 +14,7 @@ describe('<ServersDropdown />', () => {
};
const setUp = (servers: ServersMap = fallbackServers) => renderWithEvents(
<MemoryRouter>
<ul>
<ul role="menu">
<ServersDropdown servers={servers} selectedServer={null} />
</ul>
</MemoryRouter>,
@ -33,16 +33,18 @@ describe('<ServersDropdown />', () => {
await user.click(screen.getByText('Servers'));
const items = screen.getAllByRole('menuitem');
expect(items).toHaveLength(Object.values(fallbackServers).length + 1);
expect(items[0]).toHaveTextContent('foo');
expect(items[1]).toHaveTextContent('bar');
expect(items[2]).toHaveTextContent('baz');
expect(items[3]).toHaveTextContent('Manage servers');
// We have to add two for the "Manage servers" and the "Settings" menu items
expect(items).toHaveLength(Object.values(fallbackServers).length + 2);
expect(items[1]).toHaveTextContent('foo');
expect(items[2]).toHaveTextContent('bar');
expect(items[3]).toHaveTextContent('baz');
expect(items[4]).toHaveTextContent('Manage servers');
});
it('contains a toggle with proper text', () => {
setUp();
expect(screen.getByRole('link')).toHaveTextContent('Servers');
expect(screen.getByRole('button')).toHaveTextContent('Servers');
});
it('contains a button to manage servers', async () => {
@ -56,6 +58,6 @@ describe('<ServersDropdown />', () => {
const { user } = setUp({});
await user.click(screen.getByText('Servers'));
expect(screen.getByRole('menuitem')).toHaveTextContent('Add a server');
expect(screen.getByRole('menuitem', { name: 'Add a server' })).toBeInTheDocument();
});
});