mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-03 14:57:22 +03:00
Normalize and consolidate dropdown menus
This commit is contained in:
parent
afc574aceb
commit
655fbf94c1
15 changed files with 96 additions and 99 deletions
|
@ -5,9 +5,9 @@ import { Link } from 'react-router-dom';
|
|||
import { DropdownItem } from 'reactstrap';
|
||||
import type { SelectedServer } from '../../servers/data';
|
||||
import { getServerId } from '../../servers/data';
|
||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||
import { useFeature } from '../../utils/helpers/features';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
|
||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||
import type { Domain } from '../data';
|
||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
@ -20,7 +20,6 @@ interface DomainDropdownProps {
|
|||
}
|
||||
|
||||
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
|
||||
const [isOpen, toggle] = useToggle();
|
||||
const [isModalOpen, toggleModal] = useToggle();
|
||||
const { isDefault } = domain;
|
||||
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer);
|
||||
|
@ -28,7 +27,7 @@ export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedi
|
|||
const serverId = getServerId(selectedServer);
|
||||
|
||||
return (
|
||||
<DropdownBtnMenu isOpen={isOpen} toggle={toggle}>
|
||||
<RowDropdownBtn>
|
||||
{withVisits && (
|
||||
<DropdownItem
|
||||
tag={Link}
|
||||
|
@ -47,6 +46,6 @@ export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedi
|
|||
toggle={toggleModal}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
/>
|
||||
</DropdownBtnMenu>
|
||||
</RowDropdownBtn>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,8 +9,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { RowDropdownBtn } from '../utils/RowDropdownBtn';
|
||||
import type { ServerWithId } from './data';
|
||||
import type { DeleteServerModalProps } from './DeleteServerModal';
|
||||
|
||||
|
@ -25,14 +25,13 @@ interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownP
|
|||
export const ManageServersRowDropdown = (
|
||||
DeleteServerModal: FC<DeleteServerModalProps>,
|
||||
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
|
||||
const [isMenuOpen, toggleMenu] = useToggle();
|
||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||
const serverUrl = `/server/${server.id}`;
|
||||
const { autoConnect: isAutoConnect } = server;
|
||||
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
||||
|
||||
return (
|
||||
<DropdownBtnMenu isOpen={isMenuOpen} toggle={toggleMenu}>
|
||||
<RowDropdownBtn minWidth={170}>
|
||||
<DropdownItem tag={Link} to={serverUrl}>
|
||||
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
|
||||
</DropdownItem>
|
||||
|
@ -48,6 +47,6 @@ export const ManageServersRowDropdown = (
|
|||
</DropdownItem>
|
||||
|
||||
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</DropdownBtnMenu>
|
||||
</RowDropdownBtn>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -17,7 +17,7 @@ export const ShortUrlsFilterDropdown = (
|
|||
const onFilterClick = (key: keyof ShortUrlsFilter) => () => onChange({ ...selected, [key]: !selected?.[key] });
|
||||
|
||||
return (
|
||||
<DropdownBtn text="Filters" dropdownClassName={className} inline right minWidth={250}>
|
||||
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
|
||||
<DropdownItem header>Visits:</DropdownItem>
|
||||
<DropdownItem active={excludeBots} onClick={onFilterClick('excludeBots')}>Ignore visits from bots</DropdownItem>
|
||||
|
||||
|
|
|
@ -87,7 +87,7 @@ export const ShortUrlsRow = (
|
|||
<td className="responsive-table__cell short-urls-row__cell" data-th="Status">
|
||||
<ShortUrlStatus shortUrl={shortUrl} />
|
||||
</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell">
|
||||
<td className="responsive-table__cell short-urls-row__cell text-end">
|
||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -8,8 +8,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||
import type { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import type { SelectedServer } from '../../servers/data';
|
||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
|
||||
import type { ShortUrl, ShortUrlModalProps } from '../data';
|
||||
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
||||
|
||||
|
@ -23,12 +23,11 @@ export const ShortUrlsRowMenu = (
|
|||
DeleteShortUrlModal: ShortUrlModal,
|
||||
QrCodeModal: ShortUrlModal,
|
||||
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
|
||||
const [isOpen, toggle] = useToggle();
|
||||
const [isQrModalOpen,, openQrCodeModal, closeQrCodeModal] = useToggle();
|
||||
const [isDeleteModalOpen,, openDeleteModal, closeDeleteModal] = useToggle();
|
||||
|
||||
return (
|
||||
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
||||
<RowDropdownBtn minWidth={190}>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
|
@ -48,7 +47,7 @@ export const ShortUrlsRowMenu = (
|
|||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={closeDeleteModal} />
|
||||
</DropdownBtnMenu>
|
||||
</RowDropdownBtn>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -5,9 +5,9 @@ import { Link } from 'react-router-dom';
|
|||
import { DropdownItem } from 'reactstrap';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
import { getServerId } from '../servers/data';
|
||||
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { RowDropdownBtn } from '../utils/RowDropdownBtn';
|
||||
import type { ColorGenerator } from '../utils/services/ColorGenerator';
|
||||
import type { SimplifiedTag, TagModalProps } from './data';
|
||||
import { TagBullet } from './helpers/TagBullet';
|
||||
|
@ -24,7 +24,6 @@ export const TagsTableRow = (
|
|||
) => ({ tag, selectedServer }: TagsTableRowProps) => {
|
||||
const [isDeleteModalOpen, toggleDelete] = useToggle();
|
||||
const [isEditModalOpen, toggleEdit] = useToggle();
|
||||
const [isDropdownOpen, toggleDropdown] = useToggle();
|
||||
const serverId = getServerId(selectedServer);
|
||||
|
||||
return (
|
||||
|
@ -43,14 +42,14 @@ export const TagsTableRow = (
|
|||
</Link>
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-end">
|
||||
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
|
||||
<RowDropdownBtn>
|
||||
<DropdownItem onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth className="me-1" /> Edit
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="me-1" /> Delete
|
||||
</DropdownItem>
|
||||
</DropdownBtnMenu>
|
||||
</RowDropdownBtn>
|
||||
</td>
|
||||
|
||||
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
|
|
|
@ -4,6 +4,9 @@
|
|||
|
||||
.dropdown-btn__toggle.dropdown-btn__toggle {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-btn__toggle.dropdown-btn__toggle--with-caret {
|
||||
padding-right: 1.75rem;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,30 +1,45 @@
|
|||
import classNames from 'classnames';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import type { DropdownToggleProps } from 'reactstrap/types/lib/DropdownToggle';
|
||||
import { useToggle } from './helpers/hooks';
|
||||
import './DropdownBtn.scss';
|
||||
|
||||
export type DropdownBtnProps = PropsWithChildren<{
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & {
|
||||
text: ReactNode;
|
||||
noCaret?: boolean;
|
||||
className?: string;
|
||||
dropdownClassName?: string;
|
||||
right?: boolean;
|
||||
inline?: boolean;
|
||||
minWidth?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}>;
|
||||
|
||||
export const DropdownBtn: FC<DropdownBtnProps> = (
|
||||
{ text, disabled = false, className, children, dropdownClassName, right = false, minWidth, inline },
|
||||
) => {
|
||||
export const DropdownBtn: FC<DropdownBtnProps> = ({
|
||||
text,
|
||||
disabled = false,
|
||||
className,
|
||||
children,
|
||||
dropdownClassName,
|
||||
noCaret,
|
||||
end = false,
|
||||
minWidth,
|
||||
inline,
|
||||
size,
|
||||
}) => {
|
||||
const [isOpen, toggle] = useToggle();
|
||||
const toggleClasses = classNames('dropdown-btn__toggle', className, { 'btn-block': !inline });
|
||||
const style = { minWidth: minWidth && `${minWidth}px` };
|
||||
const toggleClasses = classNames('dropdown-btn__toggle', className, {
|
||||
'btn-block': !inline,
|
||||
'dropdown-btn__toggle--with-caret': !noCaret,
|
||||
});
|
||||
const menuStyle = { minWidth: minWidth && `${minWidth}px` };
|
||||
|
||||
return (
|
||||
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
|
||||
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
|
||||
<DropdownMenu className="w-100" end={right} style={style}>{children}</DropdownMenu>
|
||||
<DropdownToggle size={size} caret={!noCaret} className={toggleClasses} color="primary">
|
||||
{text}
|
||||
</DropdownToggle>
|
||||
<DropdownMenu className="w-100" end={end} style={menuStyle}>{children}</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
.dropdown-btn-menu__dropdown-toggle:after {
|
||||
display: none !important;
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import './DropdownBtnMenu.scss';
|
||||
|
||||
export type DropdownBtnMenuProps = PropsWithChildren<{
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
right?: boolean;
|
||||
}>;
|
||||
|
||||
export const DropdownBtnMenu: FC<DropdownBtnMenuProps> = ({ isOpen, toggle, children, right = true }) => (
|
||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownToggle size="sm" caret outline className="dropdown-btn-menu__dropdown-toggle">
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu end={right}>{children}</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
);
|
21
src/utils/RowDropdownBtn.tsx
Normal file
21
src/utils/RowDropdownBtn.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { DropdownBtn } from './DropdownBtn';
|
||||
|
||||
export type DropdownBtnMenuProps = PropsWithChildren<{
|
||||
minWidth?: number;
|
||||
}>;
|
||||
|
||||
export const RowDropdownBtn: FC<DropdownBtnMenuProps> = ({ children, minWidth }) => (
|
||||
<DropdownBtn
|
||||
text={<FontAwesomeIcon className="px-1" icon={menuIcon} />}
|
||||
size="sm"
|
||||
minWidth={minWidth}
|
||||
end
|
||||
noCaret
|
||||
inline
|
||||
>
|
||||
{children}
|
||||
</DropdownBtn>
|
||||
);
|
|
@ -22,7 +22,7 @@ export const VisitsFilterDropdown = (
|
|||
const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots });
|
||||
|
||||
return (
|
||||
<DropdownBtn text="Filters" dropdownClassName={className} inline right minWidth={250}>
|
||||
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
|
||||
<DropdownItem header>Bots:</DropdownItem>
|
||||
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
|
||||
|
||||
|
|
|
@ -26,9 +26,8 @@ describe('<DomainSelector />', () => {
|
|||
const btn = screen.getByRole('button', { name: expectedText });
|
||||
|
||||
expect(screen.queryByPlaceholderText('Domain')).not.toBeInTheDocument();
|
||||
expect(btn).toHaveAttribute(
|
||||
'class',
|
||||
`dropdown-btn__toggle btn-block ${expectedClassName} dropdown-toggle btn btn-primary`,
|
||||
expect(btn).toHaveClass(
|
||||
`dropdown-btn__toggle ${expectedClassName} btn-block dropdown-btn__toggle--with-caret dropdown-toggle btn btn-primary`,
|
||||
);
|
||||
await user.click(btn);
|
||||
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import type { DropdownBtnMenuProps } from '../../src/utils/DropdownBtnMenu';
|
||||
import { DropdownBtnMenu } from '../../src/utils/DropdownBtnMenu';
|
||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
|
||||
describe('<DropdownBtnMenu />', () => {
|
||||
const setUp = (props: Partial<DropdownBtnMenuProps> = {}) => renderWithEvents(
|
||||
<DropdownBtnMenu {...fromPartial<DropdownBtnMenuProps>({ toggle: jest.fn(), ...props })}>
|
||||
the children
|
||||
</DropdownBtnMenu>,
|
||||
);
|
||||
|
||||
it('renders expected components', () => {
|
||||
setUp();
|
||||
const toggle = screen.getByRole('button');
|
||||
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveClass('btn-sm');
|
||||
expect(toggle).toHaveClass('dropdown-btn-menu__dropdown-toggle');
|
||||
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders expected children', () => {
|
||||
setUp();
|
||||
expect(screen.getByText('the children')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, true],
|
||||
[true, true],
|
||||
[false, false],
|
||||
])('renders menu to the end when expected', (right, expectedEnd) => {
|
||||
setUp({ right });
|
||||
|
||||
if (expectedEnd) {
|
||||
expect(screen.getByRole('menu', { hidden: true })).toHaveClass('dropdown-menu-end');
|
||||
} else {
|
||||
expect(screen.getByRole('menu', { hidden: true })).not.toHaveClass('dropdown-menu-end');
|
||||
}
|
||||
});
|
||||
});
|
28
test/utils/RowDropdownBtn.test.tsx
Normal file
28
test/utils/RowDropdownBtn.test.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import type { DropdownBtnMenuProps } from '../../src/utils/RowDropdownBtn';
|
||||
import { RowDropdownBtn } from '../../src/utils/RowDropdownBtn';
|
||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
|
||||
describe('<RowDropdownBtn />', () => {
|
||||
const setUp = (props: Partial<DropdownBtnMenuProps> = {}) => renderWithEvents(
|
||||
<RowDropdownBtn {...fromPartial<DropdownBtnMenuProps>({ ...props })}>
|
||||
the children
|
||||
</RowDropdownBtn>,
|
||||
);
|
||||
|
||||
it('renders expected components', () => {
|
||||
setUp();
|
||||
const toggle = screen.getByRole('button');
|
||||
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveClass('btn-sm');
|
||||
expect(toggle).toHaveClass('dropdown-btn__toggle');
|
||||
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders expected children', () => {
|
||||
setUp();
|
||||
expect(screen.getByText('the children')).toBeInTheDocument();
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue