Extract initial Shlink logic to ShlinkWebComponent

This commit is contained in:
Alejandro Celaya 2023-07-16 22:54:49 +02:00
parent d82c0dc75e
commit 682de08204
20 changed files with 197 additions and 157 deletions

View file

@ -10,12 +10,10 @@ import classNames from 'classnames';
import type { FC } from 'react'; import type { FC } from 'react';
import type { NavLinkProps } from 'react-router-dom'; import type { NavLinkProps } from 'react-router-dom';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import type { SelectedServer } from '../servers/data';
import { isServerWithId } from '../servers/data';
import './AsideMenu.scss'; import './AsideMenu.scss';
export interface AsideMenuProps { export interface AsideMenuProps {
selectedServer: SelectedServer; routePrefix: string;
showOnMobile?: boolean; showOnMobile?: boolean;
} }
@ -34,14 +32,12 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
</NavLink> </NavLink>
); );
export const AsideMenu: FC<AsideMenuProps> = ({ selectedServer, showOnMobile = false }) => { export const AsideMenu: FC<AsideMenuProps> = ({ routePrefix, showOnMobile = false }) => {
const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : '';
const { pathname } = useLocation(); const { pathname } = useLocation();
const asideClass = classNames('aside-menu', { const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile, 'aside-menu--hidden': !showOnMobile,
}); });
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`; const buildPath = (suffix: string) => `${routePrefix}${suffix}`;
return ( return (
<aside className={asideClass}> <aside className={asideClass}>

View file

@ -1,15 +1,8 @@
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { isReachableServer } from '../servers/data'; import { isReachableServer } from '../servers/data';
import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useFeature } from '../utils/helpers/features'; import type { ShlinkWebComponentType } from '../shlink-web-component';
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
import { AsideMenu } from './AsideMenu';
import { NotFound } from './NotFound';
import './MenuLayout.scss'; import './MenuLayout.scss';
interface MenuLayoutProps { interface MenuLayoutProps {
@ -18,24 +11,11 @@ interface MenuLayoutProps {
} }
export const MenuLayout = ( export const MenuLayout = (
TagsList: FC,
ShortUrlsList: FC,
CreateShortUrl: FC,
ShortUrlVisits: FC,
TagVisits: FC,
DomainVisits: FC,
OrphanVisits: FC,
NonOrphanVisits: FC,
ServerError: FC, ServerError: FC,
Overview: FC, ShlinkWebComponent: ShlinkWebComponentType,
EditShortUrl: FC,
ManageDomains: FC,
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => { ) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
const location = useLocation();
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
const showContent = isReachableServer(selectedServer); const showContent = isReachableServer(selectedServer);
useEffect(() => hideSidebar(), [location]);
useEffect(() => { useEffect(() => {
showContent && sidebarPresent(); showContent && sidebarPresent();
return () => sidebarNotPresent(); return () => sidebarNotPresent();
@ -45,42 +25,10 @@ export const MenuLayout = (
return <ServerError />; return <ServerError />;
} }
const addNonOrphanVisitsRoute = useFeature('nonOrphanVisits', selectedServer);
const addDomainVisitsRoute = useFeature('domainVisits', selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
return ( return (
<> <ShlinkWebComponent
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} /> serverVersion={selectedServer.version}
routesPrefix={`/server/${selectedServer.id}`}
<div {...swipeableProps} className="menu-layout__swipeable">
<div className="menu-layout__swipeable-inner">
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="menu-layout__container" onClick={() => hideSidebar()}>
<div className="container-xl">
<Routes>
<Route index element={<Navigate replace to="overview" />} />
<Route path="/overview" element={<Overview />} />
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
<Route path="/create-short-url" element={<CreateShortUrl />} />
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
<Route path="/manage-tags" element={<TagsList />} />
<Route path="/manage-domains" element={<ManageDomains />} />
<Route
path="*"
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
/> />
</Routes>
</div>
</div>
</div>
</div>
</>
); );
}, ServerError); }, ServerError);

View file

@ -1,5 +1,9 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
// TODO This is only used for some components to have extra paddings/styles if existing section has a side menu
// Now that's basically the route which renders ShlinkWebComponent, so maybe there's some way to re-think this
// logic, and perhaps get rid of a reducer just for that
export interface Sidebar { export interface Sidebar {
sidebarPresent: boolean; sidebarPresent: boolean;
} }

View file

@ -31,22 +31,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.decorator('Home', withoutSelectedServer); bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer'])); bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
bottle.serviceFactory( bottle.serviceFactory('MenuLayout', MenuLayout, 'ServerError', 'ShlinkWebComponent');
'MenuLayout',
MenuLayout,
'TagsList',
'ShortUrlsList',
'CreateShortUrl',
'ShortUrlVisits',
'TagVisits',
'DomainVisits',
'OrphanVisits',
'NonOrphanVisits',
'ServerError',
'Overview',
'EditShortUrl',
'ManageDomains',
);
bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent'])); bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent']));
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer); bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);

View file

@ -8,6 +8,7 @@ import { provideServices as provideCommonServices } from '../common/services/pro
import { provideServices as provideMercureServices } from '../mercure/services/provideServices'; import { provideServices as provideMercureServices } from '../mercure/services/provideServices';
import { provideServices as provideServersServices } from '../servers/services/provideServices'; import { provideServices as provideServersServices } from '../servers/services/provideServices';
import { provideServices as provideSettingsServices } from '../settings/services/provideServices'; import { provideServices as provideSettingsServices } from '../settings/services/provideServices';
import { provideServices as provideWebComponentServices } from '../shlink-web-component/container';
import { provideServices as provideDomainsServices } from '../shlink-web-component/domains/services/provideServices'; import { provideServices as provideDomainsServices } from '../shlink-web-component/domains/services/provideServices';
import { provideServices as provideShortUrlsServices } from '../shlink-web-component/short-urls/services/provideServices'; import { provideServices as provideShortUrlsServices } from '../shlink-web-component/short-urls/services/provideServices';
import { provideServices as provideTagsServices } from '../shlink-web-component/tags/services/provideServices'; import { provideServices as provideTagsServices } from '../shlink-web-component/tags/services/provideServices';
@ -45,3 +46,6 @@ provideUtilsServices(bottle);
provideMercureServices(bottle); provideMercureServices(bottle);
provideSettingsServices(bottle, connect); provideSettingsServices(bottle, connect);
provideDomainsServices(bottle, connect); provideDomainsServices(bottle, connect);
// TODO This should not be needed.
provideWebComponentServices(bottle);

View file

@ -0,0 +1,78 @@
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import type { FC } from 'react';
import { useEffect } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { AsideMenu } from '../common/AsideMenu';
import { NotFound } from '../common/NotFound';
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
import type { SemVer } from '../utils/helpers/version';
import { FeaturesProvider, useFeatures } from './utils/features';
type ShlinkWebComponentProps = {
routesPrefix?: string;
serverVersion: SemVer;
};
export const ShlinkWebComponent = (
TagsList: FC,
ShortUrlsList: FC,
CreateShortUrl: FC,
ShortUrlVisits: FC,
TagVisits: FC,
DomainVisits: FC,
OrphanVisits: FC,
NonOrphanVisits: FC,
Overview: FC,
EditShortUrl: FC,
ManageDomains: FC,
): FC<ShlinkWebComponentProps> => ({ routesPrefix = '', serverVersion }) => {
const location = useLocation();
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
useEffect(() => hideSidebar(), [location]);
const features = useFeatures(serverVersion);
const addNonOrphanVisitsRoute = features.nonOrphanVisits;
const addDomainVisitsRoute = features.domainVisits;
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
// TODO Check if this is already wrapped by a router, and wrap otherwise
return (
<FeaturesProvider value={features}>
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
<div {...swipeableProps} className="menu-layout__swipeable">
<div className="menu-layout__swipeable-inner">
<AsideMenu routePrefix={routesPrefix} showOnMobile={sidebarVisible} />
<div className="menu-layout__container" onClick={() => hideSidebar()}>
<div className="container-xl">
<Routes>
<Route index element={<Navigate replace to="overview" />} />
<Route path="/overview" element={<Overview />} />
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
<Route path="/create-short-url" element={<CreateShortUrl />} />
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
<Route path="/manage-tags" element={<TagsList />} />
<Route path="/manage-domains" element={<ManageDomains />} />
<Route
path="*"
element={<NotFound to={`${routesPrefix}/list-short-urls/1`}>List short URLs</NotFound>}
/>
</Routes>
</div>
</div>
</div>
</div>
</FeaturesProvider>
);
};
export type ShlinkWebComponentType = ReturnType<typeof ShlinkWebComponent>;

View file

@ -0,0 +1,22 @@
import type Bottle from 'bottlejs';
import { ShlinkWebComponent } from '../ShlinkWebComponent';
// TODO Build sub-container
export const provideServices = (bottle: Bottle) => {
bottle.serviceFactory(
'ShlinkWebComponent',
ShlinkWebComponent,
'TagsList',
'ShortUrlsList',
'CreateShortUrl',
'ShortUrlVisits',
'TagVisits',
'DomainVisits',
'OrphanVisits',
'NonOrphanVisits',
'Overview',
'EditShortUrl',
'ManageDomains',
);
};

View file

@ -5,9 +5,9 @@ import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap'; import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../../servers/data'; import type { SelectedServer } from '../../../servers/data';
import { getServerId } from '../../../servers/data'; import { getServerId } from '../../../servers/data';
import { useFeature } from '../../../utils/helpers/features';
import { useToggle } from '../../../utils/helpers/hooks'; import { useToggle } from '../../../utils/helpers/hooks';
import { RowDropdownBtn } from '../../../utils/RowDropdownBtn'; import { RowDropdownBtn } from '../../../utils/RowDropdownBtn';
import { useFeature } from '../../utils/features';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import type { Domain } from '../data'; import type { Domain } from '../data';
import type { EditDomainRedirects } from '../reducers/domainRedirects'; import type { EditDomainRedirects } from '../reducers/domainRedirects';
@ -22,8 +22,8 @@ interface DomainDropdownProps {
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => { export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
const [isModalOpen, toggleModal] = useToggle(); const [isModalOpen, toggleModal] = useToggle();
const { isDefault } = domain; const { isDefault } = domain;
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer); const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition');
const withVisits = useFeature('domainVisits', selectedServer); const withVisits = useFeature('domainVisits');
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
return ( return (

View file

@ -0,0 +1 @@
export * from './ShlinkWebComponent';

View file

@ -10,13 +10,13 @@ import { getServerId } from '../../servers/data';
import { HighlightCard } from '../../servers/helpers/HighlightCard'; import { HighlightCard } from '../../servers/helpers/HighlightCard';
import { VisitsHighlightCard } from '../../servers/helpers/VisitsHighlightCard'; import { VisitsHighlightCard } from '../../servers/helpers/VisitsHighlightCard';
import type { Settings } from '../../settings/reducers/settings'; import type { Settings } from '../../settings/reducers/settings';
import { useFeature } from '../../utils/helpers/features';
import { prettify } from '../../utils/helpers/numbers'; import { prettify } from '../../utils/helpers/numbers';
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList'; import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable'; import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
import type { TagsList } from '../tags/reducers/tagsList'; import type { TagsList } from '../tags/reducers/tagsList';
import { useFeature } from '../utils/features';
import type { VisitsOverview } from '../visits/reducers/visitsOverview'; import type { VisitsOverview } from '../visits/reducers/visitsOverview';
interface OverviewConnectProps { interface OverviewConnectProps {
@ -47,7 +47,7 @@ export const Overview = (
const { loading: loadingTags } = tagsList; const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview; const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer); const linkToNonOrphanVisits = useFeature('nonOrphanVisits');
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {

View file

@ -1,6 +1,5 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { SelectedServer } from '../../servers/data';
import type { Settings, ShortUrlCreationSettings } from '../../settings/reducers/settings'; import type { Settings, ShortUrlCreationSettings } from '../../settings/reducers/settings';
import type { ShortUrlData } from './data'; import type { ShortUrlData } from './data';
import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult'; import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
@ -14,7 +13,6 @@ export interface CreateShortUrlProps {
interface CreateShortUrlConnectProps extends CreateShortUrlProps { interface CreateShortUrlConnectProps extends CreateShortUrlProps {
settings: Settings; settings: Settings;
shortUrlCreation: ShortUrlCreation; shortUrlCreation: ShortUrlCreation;
selectedServer: SelectedServer;
createShortUrl: (data: ShortUrlData) => Promise<void>; createShortUrl: (data: ShortUrlData) => Promise<void>;
resetCreateShortUrl: () => void; resetCreateShortUrl: () => void;
} }
@ -41,7 +39,6 @@ export const CreateShortUrl = (
createShortUrl, createShortUrl,
shortUrlCreation, shortUrlCreation,
resetCreateShortUrl, resetCreateShortUrl,
selectedServer,
basicMode = false, basicMode = false,
settings: { shortUrlCreation: shortUrlCreationSettings }, settings: { shortUrlCreation: shortUrlCreationSettings },
}: CreateShortUrlConnectProps) => { }: CreateShortUrlConnectProps) => {
@ -52,7 +49,6 @@ export const CreateShortUrl = (
<ShortUrlForm <ShortUrlForm
initialState={initialState} initialState={initialState}
saving={shortUrlCreation.saving} saving={shortUrlCreation.saving}
selectedServer={selectedServer}
mode={basicMode ? 'create-basic' : 'create'} mode={basicMode ? 'create-basic' : 'create'}
onSave={async (data: ShortUrlData) => { onSave={async (data: ShortUrlData) => {
resetCreateShortUrl(); resetCreateShortUrl();

View file

@ -6,7 +6,6 @@ import { ExternalLink } from 'react-external-link';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { Button, Card } from 'reactstrap'; import { Button, Card } from 'reactstrap';
import { ShlinkApiError } from '../../api/ShlinkApiError'; import { ShlinkApiError } from '../../api/ShlinkApiError';
import type { SelectedServer } from '../../servers/data';
import type { Settings } from '../../settings/reducers/settings'; import type { Settings } from '../../settings/reducers/settings';
import { useGoBack } from '../../utils/helpers/hooks'; import { useGoBack } from '../../utils/helpers/hooks';
import { parseQuery } from '../../utils/helpers/query'; import { parseQuery } from '../../utils/helpers/query';
@ -20,7 +19,6 @@ import type { ShortUrlFormProps } from './ShortUrlForm';
interface EditShortUrlConnectProps { interface EditShortUrlConnectProps {
settings: Settings; settings: Settings;
selectedServer: SelectedServer;
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;
shortUrlEdition: ShortUrlEdition; shortUrlEdition: ShortUrlEdition;
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void; getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
@ -29,7 +27,6 @@ interface EditShortUrlConnectProps {
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
settings: { shortUrlCreation: shortUrlCreationSettings }, settings: { shortUrlCreation: shortUrlCreationSettings },
selectedServer,
shortUrlDetail, shortUrlDetail,
getShortUrlDetail, getShortUrlDetail,
shortUrlEdition, shortUrlEdition,
@ -80,7 +77,6 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
<ShortUrlForm <ShortUrlForm
initialState={initialState} initialState={initialState}
saving={saving} saving={saving}
selectedServer={selectedServer}
mode="edit" mode="edit"
onSave={async (shortUrlData) => { onSave={async (shortUrlData) => {
if (!shortUrl) { if (!shortUrl) {

View file

@ -8,17 +8,16 @@ import type { ChangeEvent, FC } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, FormGroup, Input, Row } from 'reactstrap'; import { Button, FormGroup, Input, Row } from 'reactstrap';
import type { InputType } from 'reactstrap/types/lib/Input'; import type { InputType } from 'reactstrap/types/lib/Input';
import type { SelectedServer } from '../../servers/data';
import { Checkbox } from '../../utils/Checkbox'; import { Checkbox } from '../../utils/Checkbox';
import type { DateTimeInputProps } from '../../utils/dates/DateTimeInput'; import type { DateTimeInputProps } from '../../utils/dates/DateTimeInput';
import { DateTimeInput } from '../../utils/dates/DateTimeInput'; import { DateTimeInput } from '../../utils/dates/DateTimeInput';
import { formatIsoDate } from '../../utils/helpers/date'; import { formatIsoDate } from '../../utils/helpers/date';
import { useFeature } from '../../utils/helpers/features';
import { IconInput } from '../../utils/IconInput'; import { IconInput } from '../../utils/IconInput';
import { SimpleCard } from '../../utils/SimpleCard'; import { SimpleCard } from '../../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue } from '../../utils/utils'; import { handleEventPreventingDefault, hasValue } from '../../utils/utils';
import type { DomainSelectorProps } from '../domains/DomainSelector'; import type { DomainSelectorProps } from '../domains/DomainSelector';
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { useFeature } from '../utils/features';
import type { DeviceLongUrls, ShortUrlData } from './data'; import type { DeviceLongUrls, ShortUrlData } from './data';
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup'; import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon'; import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
@ -34,7 +33,6 @@ export interface ShortUrlFormProps {
saving: boolean; saving: boolean;
initialState: ShortUrlData; initialState: ShortUrlData;
onSave: (shortUrlData: ShortUrlData) => Promise<unknown>; onSave: (shortUrlData: ShortUrlData) => Promise<unknown>;
selectedServer: SelectedServer;
} }
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
@ -43,10 +41,10 @@ const toDate = (date?: string | Date): Date | undefined => (typeof date === 'str
export const ShortUrlForm = ( export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>, TagsSelector: FC<TagsSelectorProps>,
DomainSelector: FC<DomainSelectorProps>, DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { ): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState }) => {
const [shortUrlData, setShortUrlData] = useState(initialState); const [shortUrlData, setShortUrlData] = useState(initialState);
const reset = () => setShortUrlData(initialState); const reset = () => setShortUrlData(initialState);
const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer); const supportsDeviceLongUrls = useFeature('deviceLongUrls');
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const isBasicMode = mode === 'create-basic'; const isBasicMode = mode === 'create-basic';
@ -136,7 +134,7 @@ export const ShortUrlForm = (
</> </>
); );
const showForwardQueryControl = useFeature('forwardQuery', selectedServer); const showForwardQueryControl = useFeature('forwardQuery');
return ( return (
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}> <form name="shortUrlForm" className="short-url-form" onSubmit={submit}>

View file

@ -4,17 +4,16 @@ import classNames from 'classnames';
import { isEmpty, pipe } from 'ramda'; import { isEmpty, pipe } from 'ramda';
import type { FC } from 'react'; import type { FC } from 'react';
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap'; import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
import type { SelectedServer } from '../../servers/data';
import type { Settings } from '../../settings/reducers/settings'; import type { Settings } from '../../settings/reducers/settings';
import { DateRangeSelector } from '../../utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../../utils/helpers/date'; import { formatIsoDate } from '../../utils/helpers/date';
import type { DateRange } from '../../utils/helpers/dateIntervals'; import type { DateRange } from '../../utils/helpers/dateIntervals';
import { datesToDateRange } from '../../utils/helpers/dateIntervals'; import { datesToDateRange } from '../../utils/helpers/dateIntervals';
import { useFeature } from '../../utils/helpers/features';
import type { OrderDir } from '../../utils/helpers/ordering'; import type { OrderDir } from '../../utils/helpers/ordering';
import { OrderingDropdown } from '../../utils/OrderingDropdown'; import { OrderingDropdown } from '../../utils/OrderingDropdown';
import { SearchField } from '../../utils/SearchField'; import { SearchField } from '../../utils/SearchField';
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { useFeature } from '../utils/features';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { SHORT_URLS_ORDERABLE_FIELDS } from './data'; import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn'; import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
@ -23,7 +22,6 @@ import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
import './ShortUrlsFilteringBar.scss'; import './ShortUrlsFilteringBar.scss';
interface ShortUrlsFilteringProps { interface ShortUrlsFilteringProps {
selectedServer: SelectedServer;
order: ShortUrlsOrder; order: ShortUrlsOrder;
settings: Settings; settings: Settings;
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
@ -34,7 +32,7 @@ interface ShortUrlsFilteringProps {
export const ShortUrlsFilteringBar = ( export const ShortUrlsFilteringBar = (
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>, ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
TagsSelector: FC<TagsSelectorProps>, TagsSelector: FC<TagsSelectorProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => { ): FC<ShortUrlsFilteringProps> => ({ className, shortUrlsAmount, order, handleOrderBy, settings }) => {
const [filter, toFirstPage] = useShortUrlsQuery(); const [filter, toFirstPage] = useShortUrlsQuery();
const { const {
search, search,
@ -46,7 +44,7 @@ export const ShortUrlsFilteringBar = (
excludePastValidUntil, excludePastValidUntil,
tagsMode = 'any', tagsMode = 'any',
} = filter; } = filter;
const supportsDisabledFiltering = useFeature('filterDisabledUrls', selectedServer); const supportsDisabledFiltering = useFeature('filterDisabledUrls');
const setDates = pipe( const setDates = pipe(
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({ ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
@ -60,7 +58,7 @@ export const ShortUrlsFilteringBar = (
(searchTerm) => toFirstPage({ search: searchTerm }), (searchTerm) => toFirstPage({ search: searchTerm }),
); );
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags }); const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
const canChangeTagsMode = useFeature('allTagsFiltering', selectedServer); const canChangeTagsMode = useFeature('allTagsFiltering');
const toggleTagsMode = pipe( const toggleTagsMode = pipe(
() => (tagsMode === 'any' ? 'all' : 'any'), () => (tagsMode === 'any' ? 'all' : 'any'),
(mode) => toFirstPage({ tagsMode: mode }), (mode) => toFirstPage({ tagsMode: mode }),

View file

@ -9,10 +9,10 @@ import type { SelectedServer } from '../../servers/data';
import { getServerId } from '../../servers/data'; import { getServerId } from '../../servers/data';
import type { Settings } from '../../settings/reducers/settings'; import type { Settings } from '../../settings/reducers/settings';
import { DEFAULT_SHORT_URLS_ORDERING } from '../../settings/reducers/settings'; import { DEFAULT_SHORT_URLS_ORDERING } from '../../settings/reducers/settings';
import { useFeature } from '../../utils/helpers/features';
import type { OrderDir } from '../../utils/helpers/ordering'; import type { OrderDir } from '../../utils/helpers/ordering';
import { determineOrderDir } from '../../utils/helpers/ordering'; import { determineOrderDir } from '../../utils/helpers/ordering';
import { TableOrderIcon } from '../../utils/table/TableOrderIcon'; import { TableOrderIcon } from '../../utils/table/TableOrderIcon';
import { useFeature } from '../utils/features';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { useShortUrlsQuery } from './helpers/hooks'; import { useShortUrlsQuery } from './helpers/hooks';
import { Paginator } from './Paginator'; import { Paginator } from './Paginator';
@ -52,7 +52,7 @@ export const ShortUrlsList = (
); );
const { pagination } = shortUrlsList?.shortUrls ?? {}; const { pagination } = shortUrlsList?.shortUrls ?? {};
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots; const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls', selectedServer); const supportsExcludingBots = useFeature('excludeBotsOnShortUrls');
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => { const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } }); toFirstPage({ orderBy: { field, dir } });
setActualOrderBy({ field, dir }); setActualOrderBy({ field, dir });
@ -101,7 +101,6 @@ export const ShortUrlsList = (
return ( return (
<> <>
<ShortUrlsFilteringBar <ShortUrlsFilteringBar
selectedServer={selectedServer}
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems} shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy} order={actualOrderBy}
handleOrderBy={handleOrderBy} handleOrderBy={handleOrderBy}

View file

@ -4,28 +4,23 @@ import { useMemo, useState } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
import type { ImageDownloader } from '../../../common/services/ImageDownloader'; import type { ImageDownloader } from '../../../common/services/ImageDownloader';
import type { SelectedServer } from '../../../servers/data';
import { CopyToClipboardIcon } from '../../../utils/CopyToClipboardIcon'; import { CopyToClipboardIcon } from '../../../utils/CopyToClipboardIcon';
import { useFeature } from '../../../utils/helpers/features';
import type { QrCodeFormat, QrErrorCorrection } from '../../../utils/helpers/qrCodes'; import type { QrCodeFormat, QrErrorCorrection } from '../../../utils/helpers/qrCodes';
import { buildQrCodeUrl } from '../../../utils/helpers/qrCodes'; import { buildQrCodeUrl } from '../../../utils/helpers/qrCodes';
import { useFeature } from '../../utils/features';
import type { ShortUrlModalProps } from '../data'; import type { ShortUrlModalProps } from '../data';
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown'; import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown'; import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
import './QrCodeModal.scss'; import './QrCodeModal.scss';
interface QrCodeModalConnectProps extends ShortUrlModalProps {
selectedServer: SelectedServer;
}
export const QrCodeModal = (imageDownloader: ImageDownloader) => ( export const QrCodeModal = (imageDownloader: ImageDownloader) => (
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps, { shortUrl: { shortUrl, shortCode }, toggle, isOpen }: ShortUrlModalProps,
) => { ) => {
const [size, setSize] = useState(300); const [size, setSize] = useState(300);
const [margin, setMargin] = useState(0); const [margin, setMargin] = useState(0);
const [format, setFormat] = useState<QrCodeFormat>('png'); const [format, setFormat] = useState<QrCodeFormat>('png');
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L'); const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
const displayDownloadBtn = useFeature('nonRestCors', selectedServer); const displayDownloadBtn = useFeature('nonRestCors');
const qrCodeUrl = useMemo( const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }), () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
[shortUrl, size, format, margin, errorCorrection], [shortUrl, size, format, margin, errorCorrection],
@ -46,7 +41,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Row> <Row>
<FormGroup className="d-grid col-md-4"> <FormGroup className="d-grid col-md-6">
<label>Size: {size}px</label> <label>Size: {size}px</label>
<input <input
type="range" type="range"
@ -58,7 +53,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setSize(Number(e.target.value))} onChange={(e) => setSize(Number(e.target.value))}
/> />
</FormGroup> </FormGroup>
<FormGroup className="d-grid col-md-4"> <FormGroup className="d-grid col-md-6">
<label htmlFor="marginControl">Margin: {margin}px</label> <label htmlFor="marginControl">Margin: {margin}px</label>
<input <input
id="marginControl" id="marginControl"
@ -71,7 +66,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setMargin(Number(e.target.value))} onChange={(e) => setMargin(Number(e.target.value))}
/> />
</FormGroup> </FormGroup>
<FormGroup className="d-grid col-md-4"> <FormGroup className="d-grid col-md-6">
<QrFormatDropdown format={format} setFormat={setFormat} /> <QrFormatDropdown format={format} setFormat={setFormat} />
</FormGroup> </FormGroup>
<FormGroup className="col-md-6"> <FormGroup className="col-md-6">

View file

@ -39,12 +39,12 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult'); bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult');
bottle.decorator( bottle.decorator(
'CreateShortUrl', 'CreateShortUrl',
connect(['shortUrlCreation', 'selectedServer', 'settings'], ['createShortUrl', 'resetCreateShortUrl']), connect(['shortUrlCreation', 'settings'], ['createShortUrl', 'resetCreateShortUrl']),
); );
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm'); bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
bottle.decorator('EditShortUrl', connect( bottle.decorator('EditShortUrl', connect(
['shortUrlDetail', 'shortUrlEdition', 'selectedServer', 'settings'], ['shortUrlDetail', 'shortUrlEdition', 'settings'],
['getShortUrlDetail', 'editShortUrl'], ['getShortUrlDetail', 'editShortUrl'],
)); ));
@ -55,8 +55,6 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
)); ));
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader'); bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader');
bottle.decorator('QrCodeModal', connect(['selectedServer']));
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector'); bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector');
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter'); bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');

View file

@ -4,9 +4,10 @@ import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClie
import type { ShlinkTags } from '../../../api/types'; import type { ShlinkTags } from '../../../api/types';
import type { ProblemDetailsError } from '../../../api/types/errors'; import type { ProblemDetailsError } from '../../../api/types/errors';
import { parseApiError } from '../../../api/utils'; import { parseApiError } from '../../../api/utils';
import { supportedFeatures } from '../../../utils/helpers/features'; import { isReachableServer } from '../../../servers/data';
import { createAsyncThunk } from '../../../utils/helpers/redux'; import { createAsyncThunk } from '../../../utils/helpers/redux';
import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation'; import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation';
import { isFeatureEnabledForVersion } from '../../utils/features';
import { createNewVisits } from '../../visits/reducers/visitCreation'; import { createNewVisits } from '../../visits/reducers/visitCreation';
import type { CreateVisit } from '../../visits/types'; import type { CreateVisit } from '../../visits/types';
import type { TagStats } from '../data'; import type { TagStats } from '../data';
@ -94,7 +95,9 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
const { listTags: shlinkListTags, tagsStats } = buildShlinkApiClient(getState); const { listTags: shlinkListTags, tagsStats } = buildShlinkApiClient(getState);
const { tags, stats }: ShlinkTags = await ( const { tags, stats }: ShlinkTags = await (
supportedFeatures.tagsStats(selectedServer) ? tagsStats() : shlinkListTags() isReachableServer(selectedServer) && isFeatureEnabledForVersion('tagsStats', selectedServer.version)
? tagsStats()
: shlinkListTags()
); );
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, ...rest }) => { const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, ...rest }) => {
acc[tag] = rest; acc[tag] = rest;

View file

@ -0,0 +1,50 @@
import { createContext, useContext, useMemo } from 'react';
import type { SemVer } from '../../utils/helpers/version';
import { versionMatch } from '../../utils/helpers/version';
const supportedFeatures = {
forwardQuery: '2.9.0',
nonRestCors: '2.9.0',
defaultDomainRedirectsEdition: '2.10.0',
nonOrphanVisits: '3.0.0',
allTagsFiltering: '3.0.0',
tagsStats: '3.0.0',
domainVisits: '3.1.0',
excludeBotsOnShortUrls: '3.4.0',
filterDisabledUrls: '3.4.0',
deviceLongUrls: '3.5.0',
} as const satisfies Record<string, SemVer>;
Object.freeze(supportedFeatures);
export type Feature = keyof typeof supportedFeatures;
export const isFeatureEnabledForVersion = (feature: Feature, serverVersion: SemVer): boolean =>
versionMatch(serverVersion, { minVersion: supportedFeatures[feature] });
const getFeaturesForVersion = (serverVersion: SemVer): Record<Feature, boolean> => ({
forwardQuery: isFeatureEnabledForVersion('forwardQuery', serverVersion),
nonRestCors: isFeatureEnabledForVersion('nonRestCors', serverVersion),
defaultDomainRedirectsEdition: isFeatureEnabledForVersion('defaultDomainRedirectsEdition', serverVersion),
nonOrphanVisits: isFeatureEnabledForVersion('nonOrphanVisits', serverVersion),
allTagsFiltering: isFeatureEnabledForVersion('allTagsFiltering', serverVersion),
tagsStats: isFeatureEnabledForVersion('tagsStats', serverVersion),
domainVisits: isFeatureEnabledForVersion('domainVisits', serverVersion),
excludeBotsOnShortUrls: isFeatureEnabledForVersion('excludeBotsOnShortUrls', serverVersion),
filterDisabledUrls: isFeatureEnabledForVersion('filterDisabledUrls', serverVersion),
deviceLongUrls: isFeatureEnabledForVersion('deviceLongUrls', serverVersion),
});
export const useFeatures = (serverVersion: SemVer) => useMemo(
() => getFeaturesForVersion(serverVersion),
[serverVersion],
);
const FeaturesContext = createContext(getFeaturesForVersion('0.0.0'));
export const FeaturesProvider = FeaturesContext.Provider;
export const useFeature = (feature: Feature) => {
const features = useContext(FeaturesContext);
return features[feature];
};

View file

@ -1,31 +0,0 @@
import { useMemo } from 'react';
import type { SelectedServer } from '../../servers/data';
import { isReachableServer } from '../../servers/data';
import { selectServer } from '../../servers/reducers/selectedServer';
import type { SemVerPattern } from './version';
import { versionMatch } from './version';
const matchesMinVersion = (minVersion: SemVerPattern) => (selectedServer: SelectedServer): boolean =>
isReachableServer(selectedServer) && versionMatch(selectedServer.version, { minVersion });
export const supportedFeatures = {
forwardQuery: matchesMinVersion('2.9.0'),
nonRestCors: matchesMinVersion('2.9.0'),
defaultDomainRedirectsEdition: matchesMinVersion('2.10.0'),
nonOrphanVisits: matchesMinVersion('3.0.0'),
allTagsFiltering: matchesMinVersion('3.0.0'),
tagsStats: matchesMinVersion('3.0.0'),
domainVisits: matchesMinVersion('3.1.0'),
excludeBotsOnShortUrls: matchesMinVersion('3.4.0'),
filterDisabledUrls: matchesMinVersion('3.4.0'),
deviceLongUrls: matchesMinVersion('3.5.0'),
} as const satisfies Record<string, ReturnType<typeof matchesMinVersion>>;
Object.freeze(supportedFeatures);
type Features = keyof typeof supportedFeatures;
export const useFeature = (feature: Features, selectedServer: SelectedServer) => useMemo(
() => supportedFeatures[feature](selectedServer),
[feature, selectServer],
);