mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Extract initial Shlink logic to ShlinkWebComponent
This commit is contained in:
parent
d82c0dc75e
commit
682de08204
20 changed files with 197 additions and 157 deletions
|
@ -10,12 +10,10 @@ import classNames from 'classnames';
|
|||
import type { FC } from 'react';
|
||||
import type { NavLinkProps } 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';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
selectedServer: SelectedServer;
|
||||
routePrefix: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
||||
|
@ -34,14 +32,12 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
|||
</NavLink>
|
||||
);
|
||||
|
||||
export const AsideMenu: FC<AsideMenuProps> = ({ selectedServer, showOnMobile = false }) => {
|
||||
const hasId = isServerWithId(selectedServer);
|
||||
const serverId = hasId ? selectedServer.id : '';
|
||||
export const AsideMenu: FC<AsideMenuProps> = ({ routePrefix, showOnMobile = false }) => {
|
||||
const { pathname } = useLocation();
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
||||
const buildPath = (suffix: string) => `${routePrefix}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
|
|
|
@ -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 { useEffect } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useFeature } from '../utils/helpers/features';
|
||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||
import { AsideMenu } from './AsideMenu';
|
||||
import { NotFound } from './NotFound';
|
||||
import type { ShlinkWebComponentType } from '../shlink-web-component';
|
||||
import './MenuLayout.scss';
|
||||
|
||||
interface MenuLayoutProps {
|
||||
|
@ -18,24 +11,11 @@ interface MenuLayoutProps {
|
|||
}
|
||||
|
||||
export const MenuLayout = (
|
||||
TagsList: FC,
|
||||
ShortUrlsList: FC,
|
||||
CreateShortUrl: FC,
|
||||
ShortUrlVisits: FC,
|
||||
TagVisits: FC,
|
||||
DomainVisits: FC,
|
||||
OrphanVisits: FC,
|
||||
NonOrphanVisits: FC,
|
||||
ServerError: FC,
|
||||
Overview: FC,
|
||||
EditShortUrl: FC,
|
||||
ManageDomains: FC,
|
||||
ShlinkWebComponent: ShlinkWebComponentType,
|
||||
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
|
||||
const location = useLocation();
|
||||
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
|
||||
const showContent = isReachableServer(selectedServer);
|
||||
|
||||
useEffect(() => hideSidebar(), [location]);
|
||||
useEffect(() => {
|
||||
showContent && sidebarPresent();
|
||||
return () => sidebarNotPresent();
|
||||
|
@ -45,42 +25,10 @@ export const MenuLayout = (
|
|||
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 (
|
||||
<>
|
||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||
|
||||
<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>
|
||||
</>
|
||||
<ShlinkWebComponent
|
||||
serverVersion={selectedServer.version}
|
||||
routesPrefix={`/server/${selectedServer.id}`}
|
||||
/>
|
||||
);
|
||||
}, ServerError);
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
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 {
|
||||
sidebarPresent: boolean;
|
||||
}
|
||||
|
|
|
@ -31,22 +31,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
bottle.decorator('Home', withoutSelectedServer);
|
||||
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
|
||||
|
||||
bottle.serviceFactory(
|
||||
'MenuLayout',
|
||||
MenuLayout,
|
||||
'TagsList',
|
||||
'ShortUrlsList',
|
||||
'CreateShortUrl',
|
||||
'ShortUrlVisits',
|
||||
'TagVisits',
|
||||
'DomainVisits',
|
||||
'OrphanVisits',
|
||||
'NonOrphanVisits',
|
||||
'ServerError',
|
||||
'Overview',
|
||||
'EditShortUrl',
|
||||
'ManageDomains',
|
||||
);
|
||||
bottle.serviceFactory('MenuLayout', MenuLayout, 'ServerError', 'ShlinkWebComponent');
|
||||
bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent']));
|
||||
|
||||
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||
|
|
|
@ -8,6 +8,7 @@ import { provideServices as provideCommonServices } from '../common/services/pro
|
|||
import { provideServices as provideMercureServices } from '../mercure/services/provideServices';
|
||||
import { provideServices as provideServersServices } from '../servers/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 provideShortUrlsServices } from '../shlink-web-component/short-urls/services/provideServices';
|
||||
import { provideServices as provideTagsServices } from '../shlink-web-component/tags/services/provideServices';
|
||||
|
@ -45,3 +46,6 @@ provideUtilsServices(bottle);
|
|||
provideMercureServices(bottle);
|
||||
provideSettingsServices(bottle, connect);
|
||||
provideDomainsServices(bottle, connect);
|
||||
|
||||
// TODO This should not be needed.
|
||||
provideWebComponentServices(bottle);
|
||||
|
|
78
src/shlink-web-component/ShlinkWebComponent.tsx
Normal file
78
src/shlink-web-component/ShlinkWebComponent.tsx
Normal 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>;
|
22
src/shlink-web-component/container/index.ts
Normal file
22
src/shlink-web-component/container/index.ts
Normal 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',
|
||||
);
|
||||
};
|
|
@ -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 { useFeature } from '../../../utils/helpers/features';
|
||||
import { useToggle } from '../../../utils/helpers/hooks';
|
||||
import { RowDropdownBtn } from '../../../utils/RowDropdownBtn';
|
||||
import { useFeature } from '../../utils/features';
|
||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||
import type { Domain } from '../data';
|
||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
@ -22,8 +22,8 @@ interface DomainDropdownProps {
|
|||
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
|
||||
const [isModalOpen, toggleModal] = useToggle();
|
||||
const { isDefault } = domain;
|
||||
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer);
|
||||
const withVisits = useFeature('domainVisits', selectedServer);
|
||||
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition');
|
||||
const withVisits = useFeature('domainVisits');
|
||||
const serverId = getServerId(selectedServer);
|
||||
|
||||
return (
|
||||
|
|
1
src/shlink-web-component/index.ts
Normal file
1
src/shlink-web-component/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './ShlinkWebComponent';
|
|
@ -10,13 +10,13 @@ import { getServerId } from '../../servers/data';
|
|||
import { HighlightCard } from '../../servers/helpers/HighlightCard';
|
||||
import { VisitsHighlightCard } from '../../servers/helpers/VisitsHighlightCard';
|
||||
import type { Settings } from '../../settings/reducers/settings';
|
||||
import { useFeature } from '../../utils/helpers/features';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
|
||||
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
|
||||
import type { TagsList } from '../tags/reducers/tagsList';
|
||||
import { useFeature } from '../utils/features';
|
||||
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
|
||||
interface OverviewConnectProps {
|
||||
|
@ -47,7 +47,7 @@ export const Overview = (
|
|||
const { loading: loadingTags } = tagsList;
|
||||
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
|
||||
const serverId = getServerId(selectedServer);
|
||||
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
|
||||
const linkToNonOrphanVisits = useFeature('nonOrphanVisits');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { FC } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { SelectedServer } from '../../servers/data';
|
||||
import type { Settings, ShortUrlCreationSettings } from '../../settings/reducers/settings';
|
||||
import type { ShortUrlData } from './data';
|
||||
import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
||||
|
@ -14,7 +13,6 @@ export interface CreateShortUrlProps {
|
|||
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
|
||||
settings: Settings;
|
||||
shortUrlCreation: ShortUrlCreation;
|
||||
selectedServer: SelectedServer;
|
||||
createShortUrl: (data: ShortUrlData) => Promise<void>;
|
||||
resetCreateShortUrl: () => void;
|
||||
}
|
||||
|
@ -41,7 +39,6 @@ export const CreateShortUrl = (
|
|||
createShortUrl,
|
||||
shortUrlCreation,
|
||||
resetCreateShortUrl,
|
||||
selectedServer,
|
||||
basicMode = false,
|
||||
settings: { shortUrlCreation: shortUrlCreationSettings },
|
||||
}: CreateShortUrlConnectProps) => {
|
||||
|
@ -52,7 +49,6 @@ export const CreateShortUrl = (
|
|||
<ShortUrlForm
|
||||
initialState={initialState}
|
||||
saving={shortUrlCreation.saving}
|
||||
selectedServer={selectedServer}
|
||||
mode={basicMode ? 'create-basic' : 'create'}
|
||||
onSave={async (data: ShortUrlData) => {
|
||||
resetCreateShortUrl();
|
||||
|
|
|
@ -6,7 +6,6 @@ import { ExternalLink } from 'react-external-link';
|
|||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Button, Card } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
import type { SelectedServer } from '../../servers/data';
|
||||
import type { Settings } from '../../settings/reducers/settings';
|
||||
import { useGoBack } from '../../utils/helpers/hooks';
|
||||
import { parseQuery } from '../../utils/helpers/query';
|
||||
|
@ -20,7 +19,6 @@ import type { ShortUrlFormProps } from './ShortUrlForm';
|
|||
|
||||
interface EditShortUrlConnectProps {
|
||||
settings: Settings;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
|
||||
|
@ -29,7 +27,6 @@ interface EditShortUrlConnectProps {
|
|||
|
||||
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||
settings: { shortUrlCreation: shortUrlCreationSettings },
|
||||
selectedServer,
|
||||
shortUrlDetail,
|
||||
getShortUrlDetail,
|
||||
shortUrlEdition,
|
||||
|
@ -80,7 +77,6 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||
<ShortUrlForm
|
||||
initialState={initialState}
|
||||
saving={saving}
|
||||
selectedServer={selectedServer}
|
||||
mode="edit"
|
||||
onSave={async (shortUrlData) => {
|
||||
if (!shortUrl) {
|
||||
|
|
|
@ -8,17 +8,16 @@ import type { ChangeEvent, FC } from 'react';
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||
import type { InputType } from 'reactstrap/types/lib/Input';
|
||||
import type { SelectedServer } from '../../servers/data';
|
||||
import { Checkbox } from '../../utils/Checkbox';
|
||||
import type { DateTimeInputProps } from '../../utils/dates/DateTimeInput';
|
||||
import { DateTimeInput } from '../../utils/dates/DateTimeInput';
|
||||
import { formatIsoDate } from '../../utils/helpers/date';
|
||||
import { useFeature } from '../../utils/helpers/features';
|
||||
import { IconInput } from '../../utils/IconInput';
|
||||
import { SimpleCard } from '../../utils/SimpleCard';
|
||||
import { handleEventPreventingDefault, hasValue } from '../../utils/utils';
|
||||
import type { DomainSelectorProps } from '../domains/DomainSelector';
|
||||
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
import { useFeature } from '../utils/features';
|
||||
import type { DeviceLongUrls, ShortUrlData } from './data';
|
||||
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
|
||||
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
|
||||
|
@ -34,7 +33,6 @@ export interface ShortUrlFormProps {
|
|||
saving: boolean;
|
||||
initialState: ShortUrlData;
|
||||
onSave: (shortUrlData: ShortUrlData) => Promise<unknown>;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
|
@ -43,10 +41,10 @@ const toDate = (date?: string | Date): Date | undefined => (typeof date === 'str
|
|||
export const ShortUrlForm = (
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
DomainSelector: FC<DomainSelectorProps>,
|
||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState }) => {
|
||||
const [shortUrlData, setShortUrlData] = useState(initialState);
|
||||
const reset = () => setShortUrlData(initialState);
|
||||
const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer);
|
||||
const supportsDeviceLongUrls = useFeature('deviceLongUrls');
|
||||
|
||||
const isEdit = mode === 'edit';
|
||||
const isBasicMode = mode === 'create-basic';
|
||||
|
@ -136,7 +134,7 @@ export const ShortUrlForm = (
|
|||
</>
|
||||
);
|
||||
|
||||
const showForwardQueryControl = useFeature('forwardQuery', selectedServer);
|
||||
const showForwardQueryControl = useFeature('forwardQuery');
|
||||
|
||||
return (
|
||||
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
|
||||
|
|
|
@ -4,17 +4,16 @@ import classNames from 'classnames';
|
|||
import { isEmpty, pipe } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
||||
import type { SelectedServer } from '../../servers/data';
|
||||
import type { Settings } from '../../settings/reducers/settings';
|
||||
import { DateRangeSelector } from '../../utils/dates/DateRangeSelector';
|
||||
import { formatIsoDate } from '../../utils/helpers/date';
|
||||
import type { DateRange } from '../../utils/helpers/dateIntervals';
|
||||
import { datesToDateRange } from '../../utils/helpers/dateIntervals';
|
||||
import { useFeature } from '../../utils/helpers/features';
|
||||
import type { OrderDir } from '../../utils/helpers/ordering';
|
||||
import { OrderingDropdown } from '../../utils/OrderingDropdown';
|
||||
import { SearchField } from '../../utils/SearchField';
|
||||
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
import { useFeature } from '../utils/features';
|
||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||
import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
|
||||
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
||||
|
@ -23,7 +22,6 @@ import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
|
|||
import './ShortUrlsFilteringBar.scss';
|
||||
|
||||
interface ShortUrlsFilteringProps {
|
||||
selectedServer: SelectedServer;
|
||||
order: ShortUrlsOrder;
|
||||
settings: Settings;
|
||||
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
|
||||
|
@ -34,7 +32,7 @@ interface ShortUrlsFilteringProps {
|
|||
export const ShortUrlsFilteringBar = (
|
||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => {
|
||||
): FC<ShortUrlsFilteringProps> => ({ className, shortUrlsAmount, order, handleOrderBy, settings }) => {
|
||||
const [filter, toFirstPage] = useShortUrlsQuery();
|
||||
const {
|
||||
search,
|
||||
|
@ -46,7 +44,7 @@ export const ShortUrlsFilteringBar = (
|
|||
excludePastValidUntil,
|
||||
tagsMode = 'any',
|
||||
} = filter;
|
||||
const supportsDisabledFiltering = useFeature('filterDisabledUrls', selectedServer);
|
||||
const supportsDisabledFiltering = useFeature('filterDisabledUrls');
|
||||
|
||||
const setDates = pipe(
|
||||
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
||||
|
@ -60,7 +58,7 @@ export const ShortUrlsFilteringBar = (
|
|||
(searchTerm) => toFirstPage({ search: searchTerm }),
|
||||
);
|
||||
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
|
||||
const canChangeTagsMode = useFeature('allTagsFiltering', selectedServer);
|
||||
const canChangeTagsMode = useFeature('allTagsFiltering');
|
||||
const toggleTagsMode = pipe(
|
||||
() => (tagsMode === 'any' ? 'all' : 'any'),
|
||||
(mode) => toFirstPage({ tagsMode: mode }),
|
||||
|
|
|
@ -9,10 +9,10 @@ import type { SelectedServer } from '../../servers/data';
|
|||
import { getServerId } from '../../servers/data';
|
||||
import type { Settings } 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 { determineOrderDir } from '../../utils/helpers/ordering';
|
||||
import { TableOrderIcon } from '../../utils/table/TableOrderIcon';
|
||||
import { useFeature } from '../utils/features';
|
||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||
import { useShortUrlsQuery } from './helpers/hooks';
|
||||
import { Paginator } from './Paginator';
|
||||
|
@ -52,7 +52,7 @@ export const ShortUrlsList = (
|
|||
);
|
||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
|
||||
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls', selectedServer);
|
||||
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls');
|
||||
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
||||
toFirstPage({ orderBy: { field, dir } });
|
||||
setActualOrderBy({ field, dir });
|
||||
|
@ -101,7 +101,6 @@ export const ShortUrlsList = (
|
|||
return (
|
||||
<>
|
||||
<ShortUrlsFilteringBar
|
||||
selectedServer={selectedServer}
|
||||
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
|
||||
order={actualOrderBy}
|
||||
handleOrderBy={handleOrderBy}
|
||||
|
|
|
@ -4,28 +4,23 @@ import { useMemo, useState } from 'react';
|
|||
import { ExternalLink } from 'react-external-link';
|
||||
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
|
||||
import type { ImageDownloader } from '../../../common/services/ImageDownloader';
|
||||
import type { SelectedServer } from '../../../servers/data';
|
||||
import { CopyToClipboardIcon } from '../../../utils/CopyToClipboardIcon';
|
||||
import { useFeature } from '../../../utils/helpers/features';
|
||||
import type { QrCodeFormat, QrErrorCorrection } from '../../../utils/helpers/qrCodes';
|
||||
import { buildQrCodeUrl } from '../../../utils/helpers/qrCodes';
|
||||
import { useFeature } from '../../utils/features';
|
||||
import type { ShortUrlModalProps } from '../data';
|
||||
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
||||
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
|
||||
import './QrCodeModal.scss';
|
||||
|
||||
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
|
||||
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen }: ShortUrlModalProps,
|
||||
) => {
|
||||
const [size, setSize] = useState(300);
|
||||
const [margin, setMargin] = useState(0);
|
||||
const [format, setFormat] = useState<QrCodeFormat>('png');
|
||||
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
|
||||
const displayDownloadBtn = useFeature('nonRestCors', selectedServer);
|
||||
const displayDownloadBtn = useFeature('nonRestCors');
|
||||
const qrCodeUrl = useMemo(
|
||||
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
|
||||
[shortUrl, size, format, margin, errorCorrection],
|
||||
|
@ -46,7 +41,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
|||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Row>
|
||||
<FormGroup className="d-grid col-md-4">
|
||||
<FormGroup className="d-grid col-md-6">
|
||||
<label>Size: {size}px</label>
|
||||
<input
|
||||
type="range"
|
||||
|
@ -58,7 +53,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
|||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className="d-grid col-md-4">
|
||||
<FormGroup className="d-grid col-md-6">
|
||||
<label htmlFor="marginControl">Margin: {margin}px</label>
|
||||
<input
|
||||
id="marginControl"
|
||||
|
@ -71,7 +66,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
|||
onChange={(e) => setMargin(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className="d-grid col-md-4">
|
||||
<FormGroup className="d-grid col-md-6">
|
||||
<QrFormatDropdown format={format} setFormat={setFormat} />
|
||||
</FormGroup>
|
||||
<FormGroup className="col-md-6">
|
||||
|
|
|
@ -39,12 +39,12 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult');
|
||||
bottle.decorator(
|
||||
'CreateShortUrl',
|
||||
connect(['shortUrlCreation', 'selectedServer', 'settings'], ['createShortUrl', 'resetCreateShortUrl']),
|
||||
connect(['shortUrlCreation', 'settings'], ['createShortUrl', 'resetCreateShortUrl']),
|
||||
);
|
||||
|
||||
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
|
||||
bottle.decorator('EditShortUrl', connect(
|
||||
['shortUrlDetail', 'shortUrlEdition', 'selectedServer', 'settings'],
|
||||
['shortUrlDetail', 'shortUrlEdition', 'settings'],
|
||||
['getShortUrlDetail', 'editShortUrl'],
|
||||
));
|
||||
|
||||
|
@ -55,8 +55,6 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
));
|
||||
|
||||
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader');
|
||||
bottle.decorator('QrCodeModal', connect(['selectedServer']));
|
||||
|
||||
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector');
|
||||
|
||||
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
|
||||
|
|
|
@ -4,9 +4,10 @@ import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClie
|
|||
import type { ShlinkTags } from '../../../api/types';
|
||||
import type { ProblemDetailsError } from '../../../api/types/errors';
|
||||
import { parseApiError } from '../../../api/utils';
|
||||
import { supportedFeatures } from '../../../utils/helpers/features';
|
||||
import { isReachableServer } from '../../../servers/data';
|
||||
import { createAsyncThunk } from '../../../utils/helpers/redux';
|
||||
import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation';
|
||||
import { isFeatureEnabledForVersion } from '../../utils/features';
|
||||
import { createNewVisits } from '../../visits/reducers/visitCreation';
|
||||
import type { CreateVisit } from '../../visits/types';
|
||||
import type { TagStats } from '../data';
|
||||
|
@ -94,7 +95,9 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
|||
|
||||
const { listTags: shlinkListTags, tagsStats } = buildShlinkApiClient(getState);
|
||||
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 }) => {
|
||||
acc[tag] = rest;
|
||||
|
|
50
src/shlink-web-component/utils/features.ts
Normal file
50
src/shlink-web-component/utils/features.ts
Normal 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];
|
||||
};
|
|
@ -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],
|
||||
);
|
Loading…
Reference in a new issue