Merge pull request #611 from acelaya-forks/feature/new-coding-style

Feature/new coding style
This commit is contained in:
Alejandro Celaya 2022-03-27 09:48:39 +02:00 committed by GitHub
commit 9cac4d23a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
245 changed files with 2319 additions and 4485 deletions

View file

@ -1,25 +1,16 @@
{ {
"root": true,
"extends": [ "extends": [
"@shlinkio/js-coding-standard" "@shlinkio/js-coding-standard"
], ],
"plugins": ["jest"],
"env": {
"jest/globals": true
},
"parserOptions": { "parserOptions": {
"tsconfigRootDir": ".", "project": "./tsconfig.json"
"createDefaultProgram": true
},
"globals": {
"process": true,
"setImmediate": true
}, },
"ignorePatterns": ["src/service*.ts"], "ignorePatterns": ["src/service*.ts"],
"rules": { "rules": {
"complexity": "off", "jsx-a11y/control-has-associated-label": "off",
"import/named": "off", "jsx-a11y/label-has-associated-control": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off", "jsx-a11y/click-events-have-key-events": "off",
"@typescript-eslint/no-unsafe-return": "off", "jsx-a11y/no-static-element-interactions": "off"
"@typescript-eslint/no-unsafe-call": "off"
} }
} }

View file

@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
### Added
* *Nothing*
### Changed
* [#594](https://github.com/shlinkio/shlink-web-client/pull/594) Updated to a new coding standard.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [3.6.0] - 2022-03-17 ## [3.6.0] - 2022-03-17
### Added ### Added
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility. * [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.

4319
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -66,7 +66,7 @@
"workbox-strategies": "^6.5.1" "workbox-strategies": "^6.5.1"
}, },
"devDependencies": { "devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2", "@shlinkio/eslint-config-js-coding-standard": "~2.0.0",
"@stryker-mutator/core": "^5.6.1", "@stryker-mutator/core": "^5.6.1",
"@stryker-mutator/jest-runner": "^5.6.1", "@stryker-mutator/jest-runner": "^5.6.1",
"@stryker-mutator/typescript-checker": "^5.6.1", "@stryker-mutator/typescript-checker": "^5.6.1",
@ -91,7 +91,7 @@
"chalk": "^5.0.1", "chalk": "^5.0.1",
"dart-sass": "^1.25.0", "dart-sass": "^1.25.0",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"eslint": "^7.13.0", "eslint": "^8.12.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1", "jest": "^27.5.1",
"react-scripts": "^5.0.0", "react-scripts": "^5.0.0",

View file

@ -1,5 +1,3 @@
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
// Do this as the first thing so that any code reading it knows the right env. // Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production'; process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';

View file

@ -9,8 +9,10 @@ export interface ShlinkApiErrorProps {
export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => ( export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => (
<> <>
{errorData?.detail ?? fallbackMessage} {errorData?.detail ?? fallbackMessage}
{isInvalidArgumentError(errorData) && {isInvalidArgumentError(errorData) && (
<p className="mb-0">Invalid elements: [{errorData.invalidElements.join(', ')}]</p> <p className="mb-0">
} Invalid elements: [{errorData.invalidElements.join(', ')}]
</p>
)}
</> </>
); );

View file

@ -21,7 +21,7 @@ import {
import { stringifyQuery } from '../../utils/helpers/query'; import { stringifyQuery } from '../../utils/helpers/query';
import { orderToString } from '../../utils/helpers/ordering'; import { orderToString } from '../../utils/helpers/ordering';
const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : ''; const buildShlinkBaseUrl = (url: string) => (url ? `${url}/rest/v2` : '');
const rejectNilProps = reject(isNil); const rejectNilProps = reject(isNil);
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
const { orderBy = {}, ...rest } = params; const { orderBy = {}, ...rest } = params;
@ -76,7 +76,6 @@ export default class ShlinkApiClient {
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => {}); .then(() => {});
// eslint-disable-next-line valid-jsdoc
/** /**
* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead * @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead
*/ */
@ -91,10 +90,9 @@ export default class ShlinkApiClient {
public readonly updateShortUrl = async ( public readonly updateShortUrl = async (
shortCode: string, shortCode: string,
domain: OptionalString, domain: OptionalString,
data: ShlinkShortUrlData, edit: ShlinkShortUrlData,
): Promise<ShortUrl> => ): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, data) this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit).then(({ data }) => data);
.then(({ data }) => data);
public readonly listTags = async (): Promise<ShlinkTags> => public readonly listTags = async (): Promise<ShlinkTags> =>
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' }) this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })

View file

@ -23,7 +23,7 @@ const App = (
MenuLayout: FC, MenuLayout: FC,
CreateServer: FC, CreateServer: FC,
EditServer: FC, EditServer: FC,
Settings: FC, SettingsComp: FC,
ManageServers: FC, ManageServers: FC,
ShlinkVersionsContainer: FC, ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => { ) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
@ -47,7 +47,7 @@ const App = (
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}> <div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
<Routes> <Routes>
<Route index element={<Home />} /> <Route index element={<Home />} />
<Route path="/settings/*" element={<Settings />} /> <Route path="/settings/*" element={<SettingsComp />} />
<Route path="/manage-servers" element={<ManageServers />} /> <Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} /> <Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} /> <Route path="/server/:serverId/edit" element={<EditServer />} />

View file

@ -1,10 +1,8 @@
import { Action } from 'redux'; import { Action } from 'redux';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
/* eslint-disable padding-line-between-statements */
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE'; export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE'; export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
/* eslint-enable padding-line-between-statements */
const initialState = false; const initialState = false;

View file

@ -17,7 +17,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
'ManageServers', 'ManageServers',
'ShlinkVersionsContainer', 'ShlinkVersionsContainer',
); );
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ])); bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
// Actions // Actions
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable); bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);

View file

@ -13,7 +13,7 @@ interface AppUpdateBannerProps {
} }
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => { export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
const [ isUpdating,, setUpdating ] = useToggle(); const [isUpdating,, setUpdating] = useToggle();
const update = () => { const update = () => {
setUpdating(); setUpdating();
forceUpdate(); forceUpdate();

View file

@ -17,7 +17,6 @@ import './AsideMenu.scss';
export interface AsideMenuProps { export interface AsideMenuProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
className?: string;
showOnMobile?: boolean; showOnMobile?: boolean;
} }

View file

@ -6,7 +6,7 @@ interface ErrorHandlerState {
hasError: boolean; hasError: boolean;
} }
const ErrorHandler = ( const ErrorHandlerCreator = (
{ location }: Window, { location }: Window,
{ error }: Console, { error }: Console,
) => class ErrorHandler extends Component<any, ErrorHandlerState> { ) => class ErrorHandler extends Component<any, ErrorHandlerState> {
@ -26,7 +26,8 @@ const ErrorHandler = (
} }
public render(): ReactNode { public render(): ReactNode {
if (this.state.hasError) { const { hasError } = this.state;
if (hasError) {
return ( return (
<div className="home"> <div className="home">
<SimpleCard className="p-4"> <SimpleCard className="p-4">
@ -39,8 +40,9 @@ const ErrorHandler = (
); );
} }
return this.props.children; const { children } = this.props;
return children;
} }
}; };
export default ErrorHandler; export default ErrorHandlerCreator;

View file

@ -22,7 +22,6 @@ const Home = ({ servers }: HomeProps) => {
useEffect(() => { useEffect(() => {
// Try to redirect to the first server marked as auto-connect // Try to redirect to the first server marked as auto-connect
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect); const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
autoConnectServer && navigate(`/server/${autoConnectServer.id}`); autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
}, []); }, []);

View file

@ -9,11 +9,11 @@ import { ShlinkLogo } from './img/ShlinkLogo';
import './MainHeader.scss'; import './MainHeader.scss';
const MainHeader = (ServersDropdown: FC) => () => { const MainHeader = (ServersDropdown: FC) => () => {
const [ isOpen, toggleOpen, , close ] = useToggle(); const [isOpen, toggleOpen, , close] = useToggle();
const location = useLocation(); const location = useLocation();
const { pathname } = location; const { pathname } = location;
useEffect(close, [ location ]); useEffect(close, [location]);
const settingsPath = '/settings'; const settingsPath = '/settings';
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen }); const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });

View file

@ -31,10 +31,10 @@ const MenuLayout = (
ManageDomains: FC, ManageDomains: FC,
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => { ) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
const location = useLocation(); const location = useLocation();
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle(); const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
const showContent = isReachableServer(selectedServer); const showContent = isReachableServer(selectedServer);
useEffect(() => hideSidebar(), [ location ]); useEffect(() => hideSidebar(), [location]);
useEffect(() => { useEffect(() => {
showContent && sidebarPresent(); showContent && sidebarPresent();

View file

@ -6,7 +6,7 @@ const ScrollToTop = (): FC => ({ children }) => {
useEffect(() => { useEffect(() => {
scrollTo(0, 0); scrollTo(0, 0);
}, [ location ]); }, [location]);
return <>{children}</>; return <>{children}</>;
}; };

View file

@ -22,9 +22,9 @@ const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERS
return ( return (
<small className="text-muted"> <small className="text-muted">
{isReachableServer(selectedServer) && {isReachableServer(selectedServer) && (
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </> <>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
} )}
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} /> Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
</small> </small>
); );

View file

@ -1,10 +1,8 @@
import { Action } from 'redux'; import { Action } from 'redux';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
/* eslint-disable padding-line-between-statements */
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT'; export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT'; export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
/* eslint-enable padding-line-between-statements */
export interface Sidebar { export interface Sidebar {
sidebarPresent: boolean; sidebarPresent: boolean;

View file

@ -29,7 +29,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('Home', () => Home); bottle.serviceFactory('Home', () => Home);
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',
@ -47,12 +47,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
'EditShortUrl', 'EditShortUrl',
'ManageDomains', 'ManageDomains',
); );
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer', 'sidebarPresent', 'sidebarNotPresent' ])); bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent']));
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton'); bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer); bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer', 'sidebar' ])); bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer', 'sidebar']));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console'); bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');

View file

@ -20,8 +20,8 @@ const bottle = new Bottle();
export const { container } = bottle; export const { container } = bottle;
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) => const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
(...args: any[]) => (container[serviceName] as T)(...args) as K; (...args: any[]) => (cont[serviceName] as T)(...args) as K;
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({ const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
...map, ...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called // Wrap actual action service in a function so that it is lazily created the first time it is called

View file

@ -6,10 +6,10 @@ import { migrateDeprecatedSettings } from '../settings/helpers';
import { ShlinkState } from './types'; import { ShlinkState } from './types';
const isProduction = process.env.NODE_ENV !== 'production'; const isProduction = process.env.NODE_ENV !== 'production';
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const composeEnhancers: Function = !isProduction ? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose;
const localStorageConfig: RLSOptions = { const localStorageConfig: RLSOptions = {
states: [ 'settings', 'servers' ], states: ['settings', 'servers'],
namespace: 'shlink', namespace: 'shlink',
namespaceSeparator: '.', namespaceSeparator: '.',
debounce: 300, debounce: 300,

View file

@ -39,7 +39,7 @@ const DefaultDomain: FC = () => (
export const DomainRow: FC<DomainRowProps> = ( export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer }, { domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
) => { ) => {
const [ isOpen, toggle ] = useToggle(); const [isOpen, toggle] = useToggle();
const { domain: authority, isDefault, redirects, status } = domain; const { domain: authority, isDefault, redirects, status } = domain;
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer); const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);

View file

@ -19,7 +19,7 @@ interface DomainSelectorConnectProps extends DomainSelectorProps {
} }
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => { export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
const [ inputDisplayed,, showInput, hideInput ] = useToggle(); const [inputDisplayed,, showInput, hideInput] = useToggle();
const { domains } = domainsList; const { domains } = domainsList;
const valueIsEmpty = isEmpty(value); const valueIsEmpty = isEmpty(value);
const unselectDomain = () => onChange(''); const unselectDomain = () => onChange('');
@ -56,7 +56,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
{domains.map(({ domain, isDefault }) => ( {domains.map(({ domain, isDefault }) => (
<DropdownItem <DropdownItem
key={domain} key={domain}
active={value === domain || isDefault && valueIsEmpty} active={(value === domain || isDefault) && valueIsEmpty}
onClick={() => onChange(domain)} onClick={() => onChange(domain)}
> >
{domain} {domain}

View file

@ -18,7 +18,7 @@ interface ManageDomainsProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ]; const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
export const ManageDomains: FC<ManageDomainsProps> = ( export const ManageDomains: FC<ManageDomainsProps> = (
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer }, { listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },

View file

@ -18,7 +18,7 @@ interface DomainStatusIconProps {
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => { export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
const ref = useRef<HTMLSpanElement>(); const ref = useRef<HTMLSpanElement>();
const matchesMobile = () => matchMedia('(max-width: 991px)').matches; const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
const [ isMobile, setIsMobile ] = useState<boolean>(matchesMobile()); const [isMobile, setIsMobile] = useState<boolean>(matchesMobile());
useEffect(() => { useEffect(() => {
const listener = () => setIsMobile(matchesMobile()); const listener = () => setIsMobile(matchesMobile());

View file

@ -25,9 +25,9 @@ const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = ( export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
{ isOpen, toggle, domain, editDomainRedirects }, { isOpen, toggle, domain, editDomainRedirects },
) => { ) => {
const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? ''); const [baseUrlRedirect, setBaseUrlRedirect] = useState(domain.redirects?.baseUrlRedirect ?? '');
const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? ''); const [regular404Redirect, setRegular404Redirect] = useState(domain.redirects?.regular404Redirect ?? '');
const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState( const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
domain.redirects?.invalidShortUrlRedirect ?? '', domain.redirects?.invalidShortUrlRedirect ?? '',
); );
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, { const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {

View file

@ -5,11 +5,9 @@ import { GetState } from '../../container/types';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START'; export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR'; export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS'; export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
/* eslint-enable padding-line-between-statements */
export interface EditDomainRedirectsAction extends Action<string> { export interface EditDomainRedirectsAction extends Action<string> {
domain: string; domain: string;
@ -21,10 +19,10 @@ export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder
domainRedirects: Partial<ShlinkDomainRedirects>, domainRedirects: Partial<ShlinkDomainRedirects>,
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START }); dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
const { editDomainRedirects } = buildShlinkApiClient(getState); const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
try { try {
const redirects = await editDomainRedirects({ domain, ...domainRedirects }); const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects });
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
} catch (e: any) { } catch (e: any) {

View file

@ -10,13 +10,11 @@ import { hasServerData } from '../../servers/data';
import { replaceAuthorityFromUri } from '../../utils/helpers/uri'; import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects'; import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
/* eslint-disable padding-line-between-statements */
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START'; export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR'; export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN'; export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
/* eslint-enable padding-line-between-statements */
export interface DomainsList { export interface DomainsList {
domains: Domain[]; domains: Domain[];
@ -55,10 +53,10 @@ export type DomainsCombinedAction = ListDomainsAction
& ValidateDomain; & ValidateDomain;
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
(d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects }; (d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) => export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
(d: Domain): Domain => d.domain !== domain ? d : { ...d, status }; (d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
export default buildReducer<DomainsList, DomainsCombinedAction>({ export default buildReducer<DomainsList, DomainsCombinedAction>({
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
@ -86,15 +84,15 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
getState: GetState, getState: GetState,
) => { ) => {
dispatch({ type: LIST_DOMAINS_START }); dispatch({ type: LIST_DOMAINS_START });
const { listDomains } = buildShlinkApiClient(getState); const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
try { try {
const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({ const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
defaultRedirects, defaultRedirects,
})); }));
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects }); dispatch<ListDomainsAction>({ type: LIST_DOMAINS, ...resp });
} catch (e: any) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
} }

View file

@ -8,12 +8,12 @@ import { editDomainRedirects } from '../reducers/domainRedirects';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('DomainSelector', () => DomainSelector); bottle.serviceFactory('DomainSelector', () => DomainSelector);
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ])); bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains']));
bottle.serviceFactory('ManageDomains', () => ManageDomains); bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect( bottle.decorator('ManageDomains', connect(
[ 'domainsList', 'selectedServer' ], ['domainsList', 'selectedServer'],
[ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ], ['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
)); ));
// Actions // Actions

View file

@ -33,6 +33,6 @@ render(
// Learn more about service workers: https://cra.link/PWA // Learn more about service workers: https://cra.link/PWA
registerServiceWorker({ registerServiceWorker({
onUpdate() { onUpdate() {
store.dispatch(appUpdateAvailable()); // eslint-disable-line @typescript-eslint/no-unsafe-call store.dispatch(appUpdateAvailable());
}, },
}); });

View file

@ -23,7 +23,7 @@ export function boundToMercureHub<T = {}>(
const params = useParams(); const params = useParams();
useEffect(() => { useEffect(() => {
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]); const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
const topics = getTopicsForProps(props, params); const topics = getTopicsForProps(props, params);
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo); const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
@ -32,12 +32,12 @@ export function boundToMercureHub<T = {}>(
} }
const timer = setInterval(() => { const timer = setInterval(() => {
createNewVisits([ ...pendingUpdates ]); createNewVisits([...pendingUpdates]);
pendingUpdates.clear(); pendingUpdates.clear();
}, interval * 1000 * 60); }, interval * 1000 * 60);
return pipe(() => clearInterval(timer), () => closeEventSource?.()); return pipe(() => clearInterval(timer), () => closeEventSource?.());
}, [ mercureInfo ]); }, [mercureInfo]);
return <WrappedComponent {...props} />; return <WrappedComponent {...props} />;
}; };

View file

@ -4,11 +4,9 @@ import { GetState } from '../../container/types';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
/* eslint-disable padding-line-between-statements */
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START'; export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR'; export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO'; export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
/* eslint-enable padding-line-between-statements */
export interface MercureInfo { export interface MercureInfo {
token?: string; token?: string;

View file

@ -32,10 +32,10 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
const navigate = useNavigate(); const navigate = useNavigate();
const goBack = useGoBack(); const goBack = useGoBack();
const hasServers = !!Object.keys(servers).length; const hasServers = !!Object.keys(servers).length;
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [serversImported, setServersImported] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle(); const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [ serverData, setServerData ] = useState<ServerData | undefined>(); const [serverData, setServerData] = useState<ServerData | undefined>();
const save = () => { const save = () => {
if (!serverData) { if (!serverData) {
return; return;
@ -53,13 +53,14 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
); );
serverExists ? toggleConfirmModal() : save(); serverExists ? toggleConfirmModal() : save();
}, [ serverData ]); }, [serverData]);
return ( return (
<NoMenuLayout> <NoMenuLayout>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}> <ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
{!hasServers && {!hasServers && (
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />} <ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
)}
{hasServers && <Button outline onClick={goBack}>Cancel</Button>} {hasServers && <Button outline onClick={goBack}>Cancel</Button>}
<Button outline color="primary" className="ms-2">Create server</Button> <Button outline color="primary" className="ms-2">Create server</Button>
</ServerForm> </ServerForm>
@ -69,7 +70,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
<DuplicatedServersModal <DuplicatedServersModal
isOpen={isConfirmModalOpen} isOpen={isConfirmModalOpen}
duplicatedServers={serverData ? [ serverData ] : []} duplicatedServers={serverData ? [serverData] : []}
onDiscard={goBack} onDiscard={goBack}
onSave={save} onSave={save}
/> />

View file

@ -14,7 +14,7 @@ export interface DeleteServerButtonProps {
const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => ( const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
{ server, className, children, textClassName }, { server, className, children, textClassName },
) => { ) => {
const [ isModalOpen, , showModal, hideModal ] = useToggle(); const [isModalOpen, , showModal, hideModal] = useToggle();
return ( return (
<> <>

View file

@ -1,5 +1,5 @@
import { FC } from 'react'; import { FC } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ServerWithId } from './data'; import { ServerWithId } from './data';
@ -37,8 +37,8 @@ const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
</p> </p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button> <Button color="link" onClick={toggle}>Cancel</Button>
<button className="btn btn-danger" onClick={() => closeModal()}>Delete</button> <Button color="danger" onClick={() => closeModal()}>Delete</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
); );

View file

@ -26,16 +26,16 @@ export const ManageServers = (
ManageServersRow: FC<ManageServersRowProps>, ManageServersRow: FC<ManageServersRowProps>,
): FC<ManageServersProps> => ({ servers }) => { ): FC<ManageServersProps> => ({ servers }) => {
const allServers = Object.values(servers); const allServers = Object.values(servers);
const [ serversList, setServersList ] = useState(allServers); const [serversList, setServersList] = useState(allServers);
const filterServers = (searchTerm: string) => setServersList( const filterServers = (searchTerm: string) => setServersList(
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)), allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
); );
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect); const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
useEffect(() => { useEffect(() => {
setServersList(Object.values(servers)); setServersList(Object.values(servers));
}, [ servers ]); }, [servers]);
return ( return (
<NoMenuLayout> <NoMenuLayout>
@ -61,17 +61,17 @@ export const ManageServers = (
<table className="table table-hover responsive-table mb-0"> <table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header"> <thead className="responsive-table__header">
<tr> <tr>
{hasAutoConnect && <th style={{ width: '50px' }} />} {hasAutoConnect && <th aria-label="Auto-connect" style={{ width: '50px' }} />}
<th>Name</th> <th>Name</th>
<th>Base URL</th> <th>Base URL</th>
<th /> <th aria-label="Options" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>} {!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
{serversList.map((server) => {serversList.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />) <ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
} ))}
</tbody> </tbody>
</table> </table>
</SimpleCard> </SimpleCard>

View file

@ -25,8 +25,8 @@ interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownP
export const ManageServersRowDropdown = ( export const ManageServersRowDropdown = (
DeleteServerModal: FC<DeleteServerModalProps>, DeleteServerModal: FC<DeleteServerModalProps>,
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => { ): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
const [ isMenuOpen, toggleMenu ] = useToggle(); const [isMenuOpen, toggleMenu] = useToggle();
const [ isModalOpen,, showModal, hideModal ] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const serverUrl = `/server/${server.id}`; const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server; const { autoConnect: isAutoConnect } = server;
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon; const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;

View file

@ -109,4 +109,4 @@ export const Overview = (
</Card> </Card>
</> </>
); );
}, () => [ Topics.visits, Topics.orphanVisits ]); }, () => [Topics.visits, Topics.orphanVisits]);

View file

@ -35,15 +35,15 @@ export const hasServerData = (server: SelectedServer | ServerData): server is Se
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey; !!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId => export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
!!server?.hasOwnProperty('id'); !!(server as ServerWithId)?.id;
export const isReachableServer = (server: SelectedServer): server is ReachableServer => export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
!!server?.hasOwnProperty('version'); !!(server as ReachableServer)?.version;
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer => export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
!!server?.hasOwnProperty('serverNotFound'); !!(server as NotFoundServer)?.serverNotFound;
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : ''; export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
export const serverWithIdToServerData = (server: ServerWithId): ServerData => export const serverWithIdToServerData = (server: ServerWithId): ServerData =>
omit<ServerWithId, 'id' | 'autoConnect'>([ 'id', 'autoConnect' ], server); omit<ServerWithId, 'id' | 'autoConnect'>(['id', 'autoConnect'], server);

View file

@ -20,12 +20,12 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
<ModalBody> <ModalBody>
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p> <p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul> <ul>
{duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? ( {duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}> <Fragment key={index}>
<li>URL: <b>{url}</b></li> <li>URL: <b>{url}</b></li>
<li>API key: <b>{apiKey}</b></li> <li>API key: <b>{apiKey}</b></li>
</Fragment> </Fragment>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>)} ) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
</ul> </ul>
<span> <span>
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}? {hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?

View file

@ -10,7 +10,7 @@ export interface HighlightCardProps {
link?: string | false; link?: string | false;
} }
const buildExtraProps = (link?: string | false) => !link ? {} : { tag: Link, to: link }; const buildExtraProps = (link?: string | false) => (!link ? {} : { tag: Link, to: link });
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => ( export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => (
<Card className="highlight-card" body {...buildExtraProps(link)}> <Card className="highlight-card" body {...buildExtraProps(link)}>

View file

@ -1,4 +1,4 @@
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react'; import { useRef, RefObject, ChangeEvent, MutableRefObject, useState, useEffect, FC } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import { complement, pipe } from 'ramda'; import { complement, pipe } from 'ramda';
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
@ -38,9 +38,9 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
className = '', className = '',
}) => { }) => {
const ref = fileRef ?? useRef<HTMLInputElement>(); const ref = fileRef ?? useRef<HTMLInputElement>();
const [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>(); const [serversToCreate, setServersToCreate] = useState<ServerData[] | undefined>();
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]); const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [ isModalOpen,, showModal, hideModal ] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const create = pipe(createServers, onImport); const create = pipe(createServers, onImport);
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal); const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
const createNonDuplicatedServers = pipe( const createNonDuplicatedServers = pipe(
@ -52,7 +52,7 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
.then(setServersToCreate) .then(setServersToCreate)
.then(() => { .then(() => {
// Reset input after processing file // Reset input after processing file
(target as { value: string | null }).value = null; (target as { value: string | null }).value = null; // eslint-disable-line no-param-reassign
}) })
.catch(onImportError); .catch(onImportError);
@ -62,12 +62,12 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
} }
const existingServers = Object.values(servers); const existingServers = Object.values(servers);
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers)); const dupServers = serversToCreate.filter(serversFiltering(existingServers));
const hasDuplicatedServers = !!duplicatedServers.length; const hasDuplicatedServers = !!dupServers.length;
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers); !hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(dupServers);
hasDuplicatedServers && showModal(); hasDuplicatedServers && showModal();
}, [ serversToCreate ]); }, [serversToCreate]);
return ( return (
<> <>

View file

@ -11,16 +11,16 @@ interface ServerFormProps {
} }
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => { export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
const [ name, setName ] = useState(''); const [name, setName] = useState('');
const [ url, setUrl ] = useState(''); const [url, setUrl] = useState('');
const [ apiKey, setApiKey ] = useState(''); const [apiKey, setApiKey] = useState('');
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey })); const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
useEffect(() => { useEffect(() => {
initialValues && setName(initialValues.name); initialValues && setName(initialValues.name);
initialValues && setUrl(initialValues.url); initialValues && setUrl(initialValues.url);
initialValues && setApiKey(initialValues.apiKey); initialValues && setApiKey(initialValues.apiKey);
}, [ initialValues ]); }, [initialValues]);
return ( return (
<form className="server-form" onSubmit={handleSubmit}> <form className="server-form" onSubmit={handleSubmit}>

View file

@ -16,7 +16,7 @@ export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServ
useEffect(() => { useEffect(() => {
params.serverId && selectServer(params.serverId); params.serverId && selectServer(params.serverId);
}, [ params.serverId ]); }, [params.serverId]);
if (!selectedServer) { if (!selectedServer) {
return ( return (

View file

@ -6,8 +6,9 @@ interface WithoutSelectedServerProps {
export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) { export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
return (props: WithoutSelectedServerProps & T) => { return (props: WithoutSelectedServerProps & T) => {
const { resetSelectedServer } = props;
useEffect(() => { useEffect(() => {
props.resetSelectedServer(); resetSelectedServer();
}, []); }, []);
return <WrappedComponent {...props} />; return <WrappedComponent {...props} />;

View file

@ -7,7 +7,7 @@ import { createServers } from './servers';
const responseToServersList = pipe( const responseToServersList = pipe(
prop<any, any>('data'), prop<any, any>('data'),
(data: any): ServerData[] => Array.isArray(data) ? data.filter(hasServerData) : [], (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []),
); );
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => { export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {

View file

@ -7,21 +7,19 @@ import { ShlinkHealth } from '../../api/types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER'; export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER'; export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
export const MIN_FALLBACK_VERSION = '1.0.0'; export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999'; export const MAX_FALLBACK_VERSION = '999.999.999';
export const LATEST_VERSION_CONSTRAINT = 'latest'; export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */
export interface SelectServerAction extends Action<string> { export interface SelectServerAction extends Action<string> {
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }
const versionToSemVer = pipe( const versionToSemVer = pipe(
(version: string) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version, (version: string) => (version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version),
toSemVer(MIN_FALLBACK_VERSION), toSemVer(MIN_FALLBACK_VERSION),
); );

View file

@ -4,12 +4,10 @@ import { Action } from 'redux';
import { ServerData, ServersMap, ServerWithId } from '../data'; import { ServerData, ServersMap, ServerWithId } from '../data';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
/* eslint-disable padding-line-between-statements */
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER'; export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER'; export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS'; export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT'; export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
/* eslint-enable padding-line-between-statements */
export interface CreateServersAction extends Action<string> { export interface CreateServersAction extends Action<string> {
newServers: ServersMap; newServers: ServersMap;
@ -37,9 +35,9 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({ export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }), [CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state), [DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId] [EDIT_SERVER]: (state, { serverId, serverData }: any) => (
? state !state[serverId] ? state : assoc(serverId, { ...state[serverId], ...serverData }, state)
: assoc(serverId, { ...state[serverId], ...serverData }, state), ),
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => { [SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
if (!state[serverId]) { if (!state[serverId]) {
return state; return state;
@ -50,7 +48,7 @@ export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction
} }
return fromPairs( return fromPairs(
toPairs(state).map(([ evaluatedServerId, server ]) => [ toPairs(state).map(([evaluatedServerId, server]) => [
evaluatedServerId, evaluatedServerId,
{ ...server, autoConnect: evaluatedServerId === serverId }, { ...server, autoConnect: evaluatedServerId === serverId },
]), ]),
@ -66,7 +64,7 @@ export const createServers = pipe(
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }), (newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
); );
export const createServer = (server: ServerWithId) => createServers([ server ]); export const createServer = (server: ServerWithId) => createServers([server]);
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({ export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
type: EDIT_SERVER, type: EDIT_SERVER,

View file

@ -29,8 +29,8 @@ export class ServersImporter {
} }
resolve(servers); resolve(servers);
} catch (e) { } catch (error) {
reject(e); reject(error);
} }
}); });
reader.readAsText(file); reader.readAsText(file);

View file

@ -31,41 +31,41 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
'ManageServersRow', 'ManageServersRow',
); );
bottle.decorator('ManageServers', withoutSelectedServer); bottle.decorator('ManageServers', withoutSelectedServer);
bottle.decorator('ManageServers', connect([ 'selectedServer', 'servers' ], [ 'resetSelectedServer' ])); bottle.decorator('ManageServers', connect(['selectedServer', 'servers'], ['resetSelectedServer']));
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown'); bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal'); bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ])); bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect']));
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout'); bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.decorator('CreateServer', withoutSelectedServer); bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ])); bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServer', 'resetSelectedServer']));
bottle.serviceFactory('EditServer', EditServer, 'ServerError'); bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer', 'resetSelectedServer' ])); bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer']));
bottle.serviceFactory('ServersDropdown', () => ServersDropdown); bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ])); bottle.decorator('ServersDropdown', connect(['servers', 'selectedServer']));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal); bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ])); bottle.decorator('DeleteServerModal', connect(null, ['deleteServer']));
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal'); bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter'); bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ])); bottle.decorator('ImportServersBtn', connect(['servers'], ['createServers']));
bottle.serviceFactory('ForServerVersion', () => ForServerVersion); bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ])); bottle.decorator('ForServerVersion', connect(['selectedServer']));
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton'); bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ])); bottle.decorator('ServerError', connect(['servers', 'selectedServer']));
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl', 'ForServerVersion'); bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl', 'ForServerVersion');
bottle.decorator('Overview', connect( bottle.decorator('Overview', connect(
[ 'shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview' ], ['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview'],
[ 'listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview' ], ['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
)); ));
// Services // Services

View file

@ -1,5 +1,4 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */
// This service worker can be customized! // This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules // See https://developers.google.com/web/tools/workbox/modules

View file

@ -12,7 +12,7 @@ interface RealTimeUpdatesProps {
setRealTimeUpdatesInterval: (interval: number) => void; setRealTimeUpdatesInterval: (interval: number) => void;
} }
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`; const intervalValue = (interval?: number) => (!interval ? '' : `${interval}`);
const RealTimeUpdatesSettings = ( const RealTimeUpdatesSettings = (
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps, { settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,

View file

@ -25,9 +25,9 @@ const Settings = (
</NavPills> </NavPills>
<Routes> <Routes>
<Route path="general" element={<SettingsSections items={[ <UserInterface key="one" />, <RealTimeUpdates key="two" /> ]} />} /> <Route path="general" element={<SettingsSections items={[<UserInterface />, <RealTimeUpdates />]} />} />
<Route path="short-urls" element={<SettingsSections items={[ <ShortUrlCreation key="one" />, <ShortUrlsList key="two" /> ]} />} /> <Route path="short-urls" element={<SettingsSections items={[<ShortUrlCreation />, <ShortUrlsList />]} />} />
<Route path="other-items" element={<SettingsSections items={[ <Tags key="one" />, <Visits key="two" /> ]} />} /> <Route path="other-items" element={<SettingsSections items={[<Tags />, <Visits />]} />} />
<Route path="*" element={<Navigate replace to="general" />} /> <Route path="*" element={<Navigate replace to="general" />} />
</Routes> </Routes>
</NoMenuLayout> </NoMenuLayout>

View file

@ -13,11 +13,12 @@ interface ShortUrlCreationProps {
} }
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string => const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input'; (tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input');
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode => const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode => (
tagFilteringMode === 'includes' tagFilteringMode === 'includes'
? <>The list of suggested tags will contain those <b>including</b> provided input.</> ? <>The list of suggested tags will contain those <b>including</b> provided input.</>
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>; : <>The list of suggested tags will contain those <b>starting with</b> provided input.</>
);
export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => { export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false }; const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };

View file

@ -1,5 +1,6 @@
import { ShlinkState } from '../../container/types'; import { ShlinkState } from '../../container/types';
/* eslint-disable no-param-reassign */
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => { export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {
if (!state.settings) { if (!state.settings) {
return state; return state;

View file

@ -31,28 +31,28 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
'TagsSettings', 'TagsSettings',
); );
bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ])); bottle.decorator('Settings', connect(null, ['resetSelectedServer']));
bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings); bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
bottle.decorator( bottle.decorator(
'RealTimeUpdatesSettings', 'RealTimeUpdatesSettings',
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]), connect(['settings'], ['toggleRealTimeUpdates', 'setRealTimeUpdatesInterval']),
); );
bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings); bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
bottle.decorator('ShortUrlCreationSettings', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ])); bottle.decorator('ShortUrlCreationSettings', connect(['settings'], ['setShortUrlCreationSettings']));
bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings); bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
bottle.decorator('UserInterfaceSettings', connect([ 'settings' ], [ 'setUiSettings' ])); bottle.decorator('UserInterfaceSettings', connect(['settings'], ['setUiSettings']));
bottle.serviceFactory('VisitsSettings', () => VisitsSettings); bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
bottle.decorator('VisitsSettings', connect([ 'settings' ], [ 'setVisitsSettings' ])); bottle.decorator('VisitsSettings', connect(['settings'], ['setVisitsSettings']));
bottle.serviceFactory('TagsSettings', () => TagsSettings); bottle.serviceFactory('TagsSettings', () => TagsSettings);
bottle.decorator('TagsSettings', connect([ 'settings' ], [ 'setTagsSettings' ])); bottle.decorator('TagsSettings', connect(['settings'], ['setTagsSettings']));
bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings); bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings);
bottle.decorator('ShortUrlsListSettings', connect([ 'settings' ], [ 'setShortUrlsListSettings' ])); bottle.decorator('ShortUrlsListSettings', connect(['settings'], ['setShortUrlsListSettings']));
// Actions // Actions
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);

View file

@ -41,7 +41,7 @@ const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResul
basicMode = false, basicMode = false,
settings: { shortUrlCreation: shortUrlCreationSettings }, settings: { shortUrlCreation: shortUrlCreationSettings },
}: CreateShortUrlConnectProps) => { }: CreateShortUrlConnectProps) => {
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [ shortUrlCreationSettings ]); const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]);
return ( return (
<> <>

View file

@ -63,9 +63,9 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
const { domain } = parseQuery<{ domain?: string }>(search); const { domain } = parseQuery<{ domain?: string }>(search);
const initialState = useMemo( const initialState = useMemo(
() => getInitialState(shortUrl, shortUrlCreationSettings), () => getInitialState(shortUrl, shortUrlCreationSettings),
[ shortUrl, shortUrlCreationSettings ], [shortUrl, shortUrlCreationSettings],
); );
const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle(); const [savingSucceeded,, isSuccessful, isNotSuccessful] = useToggle();
useEffect(() => { useEffect(() => {
params.shortCode && getShortUrlDetail(params.shortCode, domain); params.shortCode && getShortUrlDetail(params.shortCode, domain);

View file

@ -32,14 +32,14 @@ export interface ShortUrlFormProps {
} }
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date; const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
const dynamicColClasses = (flag: boolean) => ({ 'col-sm-6': flag, 'col-sm-12': !flag }); const dynamicColClasses = (flag: boolean) => ({ 'col-sm-6': flag, 'col-sm-12': !flag });
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, selectedServer }) => {
const [ shortUrlData, setShortUrlData ] = useState(initialState); const [shortUrlData, setShortUrlData] = useState(initialState);
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const isBasicMode = mode === 'create-basic'; const isBasicMode = mode === 'create-basic';
const hadTitleOriginally = hasValue(initialState.title); const hadTitleOriginally = hasValue(initialState.title);
@ -48,9 +48,9 @@ export const ShortUrlForm = (
const resolveNewTitle = (): OptionalString => { const resolveNewTitle = (): OptionalString => {
const hasNewTitle = hasValue(shortUrlData.title); const hasNewTitle = hasValue(shortUrlData.title);
const matcher = cond<never, OptionalString>([ const matcher = cond<never, OptionalString>([
[ () => !hasNewTitle && !hadTitleOriginally, () => undefined ], [() => !hasNewTitle && !hadTitleOriginally, () => undefined],
[ () => !hasNewTitle && hadTitleOriginally, () => null ], [() => !hasNewTitle && hadTitleOriginally, () => null],
[ T, () => shortUrlData.title ], [T, () => shortUrlData.title],
]); ]);
return matcher(); return matcher();
@ -65,7 +65,7 @@ export const ShortUrlForm = (
useEffect(() => { useEffect(() => {
setShortUrlData(initialState); setShortUrlData(initialState);
}, [ initialState ]); }, [initialState]);
const renderOptionalInput = ( const renderOptionalInput = (
id: NonDateFields, id: NonDateFields,

View file

@ -29,23 +29,23 @@ export interface ShortUrlsFilteringProps {
shortUrlsAmount?: number; shortUrlsAmount?: number;
} }
const dateOrNull = (date?: string) => date ? parseISO(date) : null; const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
const ShortUrlsFilteringBar = ( const ShortUrlsFilteringBar = (
colorGenerator: ColorGenerator, colorGenerator: ColorGenerator,
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>, ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => { ): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery(); const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery();
const setDates = pipe( const setDates = pipe(
({ startDate, endDate }: DateRange) => ({ ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
startDate: formatIsoDate(startDate) ?? undefined, startDate: formatIsoDate(theStartDate) ?? undefined,
endDate: formatIsoDate(endDate) ?? undefined, endDate: formatIsoDate(theEndDate) ?? undefined,
}), }),
toFirstPage, toFirstPage,
); );
const setSearch = pipe( const setSearch = pipe(
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm, (searchTerm: string) => (isEmpty(searchTerm) ? undefined : searchTerm),
(search) => toFirstPage({ search }), (searchTerm) => toFirstPage({ search: searchTerm }),
); );
const removeTag = pipe( const removeTag = pipe(
(tag: string) => tags.filter((selectedTag) => selectedTag !== tag), (tag: string) => tags.filter((selectedTag) => selectedTag !== tag),
@ -53,8 +53,8 @@ const ShortUrlsFilteringBar = (
); );
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
const toggleTagsMode = pipe( const toggleTagsMode = pipe(
() => tagsMode === 'any' ? 'all' : 'any', () => (tagsMode === 'any' ? 'all' : 'any'),
(tagsMode) => toFirstPage({ tagsMode }), (mode) => toFirstPage({ tagsMode: mode }),
); );
return ( return (

View file

@ -30,8 +30,8 @@ const ShortUrlsList = (
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const { page } = useParams(); const { page } = useParams();
const location = useLocation(); const location = useLocation();
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage ] = useShortUrlsQuery(); const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage] = useShortUrlsQuery();
const [ actualOrderBy, setActualOrderBy ] = useState( const [actualOrderBy, setActualOrderBy] = useState(
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded // This separated state handling is needed to be able to fall back to settings value, but only once when loaded
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING, orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
); );
@ -45,7 +45,7 @@ const ShortUrlsList = (
const renderOrderIcon = (field: ShortUrlsOrderableFields) => const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
<TableOrderIcon currentOrder={actualOrderBy} field={field} />; <TableOrderIcon currentOrder={actualOrderBy} field={field} />;
const addTag = pipe( const addTag = pipe(
(newTag: string) => [ ...new Set([ ...tags, newTag ]) ], (newTag: string) => [...new Set([...tags, newTag])],
(updatedTags) => toFirstPage({ tags: updatedTags }), (updatedTags) => toFirstPage({ tags: updatedTags }),
); );
@ -59,7 +59,7 @@ const ShortUrlsList = (
orderBy: actualOrderBy, orderBy: actualOrderBy,
tagsMode, tagsMode,
}); });
}, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]); }, [page, search, tags, startDate, endDate, actualOrderBy, tagsMode]);
return ( return (
<> <>
@ -82,6 +82,6 @@ const ShortUrlsList = (
</Card> </Card>
</> </>
); );
}, () => [ Topics.visits ]); }, () => [Topics.visits]);
export default ShortUrlsList; export default ShortUrlsList;

View file

@ -70,11 +70,11 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}> <th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
Short URL {renderOrderIcon?.('shortCode')} Short URL {renderOrderIcon?.('shortCode')}
</th> </th>
{!supportsTitle && ( {!supportsTitle ? (
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}> <th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
Long URL {renderOrderIcon?.('longUrl')} Long URL {renderOrderIcon?.('longUrl')}
</th> </th>
) || ( ) : (
<th className="short-urls-table__header-cell"> <th className="short-urls-table__header-cell">
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}> <span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
Title {renderOrderIcon?.('title')} Title {renderOrderIcon?.('title')}

View file

@ -37,7 +37,7 @@ const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void })
); );
const UseExistingIfFoundInfoIcon = () => { const UseExistingIfFoundInfoIcon = () => {
const [ isModalOpen, toggleModal ] = useToggle(); const [isModalOpen, toggleModal] = useToggle();
return ( return (
<> <>

View file

@ -19,7 +19,7 @@ export interface CreateShortUrlResultProps extends ShortUrlCreation {
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => ( const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
{ error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps, { error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
) => { ) => {
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout(); const [showCopyTooltip, setShowCopyTooltip] = useStateFlagTimeout();
useEffect(() => { useEffect(() => {
resetCreateShortUrl(); resetCreateShortUrl();

View file

@ -17,7 +17,7 @@ interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
const DeleteShortUrlModal = ( const DeleteShortUrlModal = (
{ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }: DeleteShortUrlModalConnectProps, { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }: DeleteShortUrlModalConnectProps,
) => { ) => {
const [ inputValue, setInputValue ] = useState(''); const [inputValue, setInputValue] = useState('');
useEffect(() => resetDeleteShortUrl, []); useEffect(() => resetDeleteShortUrl, []);

View file

@ -22,7 +22,7 @@ export const ExportShortUrlsBtn = (
{ exportShortUrls }: ReportExporter, { exportShortUrls }: ReportExporter,
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => { ): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => {
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery(); const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
const [ loading,, startLoading, stopLoading ] = useToggle(); const [loading,, startLoading, stopLoading] = useToggle();
const exportAllUrls = async () => { const exportAllUrls = async () => {
if (!isServerWithId(selectedServer)) { if (!isServerWithId(selectedServer)) {
return; return;

View file

@ -22,31 +22,31 @@ interface QrCodeModalConnectProps extends ShortUrlModalProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }
const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Versions>) => ( // eslint-disable-line const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Versions>) => (
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps, { shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
) => { ) => {
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 capabilities: QrCodeCapabilities = useMemo(() => ({ const capabilities: QrCodeCapabilities = useMemo(() => ({
useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer), useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer),
marginIsSupported: supportsQrCodeMargin(selectedServer), marginIsSupported: supportsQrCodeMargin(selectedServer),
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer), errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
}), [ selectedServer ]); }), [selectedServer]);
const willRenderThreeControls = capabilities.marginIsSupported !== capabilities.errorCorrectionIsSupported; const willRenderThreeControls = capabilities.marginIsSupported !== capabilities.errorCorrectionIsSupported;
const qrCodeUrl = useMemo( const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities), () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities),
[ shortUrl, size, format, margin, errorCorrection, capabilities ], [shortUrl, size, format, margin, errorCorrection, capabilities],
); );
const totalSize = useMemo(() => size + margin, [ size, margin ]); const totalSize = useMemo(() => size + margin, [size, margin]);
const modalSize = useMemo(() => { const modalSize = useMemo(() => {
if (totalSize < 500) { if (totalSize < 500) {
return undefined; return undefined;
} }
return totalSize < 800 ? 'lg' : 'xl'; return totalSize < 800 ? 'lg' : 'xl';
}, [ totalSize ]); }, [totalSize]);
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered size={modalSize}> <Modal isOpen={isOpen} toggle={toggle} centered size={modalSize}>
@ -69,8 +69,9 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
</FormGroup> </FormGroup>
{capabilities.marginIsSupported && ( {capabilities.marginIsSupported && (
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}> <FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
<label>Margin: {margin}px</label> <label htmlFor="marginControl">Margin: {margin}px</label>
<input <input
id="marginControl"
type="range" type="range"
className="form-control-range" className="form-control-range"
value={margin} value={margin}
@ -101,7 +102,9 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Vers
<Button <Button
block block
color="primary" color="primary"
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)} onClick={() => {
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
}}
> >
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" /> Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
</Button> </Button>

View file

@ -23,8 +23,8 @@ const ShortUrlsRow = (
colorGenerator: ColorGenerator, colorGenerator: ColorGenerator,
useStateFlagTimeout: StateFlagTimeout, useStateFlagTimeout: StateFlagTimeout,
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => { ) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => {
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout(); const [copiedToClipboard, setCopiedToClipboard] = useStateFlagTimeout();
const [ active, setActive ] = useStateFlagTimeout(false, 500); const [active, setActive] = useStateFlagTimeout(false, 500);
const isFirstRun = useRef(true); const isFirstRun = useRef(true);
const renderTags = (tags: string[]) => { const renderTags = (tags: string[]) => {
@ -48,7 +48,7 @@ const ShortUrlsRow = (
} else { } else {
setActive(); setActive();
} }
}, [ shortUrl.visitsCount ]); }, [shortUrl.visitsCount]);
return ( return (
<tr className="responsive-table__row"> <tr className="responsive-table__row">

View file

@ -23,9 +23,9 @@ const ShortUrlsRowMenu = (
DeleteShortUrlModal: ShortUrlModal, DeleteShortUrlModal: ShortUrlModal,
QrCodeModal: ShortUrlModal, QrCodeModal: ShortUrlModal,
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => { ) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
const [ isOpen, toggle ] = useToggle(); const [isOpen, toggle] = useToggle();
const [ isQrModalOpen, toggleQrCode ] = useToggle(); const [isQrModalOpen, toggleQrCode] = useToggle();
const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [isDeleteModalOpen, toggleDelete] = useToggle();
return ( return (
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}> <DropdownBtnMenu toggle={toggle} isOpen={isOpen}>

View file

@ -8,11 +8,6 @@ import { TagsFilteringMode } from '../../api/types';
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void; type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
export interface ShortUrlListRouteParams {
page: string;
serverId: string;
}
interface ShortUrlsQueryCommon { interface ShortUrlsQueryCommon {
search?: string; search?: string;
startDate?: string; startDate?: string;
@ -45,7 +40,7 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags }; return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
}, },
), ),
[ location.search ], [location.search],
); );
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => { const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra }; const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
@ -57,8 +52,8 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const evolvedQuery = stringifyQuery(normalizedQuery); const evolvedQuery = stringifyQuery(normalizedQuery);
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`; const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
navigate(`/server/${params.serverId}/list-short-urls/1${queryString}`); navigate(`/server/${params.serverId ?? ''}/list-short-urls/1${queryString}`);
}; };
return [ query, toFirstPageWithExtra ]; return [query, toFirstPageWithExtra];
}; };

View file

@ -7,12 +7,10 @@ import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR'; export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL'; export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL'; export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
/* eslint-enable padding-line-between-statements */
export interface ShortUrlCreation { export interface ShortUrlCreation {
result: ShortUrl | null; result: ShortUrl | null;
@ -43,10 +41,10 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
getState: GetState, getState: GetState,
) => { ) => {
dispatch({ type: CREATE_SHORT_URL_START }); dispatch({ type: CREATE_SHORT_URL_START });
const { createShortUrl } = buildShlinkApiClient(getState); const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState);
try { try {
const result = await createShortUrl(data); const result = await shlinkCreateShortUrl(data);
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result }); dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
} catch (e: any) { } catch (e: any) {

View file

@ -6,12 +6,10 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START'; export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR'; export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED'; export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL'; export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
/* eslint-enable padding-line-between-statements */
export interface ShortUrlDeletion { export interface ShortUrlDeletion {
shortCode: string; shortCode: string;
@ -43,10 +41,10 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
domain?: string | null, domain?: string | null,
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: DELETE_SHORT_URL_START }); dispatch({ type: DELETE_SHORT_URL_START });
const { deleteShortUrl } = buildShlinkApiClient(getState); const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState);
try { try {
await deleteShortUrl(shortCode, domain); await shlinkDeleteShortUrl(shortCode, domain);
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain }); dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
} catch (e: any) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });

View file

@ -9,11 +9,9 @@ import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR'; export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR';
export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL';
/* eslint-enable padding-line-between-statements */
export interface ShortUrlDetail { export interface ShortUrlDetail {
shortUrl?: ShortUrl; shortUrl?: ShortUrl;
@ -46,7 +44,7 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
try { try {
const { shortUrlsList } = getState(); const { shortUrlsList } = getState();
const shortUrl = shortUrlsList?.shortUrls?.data.find( const shortUrl = shortUrlsList?.shortUrls?.data.find(
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), (url) => shortUrlMatches(url, shortCode, domain),
) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain); ) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL }); dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });

View file

@ -9,11 +9,9 @@ import { parseApiError } from '../../api/utils';
import { supportsTagsInPatch } from '../../utils/helpers/features'; import { supportsTagsInPatch } from '../../utils/helpers/features';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START'; export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR'; export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR';
export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED'; export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
/* eslint-enable padding-line-between-statements */
export interface ShortUrlEdition { export interface ShortUrlEdition {
shortUrl?: ShortUrl; shortUrl?: ShortUrl;
@ -49,7 +47,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
const { updateShortUrl, updateShortUrlTags } = buildShlinkApiClient(getState); const { updateShortUrl, updateShortUrlTags } = buildShlinkApiClient(getState);
try { try {
const [ shortUrl ] = await Promise.all([ const [shortUrl] = await Promise.all([
updateShortUrl(shortCode, domain, data as any), // FIXME Parse dates updateShortUrl(shortCode, domain, data as any), // FIXME Parse dates
sendTagsSeparately && data.tags ? updateShortUrlTags(shortCode, domain, data.tags) : undefined, sendTagsSeparately && data.tags ? updateShortUrlTags(shortCode, domain, data.tags) : undefined,
]); ]);

View file

@ -10,11 +10,9 @@ import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition'; import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
/* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START'; export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR'; export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS'; export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
/* eslint-enable padding-line-between-statements */
export const ITEMS_IN_OVERVIEW_PAGE = 5; export const ITEMS_IN_OVERVIEW_PAGE = 5;
@ -46,19 +44,19 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }), [LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }), [LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
[SHORT_URL_DELETED]: pipe( [SHORT_URL_DELETED]: pipe(
(state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => !state.shortUrls ? state : assocPath( (state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => (!state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ], ['shortUrls', 'data'],
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data), reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
state, state,
), )),
(state) => !state.shortUrls ? state : assocPath( (state) => (!state.shortUrls ? state : assocPath(
[ 'shortUrls', 'pagination', 'totalItems' ], ['shortUrls', 'pagination', 'totalItems'],
state.shortUrls.pagination.totalItems - 1, state.shortUrls.pagination.totalItems - 1,
state, state,
), )),
), ),
[CREATE_VISITS]: (state, { createdVisits }) => assocPath( [CREATE_VISITS]: (state, { createdVisits }) => assocPath(
[ 'shortUrls', 'data' ], ['shortUrls', 'data'],
state.shortUrls?.data?.map( state.shortUrls?.data?.map(
(currentShortUrl) => { (currentShortUrl) => {
// Find the last of the new visit for this short URL, and pick the amount of visits from it // Find the last of the new visit for this short URL, and pick the amount of visits from it
@ -79,36 +77,36 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
// The only place where the list and the creation form coexist is the overview page. // The only place where the list and the creation form coexist is the overview page.
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL. // There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL.
// We can also remove the items above the amount that is displayed there. // We can also remove the items above the amount that is displayed there.
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath( (state: ShortUrlsList, { result }: CreateShortUrlAction) => (!state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ], ['shortUrls', 'data'],
[ result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1) ], [result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)],
state, state,
), )),
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath( (state: ShortUrlsList) => (!state.shortUrls ? state : assocPath(
[ 'shortUrls', 'pagination', 'totalItems' ], ['shortUrls', 'pagination', 'totalItems'],
state.shortUrls.pagination.totalItems + 1, state.shortUrls.pagination.totalItems + 1,
state, state,
), )),
), ),
[SHORT_URL_EDITED]: (state, { shortUrl: editedShortUrl }) => !state.shortUrls ? state : assocPath( [SHORT_URL_EDITED]: (state, { shortUrl: editedShortUrl }) => (!state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ], ['shortUrls', 'data'],
state.shortUrls.data.map((shortUrl) => { state.shortUrls.data.map((shortUrl) => {
const { shortCode, domain } = editedShortUrl; const { shortCode, domain } = editedShortUrl;
return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl; return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl;
}), }),
state, state,
), )),
}, initialState); }, initialState);
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
params: ShlinkShortUrlsListParams = {}, params: ShlinkShortUrlsListParams = {},
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: LIST_SHORT_URLS_START }); dispatch({ type: LIST_SHORT_URLS_START });
const { listShortUrls } = buildShlinkApiClient(getState); const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState);
try { try {
const shortUrls = await listShortUrls(params); const shortUrls = await shlinkListShortUrls(params);
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls }); dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls });
} catch (e) { } catch (e) {

View file

@ -22,8 +22,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar'); bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar');
bottle.decorator('ShortUrlsList', connect( bottle.decorator('ShortUrlsList', connect(
[ 'selectedServer', 'mercureInfo', 'shortUrlsList', 'settings' ], ['selectedServer', 'mercureInfo', 'shortUrlsList', 'settings'],
[ 'listShortUrls', 'createNewVisits', 'loadMercureInfo' ], ['listShortUrls', 'createNewVisits', 'loadMercureInfo'],
)); ));
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow'); bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
@ -35,25 +35,25 @@ 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([ 'shortUrlCreationResult', 'selectedServer', 'settings' ], [ 'createShortUrl', 'resetCreateShortUrl' ]), connect(['shortUrlCreationResult', 'selectedServer', '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', 'selectedServer', 'settings'],
[ 'getShortUrlDetail', 'editShortUrl' ], ['getShortUrlDetail', 'editShortUrl'],
)); ));
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ])); bottle.decorator('DeleteShortUrlModal', connect(['shortUrlDeletion'], ['deleteShortUrl', 'resetDeleteShortUrl']));
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion'); bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); bottle.decorator('QrCodeModal', connect(['selectedServer']));
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn'); bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn');
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter'); bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
bottle.decorator('ExportShortUrlsBtn', connect([ 'selectedServer' ])); bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer']));
// Actions // Actions
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');

View file

@ -25,9 +25,9 @@ const TagCard = (
EditTagModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>,
colorGenerator: ColorGenerator, colorGenerator: ColorGenerator,
) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => { ) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [isDeleteModalOpen, toggleDelete] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle(); const [isEditModalOpen, toggleEdit] = useToggle();
const [ hasTitle,, displayTitle ] = useToggle(); const [hasTitle,, displayTitle] = useToggle();
const titleRef = useRef<HTMLElement>(); const titleRef = useRef<HTMLElement>();
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
@ -35,7 +35,7 @@ const TagCard = (
if (isTruncated(titleRef.current)) { if (isTruncated(titleRef.current)) {
displayTitle(); displayTitle();
} }
}, [ titleRef.current ]); }, [titleRef.current]);
return ( return (
<Card className="tag-card"> <Card className="tag-card">

View file

@ -8,7 +8,7 @@ const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4; const TAGS_GROUPS_AMOUNT = 4;
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ sortedTags, selectedServer }) => { export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ sortedTags, selectedServer }) => {
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>(); const [displayedTag, setDisplayedTag] = useState<string | undefined>();
const tagsCount = sortedTags.length; const tagsCount = sortedTags.length;
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), sortedTags); const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), sortedTags);

View file

@ -33,8 +33,8 @@ export interface TagsListProps {
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub(( const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps, { filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => { ) => {
const [ mode, setMode ] = useState<TagsMode>(settings.tags?.defaultMode ?? 'cards'); const [mode, setMode] = useState<TagsMode>(settings.tags?.defaultMode ?? 'cards');
const [ order, setOrder ] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {}); const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
const resolveSortedTags = pipe( const resolveSortedTags = pipe(
() => tagsList.filteredTags.map((tag): NormalizedTag => ({ () => tagsList.filteredTags.map((tag): NormalizedTag => ({
tag, tag,
@ -103,6 +103,6 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableP
{renderContent()} {renderContent()}
</> </>
); );
}, () => [ Topics.visits ]); }, () => [Topics.visits]);
export default TagsList; export default TagsList;

View file

@ -23,7 +23,7 @@ export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
const { search } = useLocation(); const { search } = useLocation();
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(search); const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(search);
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery)); const [page, setPage] = useQueryState<number>('page', Number(pageFromQuery));
const pages = splitEvery(TAGS_PER_PAGE, sortedTags); const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
const showPaginator = pages.length > 1; const showPaginator = pages.length > 1;
const currentPage = pages[page - 1] ?? []; const currentPage = pages[page - 1] ?? [];
@ -31,10 +31,10 @@ export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
useEffect(() => { useEffect(() => {
!isFirstLoad.current && setPage(1); !isFirstLoad.current && setPage(1);
isFirstLoad.current = false; isFirstLoad.current = false;
}, [ sortedTags ]); }, [sortedTags]);
useEffect(() => { useEffect(() => {
scrollTo(0, 0); scrollTo(0, 0);
}, [ page ]); }, [page]);
return ( return (
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}> <SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
@ -50,9 +50,9 @@ export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
<th className="tags-table__header-cell text-lg-end" onClick={orderByColumn('visits')}> <th className="tags-table__header-cell text-lg-end" onClick={orderByColumn('visits')}>
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" /> Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
</th> </th>
<th className="tags-table__header-cell" /> <th aria-label="Options" className="tags-table__header-cell" />
</tr> </tr>
<tr><th colSpan={4} className="p-0 border-top-0" /></tr> <tr><th aria-label="Separator" colSpan={4} className="p-0 border-top-0" /></tr>
</thead> </thead>
<tbody> <tbody>
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>} {currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}

View file

@ -21,9 +21,9 @@ export const TagsTableRow = (
EditTagModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>,
colorGenerator: ColorGenerator, colorGenerator: ColorGenerator,
) => ({ tag, selectedServer }: TagsTableRowProps) => { ) => ({ tag, selectedServer }: TagsTableRowProps) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [isDeleteModalOpen, toggleDelete] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle(); const [isEditModalOpen, toggleEdit] = useToggle();
const [ isDropdownOpen, toggleDropdown ] = useToggle(); const [isDropdownOpen, toggleDropdown] = useToggle();
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
return ( return (

View file

@ -1,4 +1,4 @@
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { TagDeletion } from '../reducers/tagDelete'; import { TagDeletion } from '../reducers/tagDelete';
import { TagModalProps } from '../data'; import { TagModalProps } from '../data';
import { Result } from '../../utils/Result'; import { Result } from '../../utils/Result';
@ -34,10 +34,10 @@ const DeleteTagConfirmModal = (
)} )}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button> <Button color="link" onClick={toggle}>Cancel</Button>
<button className="btn btn-danger" disabled={deleting} onClick={doDelete}> <Button color="danger" disabled={deleting} onClick={doDelete}>
{deleting ? 'Deleting tag...' : 'Delete tag'} {deleting ? 'Deleting tag...' : 'Delete tag'}
</button> </Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
); );

View file

@ -21,9 +21,9 @@ interface EditTagModalProps extends TagModalProps {
const EditTagModal = ({ getColorForKey }: ColorGenerator) => ( const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
{ tag, editTag, toggle, tagEdited, isOpen, tagEdit }: EditTagModalProps, { tag, editTag, toggle, tagEdited, isOpen, tagEdit }: EditTagModalProps,
) => { ) => {
const [ newTagName, setNewTagName ] = useState(tag); const [newTagName, setNewTagName] = useState(tag);
const [ color, setColor ] = useState(getColorForKey(tag)); const [color, setColor] = useState(getColorForKey(tag));
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle(); const [showColorPicker, toggleColorPicker, , hideColorPicker] = useToggle();
const { editing, error, errorData } = tagEdit; const { editing, error, errorData } = tagEdit;
const saveTag = handleEventPreventingDefault( const saveTag = handleEventPreventingDefault(
async () => editTag(tag, newTagName, color) async () => editTag(tag, newTagName, color)

View file

@ -13,7 +13,7 @@ export interface TagsSelectorProps {
} }
interface TagsSelectorConnectProps extends TagsSelectorProps { interface TagsSelectorConnectProps extends TagsSelectorProps {
listTags: Function; listTags: () => void;
tagsList: TagsList; tagsList: TagsList;
settings: Settings; settings: Settings;
} }
@ -47,14 +47,14 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
addOnBlur addOnBlur
placeholderText={placeholder ?? 'Add tags to the URL'} placeholderText={placeholder ?? 'Add tags to the URL'}
minQueryLength={1} minQueryLength={1}
delimiters={[ 'Enter', 'Tab', ',' ]} delimiters={['Enter', 'Tab', ',']}
suggestionsTransform={ suggestionsTransform={
searchMode === 'includes' searchMode === 'includes'
? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query)) ? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
: undefined : undefined
} }
onDelete={(removedTagIndex) => { onDelete={(removedTagIndex) => {
const tagsCopy = [ ...selectedTags ]; const tagsCopy = [...selectedTags];
tagsCopy.splice(removedTagIndex, 1); tagsCopy.splice(removedTagIndex, 1);
onChange(tagsCopy); onChange(tagsCopy);
@ -62,7 +62,7 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
onAddition={({ name: newTag }) => onChange( onAddition={({ name: newTag }) => onChange(
// * Avoid duplicated tags (thanks to the Set), // * Avoid duplicated tags (thanks to the Set),
// * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once. // * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once.
[ ...new Set([ ...selectedTags, ...newTag.toLowerCase().split(',') ]) ], [...new Set([...selectedTags, ...newTag.toLowerCase().split(',')])],
)} )}
/> />
); );

View file

@ -6,12 +6,10 @@ import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START'; export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR'; export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG'; export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED'; export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
/* eslint-enable padding-line-between-statements */
export interface TagDeletion { export interface TagDeletion {
deleting: boolean; deleting: boolean;
@ -42,7 +40,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
const { deleteTags } = buildShlinkApiClient(getState); const { deleteTags } = buildShlinkApiClient(getState);
try { try {
await deleteTags([ tag ]); await deleteTags([tag]);
dispatch({ type: DELETE_TAG }); dispatch({ type: DELETE_TAG });
} catch (e: any) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });

View file

@ -8,11 +8,9 @@ import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
/* eslint-disable padding-line-between-statements */
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START'; export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR'; export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
export const EDIT_TAG = 'shlink/editTag/EDIT_TAG'; export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
/* eslint-enable padding-line-between-statements */
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED'; export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
@ -41,7 +39,7 @@ export default buildReducer<TagEdition, EditTagAction & ApiErrorAction>({
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }), [EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }), [EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
[EDIT_TAG]: (_, action) => ({ [EDIT_TAG]: (_, action) => ({
...pick([ 'oldName', 'newName' ], action), ...pick(['oldName', 'newName'], action),
editing: false, editing: false,
error: false, error: false,
}), }),
@ -53,10 +51,10 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
color: string, color: string,
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_TAG_START }); dispatch({ type: EDIT_TAG_START });
const { editTag } = buildShlinkApiClient(getState); const { editTag: shlinkEditTag } = buildShlinkApiClient(getState);
try { try {
await editTag(oldName, newName); await shlinkEditTag(oldName, newName);
colorGenerator.setColorForKey(newName, color); colorGenerator.setColorForKey(newName, color);
dispatch({ type: EDIT_TAG, oldName, newName }); dispatch({ type: EDIT_TAG, oldName, newName });
} catch (e: any) { } catch (e: any) {

View file

@ -13,12 +13,10 @@ import { CREATE_SHORT_URL, CreateShortUrlAction } from '../../short-urls/reducer
import { DeleteTagAction, TAG_DELETED } from './tagDelete'; import { DeleteTagAction, TAG_DELETED } from './tagDelete';
import { EditTagAction, TAG_EDITED } from './tagEdit'; import { EditTagAction, TAG_EDITED } from './tagEdit';
/* eslint-disable padding-line-between-statements */
export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START'; export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
export const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR'; export const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR';
export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS'; export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS'; export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements */
type TagsStatsMap = Record<string, TagStats>; type TagsStatsMap = Record<string, TagStats>;
@ -58,19 +56,19 @@ const initialState = {
type TagIncrease = [string, number]; type TagIncrease = [string, number];
const renameTag = (oldName: string, newName: string) => (tag: string) => tag === oldName ? newName : tag; const renameTag = (oldName: string, newName: string) => (tag: string) => (tag === oldName ? newName : tag);
const rejectTag = (tags: string[], tagToReject: string) => reject((tag) => tag === tagToReject, tags); const rejectTag = (tags: string[], tagToReject: string) => reject((tag) => tag === tagToReject, tags);
const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags.reduce((stats, [ tag, increase ]) => { const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags.reduce((theStats, [tag, increase]) => {
if (!stats[tag]) { if (!theStats[tag]) {
return stats; return theStats;
} }
const tagStats = stats[tag]; const tagStats = theStats[tag];
tagStats.visitsCount = tagStats.visitsCount + increase; tagStats.visitsCount += increase;
stats[tag] = tagStats; theStats[tag] = tagStats; // eslint-disable-line no-param-reassign
return stats; return theStats;
}, { ...stats }); }, { ...stats });
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries( const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
createdVisits.reduce<Stats>((acc, { shortUrl }) => { createdVisits.reduce<Stats>((acc, { shortUrl }) => {
@ -123,8 +121,8 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
dispatch({ type: LIST_TAGS_START }); dispatch({ type: LIST_TAGS_START });
try { try {
const { listTags } = buildShlinkApiClient(getState); const { listTags: shlinkListTags } = buildShlinkApiClient(getState);
const { tags, stats = [] }: ShlinkTags = await listTags(); const { tags, stats = [] }: ShlinkTags = await shlinkListTags();
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, shortUrlsCount, visitsCount }) => { const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, shortUrlsCount, visitsCount }) => {
acc[tag] = { shortUrlsCount, visitsCount }; acc[tag] = { shortUrlsCount, visitsCount };

View file

@ -15,15 +15,15 @@ import { TagsTableRow } from '../TagsTableRow';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator'); bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect([ 'tagsList', 'settings' ], [ 'listTags' ])); bottle.decorator('TagsSelector', connect(['tagsList', 'settings'], ['listTags']));
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator'); bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal); bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ])); bottle.decorator('DeleteTagConfirmModal', connect(['tagDelete'], ['deleteTag', 'tagDeleted']));
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator'); bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ])); bottle.decorator('EditTagModal', connect(['tagEdit'], ['editTag', 'tagEdited']));
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard'); bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator'); bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
@ -32,8 +32,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable'); bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
bottle.decorator('TagsList', connect( bottle.decorator('TagsList', connect(
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ], ['tagsList', 'selectedServer', 'mercureInfo', 'settings'],
[ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ], ['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'],
)); ));
// Actions // Actions

View file

@ -15,7 +15,7 @@ export interface DropdownBtnProps {
export const DropdownBtn: FC<DropdownBtnProps> = ( export const DropdownBtn: FC<DropdownBtnProps> = (
{ text, disabled = false, className = '', children, dropdownClassName, right = false, minWidth }, { text, disabled = false, className = '', children, dropdownClassName, right = false, minWidth },
) => { ) => {
const [ isOpen, toggle ] = useToggle(); const [isOpen, toggle] = useToggle();
const toggleClasses = `dropdown-btn__toggle btn-block ${className}`; const toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
const style = { minWidth: minWidth && `${minWidth}px` }; const style = { minWidth: minWidth && `${minWidth}px` };

View file

@ -38,7 +38,7 @@ export function OrderingDropdown<T extends string = string>(
end={right} end={right}
className={classNames('w-100', { 'ordering-dropdown__menu--link': !isButton })} className={classNames('w-100', { 'ordering-dropdown__menu--link': !isButton })}
> >
{toPairs(items).map(([ fieldKey, fieldValue ]) => ( {toPairs(items).map(([fieldKey, fieldValue]) => (
<DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}> <DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}>
{fieldValue} {fieldValue}
{order.field === fieldKey && ( {order.field === fieldKey && (

View file

@ -16,7 +16,7 @@ interface SearchFieldProps {
} }
const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => { const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
const [ searchTerm, setSearchTerm ] = useState(initialValue); const [searchTerm, setSearchTerm] = useState(initialValue);
const resetTimer = () => { const resetTimer = () => {
timer && clearTimeout(timer); timer && clearTimeout(timer);

View file

@ -25,8 +25,8 @@ export const DateRangeSelector = (
{ onDatesChange, initialDateRange, defaultText, disabled, updatable = false }: DateRangeSelectorProps, { onDatesChange, initialDateRange, defaultText, disabled, updatable = false }: DateRangeSelectorProps,
) => { ) => {
const initialIntervalIsRange = rangeIsInterval(initialDateRange); const initialIntervalIsRange = rangeIsInterval(initialDateRange);
const [ activeInterval, setActiveInterval ] = useState(initialIntervalIsRange ? initialDateRange : undefined); const [activeInterval, setActiveInterval] = useState(initialIntervalIsRange ? initialDateRange : undefined);
const [ activeDateRange, setActiveDateRange ] = useState(initialIntervalIsRange ? undefined : initialDateRange); const [activeDateRange, setActiveDateRange] = useState(initialIntervalIsRange ? undefined : initialDateRange);
const updateDateRange = (dateRange: DateRange) => { const updateDateRange = (dateRange: DateRange) => {
setActiveInterval(dateRangeIsEmpty(dateRange) ? 'all' : undefined); setActiveInterval(dateRangeIsEmpty(dateRange) ? 'all' : undefined);
@ -44,7 +44,7 @@ export const DateRangeSelector = (
isDateInterval && updateInterval(initialDateRange); isDateInterval && updateInterval(initialDateRange);
initialDateRange && !isDateInterval && updateDateRange(initialDateRange); initialDateRange && !isDateInterval && updateDateRange(initialDateRange);
}, [ initialDateRange ]); }, [initialDateRange]);
return ( return (
<DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}> <DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}>

View file

@ -88,13 +88,13 @@ export const dateToMatchingInterval = (date: DateOrString): DateInterval => {
const theDate: Date = parseISO(date); const theDate: Date = parseISO(date);
return cond<never, DateInterval>([ return cond<never, DateInterval>([
[ () => isBeforeOrEqual(startOfDay(new Date()), theDate), () => 'today' ], [() => isBeforeOrEqual(startOfDay(new Date()), theDate), () => 'today'],
[ () => isBeforeOrEqual(startOfDaysAgo(1), theDate), () => 'yesterday' ], [() => isBeforeOrEqual(startOfDaysAgo(1), theDate), () => 'yesterday'],
[ () => isBeforeOrEqual(startOfDaysAgo(7), theDate), () => 'last7Days' ], [() => isBeforeOrEqual(startOfDaysAgo(7), theDate), () => 'last7Days'],
[ () => isBeforeOrEqual(startOfDaysAgo(30), theDate), () => 'last30Days' ], [() => isBeforeOrEqual(startOfDaysAgo(30), theDate), () => 'last30Days'],
[ () => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days' ], [() => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days'],
[ () => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days' ], [() => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days'],
[ () => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days' ], [() => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days'],
[ T, () => 'all' ], [T, () => 'all'],
])(); ])();
}; };

View file

@ -5,7 +5,6 @@ import { LabeledFormGroup } from './LabeledFormGroup';
export interface InputFormGroupProps { export interface InputFormGroupProps {
value: string; value: string;
onChange: (newValue: string) => void; onChange: (newValue: string) => void;
id?: string;
type?: InputType; type?: InputType;
required?: boolean; required?: boolean;
placeholder?: string; placeholder?: string;

View file

@ -7,6 +7,7 @@ interface LabeledFormGroupProps {
labelClassName?: string; labelClassName?: string;
} }
/* eslint-disable jsx-a11y/label-has-associated-control */
export const LabeledFormGroup: FC<LabeledFormGroupProps> = ( export const LabeledFormGroup: FC<LabeledFormGroupProps> = (
{ children, label, className = '', labelClassName = '', noMargin = false }, { children, label, className = '', labelClassName = '', noMargin = false },
) => ( ) => (

View file

@ -1,7 +1,7 @@
import { ActiveElement, ChartEvent, ChartType, TooltipItem } from 'chart.js'; import { ActiveElement, ChartEvent, ChartType, TooltipItem } from 'chart.js';
import { prettify } from './numbers'; import { prettify } from './numbers';
export const pointerOnHover = ({ native }: ChartEvent, [ firstElement ]: ActiveElement[]) => { export const pointerOnHover = ({ native }: ChartEvent, [firstElement]: ActiveElement[]) => {
if (!native?.target) { if (!native?.target) {
return; return;
} }

View file

@ -15,15 +15,15 @@ const formatDateFromFormat = (date?: NullableDate, theFormat?: string): Optional
return theFormat ? format(date, theFormat) : formatISO(date); return theFormat ? format(date, theFormat) : formatISO(date);
}; };
export const formatDate = (format = 'yyyy-MM-dd') => (date?: NullableDate) => formatDateFromFormat(date, format); export const formatDate = (theFormat = 'yyyy-MM-dd') => (date?: NullableDate) => formatDateFromFormat(date, theFormat);
export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined); export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined);
export const formatInternational = formatDate(); export const formatInternational = formatDate();
export const parseDate = (date: string, format: string) => parse(date, format, new Date()); export const parseDate = (date: string, theFormat: string) => parse(date, theFormat, new Date());
export const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date); export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date));
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => { export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
try { try {

View file

@ -5,27 +5,15 @@ const serverMatchesVersions = (versions: Versions) => (selectedServer: SelectedS
isReachableServer(selectedServer) && versionMatch(selectedServer.version, versions); isReachableServer(selectedServer) && versionMatch(selectedServer.version, versions);
export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' }); export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' });
export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' }); export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' });
export const supportsOrphanVisits = supportsShortUrlTitle; export const supportsOrphanVisits = supportsShortUrlTitle;
export const supportsQrCodeMargin = supportsShortUrlTitle; export const supportsQrCodeMargin = supportsShortUrlTitle;
export const supportsTagsInPatch = supportsShortUrlTitle; export const supportsTagsInPatch = supportsShortUrlTitle;
export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' }); export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' });
export const supportsCrawlableVisits = supportsBotVisits; export const supportsCrawlableVisits = supportsBotVisits;
export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' }); export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' });
export const supportsDomainRedirects = supportsQrErrorCorrection; export const supportsDomainRedirects = supportsQrErrorCorrection;
export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' }); export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' });
export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' }); export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' });
export const supportsNonOrphanVisits = serverMatchesVersions({ minVersion: '3.0.0' }); export const supportsNonOrphanVisits = serverMatchesVersions({ minVersion: '3.0.0' });
export const supportsAllTagsFiltering = supportsNonOrphanVisits; export const supportsAllTagsFiltering = supportsNonOrphanVisits;

View file

@ -10,7 +10,7 @@ export const saveUrl = ({ document }: Window, url: string, filename: string) =>
}; };
export const saveCsv = (window: Window, csv: string, filename: string) => { export const saveCsv = (window: Window, csv: string, filename: string) => {
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
saveUrl(window, url, filename); saveUrl(window, url, filename);

Some files were not shown because too many files have changed in this diff Show more