Merge pull request #699 from shlinkio/develop

Release 3.7.2
This commit is contained in:
Alejandro Celaya 2022-08-07 18:31:24 +02:00 committed by GitHub
commit a46116d936
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
234 changed files with 5240 additions and 4289 deletions

View file

@ -14,3 +14,4 @@ jobs:
node-version: 16.15
with-mutation-tests: true
publish-coverage: true
force-install: true

View file

@ -19,7 +19,7 @@ jobs:
node-version: 16.15
- name: Build
run: |
npm ci && \
npm ci --force && \
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
rm src/service-worker.ts && \
npm run build

View file

@ -16,7 +16,7 @@ jobs:
with:
node-version: 16.15
- name: Generate release assets
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
run: npm ci --force && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
- name: Publish release with assets
uses: docker://antonyurchenko/git-release:latest
env:

View file

@ -4,12 +4,12 @@ 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).
## [3.7.1] - 2022-05-25
## [3.7.2] - 2022-08-07
### Added
* *Nothing*
* [#671](https://github.com/shlinkio/shlink-web-client/issues/671) Added proper color-scheme in root element based on selected theme.
### Changed
* [#648](https://github.com/shlinkio/shlink-web-client/pull/648) Migrated some scripts to ESM and updated to chalk 5.
* [#688](https://github.com/shlinkio/shlink-web-client/issues/688) Finalized migration from enzyme to react-testing-library.
### Deprecated
* *Nothing*
@ -18,30 +18,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* *Nothing*
### Fixed
* [#653](https://github.com/shlinkio/shlink-web-client/pull/653) Fixed rendering values greater than 1000 in charts, when the browser has certain locales configured.
* [#695](https://github.com/shlinkio/shlink-web-client/issues/695) Fixed some warnings in tests.
* [#693](https://github.com/shlinkio/shlink-web-client/issues/693) Fixed tags, servers and domains search to make it case-insensitive.
* [#694](https://github.com/shlinkio/shlink-web-client/issues/694) Fixed editing and loading visits on short URLs with multi-segment slugs.
## [3.7.0] - 2022-05-14
## [3.7.1] - 2022-05-25
### Added
* [#622](https://github.com/shlinkio/shlink-web-client/pull/622) Added support to load domain visits when consuming Shlink 3.1.0 or newer.
* [#582](https://github.com/shlinkio/shlink-web-client/pull/582) Improved filtering short URLs by tag.
Now, a new full tags selector component is available, which allows selecting any of the existing tags and also composes a toggle to filter by "any" tag or "all" tags.
* *Nothing*
### Changed
* [#616](https://github.com/shlinkio/shlink-web-client/pull/616) Updated to React 18.
* [#595](https://github.com/shlinkio/shlink-web-client/pull/595) Updated to react-chartjs-2 v4.1.0.
* [#594](https://github.com/shlinkio/shlink-web-client/pull/594) Updated to a new coding standard.
* [#627](https://github.com/shlinkio/shlink-web-client/pull/627) Updated to Jest 28.
* [#603](https://github.com/shlinkio/shlink-web-client/pull/603) Migrated to new and maintained dependencies to parse CSV<->JSON.
* [#610](https://github.com/shlinkio/shlink-web-client/pull/610) Migrated to a maintained coding style for CSS.
* [#619](https://github.com/shlinkio/shlink-web-client/pull/619) Introduced react testing library, to progressively replace enzyme.
* [#648](https://github.com/shlinkio/shlink-web-client/issues/648) Migrated some scripts to ESM and updated to chalk 5.
### Deprecated
* *Nothing*
### Removed
* [#623](https://github.com/shlinkio/shlink-web-client/pull/623) Dropped support for Shlink older than 2.6.0.
* *Nothing*
### Fixed
* [#653](https://github.com/shlinkio/shlink-web-client/issues/653) Fixed rendering values greater than 1000 in charts, when the browser has certain locales configured.
## [3.7.0] - 2022-05-14
### Added
* [#622](https://github.com/shlinkio/shlink-web-client/issues/622) Added support to load domain visits when consuming Shlink 3.1.0 or newer.
* [#582](https://github.com/shlinkio/shlink-web-client/issues/582) Improved filtering short URLs by tag.
Now, a new full tags selector component is available, which allows selecting any of the existing tags and also composes a toggle to filter by "any" tag or "all" tags.
### Changed
* [#616](https://github.com/shlinkio/shlink-web-client/issues/616) Updated to React 18.
* [#595](https://github.com/shlinkio/shlink-web-client/issues/595) Updated to react-chartjs-2 v4.1.0.
* [#594](https://github.com/shlinkio/shlink-web-client/issues/594) Updated to a new coding standard.
* [#627](https://github.com/shlinkio/shlink-web-client/issues/627) Updated to Jest 28.
* [#603](https://github.com/shlinkio/shlink-web-client/issues/603) Migrated to new and maintained dependencies to parse CSV<->JSON.
* [#610](https://github.com/shlinkio/shlink-web-client/issues/610) Migrated to a maintained coding style for CSS.
* [#619](https://github.com/shlinkio/shlink-web-client/issues/619) Introduced react testing library, to progressively replace enzyme.
### Deprecated
* *Nothing*
### Removed
* [#623](https://github.com/shlinkio/shlink-web-client/issues/623) Dropped support for Shlink older than 2.6.0.
### Fixed
* *Nothing*
@ -49,19 +68,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [3.6.0] - 2022-03-17
### 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.
* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together when consuming Shlink 3.0.0.
* [#556](https://github.com/shlinkio/shlink-web-client/pull/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0.
* [#549](https://github.com/shlinkio/shlink-web-client/pull/549) Allowed to export the list of short URLs as CSV.
* [#558](https://github.com/shlinkio/shlink-web-client/issues/558) Added dark text for tags where the generated background is too light, improving its legibility.
* [#570](https://github.com/shlinkio/shlink-web-client/issues/570) Added new section to load non-orphan visits all together when consuming Shlink 3.0.0.
* [#556](https://github.com/shlinkio/shlink-web-client/issues/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0.
* [#549](https://github.com/shlinkio/shlink-web-client/issues/549) Allowed to export the list of short URLs as CSV.
### Changed
* [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section.
* [#567](https://github.com/shlinkio/shlink-web-client/pull/567) Improved Shlink 3.0.0 compatibility by checking the `INVALID_SHORT_URL_DELETION` error code when deleting short URLs.
* [#448](https://github.com/shlinkio/shlink-web-client/pull/448) Updated to bootstrap v5.
* [#524](https://github.com/shlinkio/shlink-web-client/pull/524) Updated to react-router v6.
* [#576](https://github.com/shlinkio/shlink-web-client/pull/576) Updated to fontawesome v6.
* [#579](https://github.com/shlinkio/shlink-web-client/pull/579) Replaced react-color with react-colorful.
* [#564](https://github.com/shlinkio/shlink-web-client/pull/564) Updated most of the dependencies.
* [#543](https://github.com/shlinkio/shlink-web-client/issues/543) Redesigned settings section.
* [#567](https://github.com/shlinkio/shlink-web-client/issues/567) Improved Shlink 3.0.0 compatibility by checking the `INVALID_SHORT_URL_DELETION` error code when deleting short URLs.
* [#448](https://github.com/shlinkio/shlink-web-client/issues/448) Updated to bootstrap v5.
* [#524](https://github.com/shlinkio/shlink-web-client/issues/524) Updated to react-router v6.
* [#576](https://github.com/shlinkio/shlink-web-client/issues/576) Updated to fontawesome v6.
* [#579](https://github.com/shlinkio/shlink-web-client/issues/579) Replaced react-color with react-colorful.
* [#564](https://github.com/shlinkio/shlink-web-client/issues/564) Updated most of the dependencies.
### Deprecated
* *Nothing*
@ -70,7 +89,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* *Nothing*
### Fixed
* [#589](https://github.com/shlinkio/shlink-web-client/pull/589) Fixed alignment of shlink versions footer, by basing the logic on the presence of the sidebar instead of selected server.
* [#589](https://github.com/shlinkio/shlink-web-client/issues/589) Fixed alignment of shlink versions footer, by basing the logic on the presence of the sidebar instead of selected server.
## [3.5.1] - 2022-01-08
@ -94,27 +113,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [3.5.0] - 2022-01-01
### Added
* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter".
* [#407](https://github.com/shlinkio/shlink-web-client/issues/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter".
Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit.
If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval.
* [#547](https://github.com/shlinkio/shlink-web-client/pull/547) Improved domains page, to tell which of the domains are not properly configured.
* [#547](https://github.com/shlinkio/shlink-web-client/issues/547) Improved domains page, to tell which of the domains are not properly configured.
Now, when this section is loaded, it tries to call the `GET /rest/health` endpoint for each one of the domains, and displays a warning icon on each one that failed.
The warning includes a link to the documentation, explaining what are the steps to get it fixed.
* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist.
* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.
* [#542](https://github.com/shlinkio/shlink-web-client/pull/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params.
* [#506](https://github.com/shlinkio/shlink-web-client/issues/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist.
* [#535](https://github.com/shlinkio/shlink-web-client/issues/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
* [#531](https://github.com/shlinkio/shlink-web-client/issues/531) Added custom slug field to the basic creation form in the Overview page.
* [#537](https://github.com/shlinkio/shlink-web-client/issues/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.
* [#542](https://github.com/shlinkio/shlink-web-client/issues/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params.
### Changed
* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios.
* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
* [#534](https://github.com/shlinkio/shlink-web-client/issues/534) Updated axios.
* [#538](https://github.com/shlinkio/shlink-web-client/issues/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
### Deprecated
* *Nothing*

View file

@ -2,7 +2,7 @@ FROM node:16.15-alpine as node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && npm ci && NODE_ENV=production npm run build
RUN cd /shlink-web-client && npm ci --force && NODE_ENV=production npm run build
FROM nginx:1.21-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

View file

@ -1,4 +0,0 @@
import * as util from 'util';
global.TextEncoder = util.TextEncoder;
global.TextDecoder = util.TextDecoder;

View file

@ -1,4 +0,0 @@
import Enzyme from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
Enzyme.configure({ adapter: new Adapter() });

View file

@ -4,4 +4,5 @@ import ResizeObserver from 'resize-observer-polyfill';
(global as any).ResizeObserver = ResizeObserver;
(global as any).scrollTo = () => {};
(global as any).prompt = () => {};
(global as any).matchMedia = (media: string) => ({ matches: false, media });

View file

@ -15,7 +15,6 @@ module.exports = {
lines: 90,
},
},
setupFiles: ['<rootDir>/config/jest/setupBeforeEnzyme.js', '<rootDir>/config/jest/setupEnzyme.js'],
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
testEnvironment: 'jsdom',

997
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,10 +16,11 @@
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.mjs",
"build:dist": "npm run build && node scripts/create-dist-file.mjs",
"build:serve": "serve -p 5000 ./build",
"test": "jest --env=jsdom --colors --verbose",
"test": "jest --env=jsdom --colors",
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
"test:ci": "npm run test:coverage -- --coverageReporters=clover --ci",
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
"test:verbose": "npm run test -- --verbose",
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
},
"dependencies": {
@ -45,14 +46,14 @@
"react": "^18.1.0",
"react-chartjs-2": "^4.1.0",
"react-colorful": "^5.5.1",
"react-copy-to-clipboard": "^5.0.4",
"react-datepicker": "^4.7.0",
"react-copy-to-clipboard": "^5.1.0",
"react-datepicker": "^4.8.0",
"react-dom": "^18.1.0",
"react-external-link": "^2.0.0",
"react-leaflet": "^4.0.0",
"react-redux": "^8.0.0",
"react-router-dom": "^6.3.0",
"react-swipeable": "^6.2.0",
"react-swipeable": "^7.0.0",
"react-tag-autocomplete": "^6.3.0",
"reactstrap": "^9.0.1",
"redux": "^4.2.0",
@ -75,7 +76,6 @@
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/enzyme": "^3.10.11",
"@types/jest": "^27.4.1",
"@types/json2csv": "^5.0.3",
"@types/leaflet": "^1.7.9",
@ -88,11 +88,9 @@
"@types/react-dom": "^18.0.3",
"@types/react-tag-autocomplete": "^6.1.1",
"@types/uuid": "^8.3.4",
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
"adm-zip": "^0.5.9",
"babel-jest": "^28.0.3",
"chalk": "^5.0.1",
"enzyme": "^3.11.0",
"eslint": "^8.12.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^28.0.3",

View file

@ -29,7 +29,7 @@ const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShor
return { ...rest, orderBy: orderToString(orderBy) };
};
export default class ShlinkApiClient {
export class ShlinkApiClient {
public constructor(
private readonly axios: AxiosInstance,
private readonly baseUrl: string,

View file

@ -2,7 +2,7 @@ import { AxiosInstance } from 'axios';
import { prop } from 'ramda';
import { hasServerData, SelectedServer, ServerWithId } from '../../servers/data';
import { GetState } from '../../container/types';
import ShlinkApiClient from './ShlinkApiClient';
import { ShlinkApiClient } from './ShlinkApiClient';
const apiClients: Record<string, ShlinkApiClient> = {};
@ -12,7 +12,7 @@ const getSelectedServerFromState = (getState: GetState): SelectedServer => prop(
export type ShlinkApiClientBuilder = (getStateOrSelectedServer: GetState | ServerWithId) => ShlinkApiClient;
const buildShlinkApiClient = (axios: AxiosInstance): ShlinkApiClientBuilder => (
export const buildShlinkApiClient = (axios: AxiosInstance): ShlinkApiClientBuilder => (
getStateOrSelectedServer: GetState | ServerWithId,
) => {
const server = isGetState(getStateOrSelectedServer)
@ -32,5 +32,3 @@ const buildShlinkApiClient = (axios: AxiosInstance): ShlinkApiClientBuilder => (
return apiClients[clientKey];
};
export default buildShlinkApiClient;

View file

@ -1,5 +1,5 @@
import Bottle from 'bottlejs';
import buildShlinkApiClient from './ShlinkApiClientBuilder';
import { buildShlinkApiClient } from './ShlinkApiClientBuilder';
const provideServices = (bottle: Bottle) => {
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');

View file

@ -34,7 +34,7 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
</NavLink>
);
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
{ selectedServer, showOnMobile = false }: AsideMenuProps,
) => {
const hasId = isServerWithId(selectedServer);
@ -89,5 +89,3 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
</aside>
);
};
export default AsideMenu;

View file

@ -5,7 +5,7 @@ import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import ServersListGroup from '../servers/ServersListGroup';
import { ServersListGroup } from '../servers/ServersListGroup';
import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss';

View file

@ -17,7 +17,7 @@ const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-cli
</ExternalLink>
);
const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => {
export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => {
const normalizedClientVersion = normalizeVersion(clientVersion);
return (
@ -29,5 +29,3 @@ const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERS
</small>
);
};
export default ShlinkVersions;

View file

@ -1,6 +1,6 @@
import classNames from 'classnames';
import { SelectedServer } from '../servers/data';
import ShlinkVersions from './ShlinkVersions';
import { ShlinkVersions } from './ShlinkVersions';
import { Sidebar } from './reducers/sidebar';
import './ShlinkVersionsContainer.scss';
@ -9,7 +9,7 @@ export interface ShlinkVersionsContainerProps {
sidebar: Sidebar;
}
const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsContainerProps) => {
export const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsContainerProps) => {
const classes = classNames('text-center', {
'shlink-versions-container--with-sidebar': sidebar.sidebarPresent,
});
@ -20,5 +20,3 @@ const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsCont
</div>
);
};
export default ShlinkVersionsContainer;

View file

@ -4,9 +4,9 @@ import { ScrollToTop } from '../ScrollToTop';
import { MainHeader } from '../MainHeader';
import { Home } from '../Home';
import { MenuLayout } from '../MenuLayout';
import AsideMenu from '../AsideMenu';
import { AsideMenu } from '../AsideMenu';
import { ErrorHandler } from '../ErrorHandler';
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';

View file

@ -1,5 +1,5 @@
import { FC, useEffect } from 'react';
import Message from '../utils/Message';
import { Message } from '../utils/Message';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { SimpleCard } from '../utils/SimpleCard';

View file

@ -8,6 +8,7 @@ import {
faCircleNotch as loadingStatusIcon,
} from '@fortawesome/free-solid-svg-icons';
import { MediaMatcher } from '../../utils/types';
import { mutableRefToElementRef } from '../../utils/helpers/components';
import { DomainStatus } from '../data';
interface DomainStatusIconProps {
@ -34,11 +35,7 @@ export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia
return (
<>
<span
ref={(el: HTMLSpanElement) => {
ref.current = el;
}}
>
<span ref={mutableRefToElementRef(ref)}>
{status === 'valid'
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}

View file

@ -65,7 +65,7 @@ export default buildReducer<DomainsList, DomainsCombinedAction>({
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
...state,
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm.toLowerCase())),
}),
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
...state,

View file

@ -13,6 +13,7 @@
:root {
scroll-behavior: auto;
color-scheme: var(--color-scheme);
}
html,

View file

@ -4,7 +4,7 @@ import { Button } from 'reactstrap';
import { useNavigate } from 'react-router-dom';
import { Result } from '../utils/Result';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { StateFlagTimeout, useGoBack, useToggle } from '../utils/helpers/hooks';
import { TimeoutToggle, useGoBack, useToggle } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerData, ServersMap, ServerWithId } from './data';
@ -26,14 +26,14 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
</div>
);
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTimeoutToggle: TimeoutToggle) => (
{ servers, createServer }: CreateServerProps,
) => {
const navigate = useNavigate();
const goBack = useGoBack();
const hasServers = !!Object.keys(servers).length;
const [serversImported, setServersImported] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [serverData, setServerData] = useState<ServerData | undefined>();
const save = () => {
@ -77,5 +77,3 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
</NoMenuLayout>
);
};
export default CreateServer;

View file

@ -11,7 +11,7 @@ export type DeleteServerButtonProps = PropsWithChildren<{
textClassName?: string;
}>;
const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
export const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
{ server, className, children, textClassName },
) => {
const [isModalOpen, , showModal, hideModal] = useToggle();
@ -27,5 +27,3 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
</>
);
};
export default DeleteServerButton;

View file

@ -14,7 +14,7 @@ interface DeleteServerModalConnectProps extends DeleteServerModalProps {
deleteServer: (server: ServerWithId) => void;
}
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
{ server, toggle, isOpen, deleteServer, redirectHome = true },
) => {
const navigate = useNavigate();
@ -26,7 +26,7 @@ const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}><span className="text-danger">Remove server</span></ModalHeader>
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
<ModalBody>
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
@ -43,5 +43,3 @@ const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
</Modal>
);
};
export default DeleteServerModal;

View file

@ -7,7 +7,7 @@ import { NoMenuLayout } from '../common/NoMenuLayout';
import { SimpleCard } from '../utils/SimpleCard';
import { SearchField } from '../utils/SearchField';
import { Result } from '../utils/Result';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { TimeoutToggle } from '../utils/helpers/hooks';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServersMap } from './data';
import { ManageServersRowProps } from './ManageServersRow';
@ -22,16 +22,16 @@ const SHOW_IMPORT_MSG_TIME = 4000;
export const ManageServers = (
serversExporter: ServersExporter,
ImportServersBtn: FC<ImportServersBtnProps>,
useStateFlagTimeout: StateFlagTimeout,
useTimeoutToggle: TimeoutToggle,
ManageServersRow: FC<ManageServersRowProps>,
): FC<ManageServersProps> => ({ servers }) => {
const allServers = Object.values(servers);
const [serversList, setServersList] = useState(allServers);
const filterServers = (searchTerm: string) => setServersList(
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
allServers.filter(({ name, url }) => `${name} ${url}`.toLowerCase().match(searchTerm.toLowerCase())),
);
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
useEffect(() => {
setServersList(Object.values(servers));

View file

@ -39,7 +39,7 @@ export const ManageServersRowDropdown = (
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
</DropdownItem>
<DropdownItem onClick={() => setAutoConnect(server, !server.autoConnect)}>
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</DropdownItem>
<DropdownItem divider />

View file

@ -19,7 +19,7 @@ const ServerListItem = ({ id, name }: { id: string; name: string }) => (
</ListGroupItem>
);
const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
<>
{children && <h5 className="mb-md-3">{children}</h5>}
{servers.length > 0 && (
@ -31,5 +31,3 @@ const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedd
)}
</>
);
export default ServersListGroup;

View file

@ -1,16 +1,15 @@
import { useRef, RefObject, ChangeEvent, MutableRefObject, useState, useEffect, FC, PropsWithChildren } from 'react';
import { useRef, ChangeEvent, useState, useEffect, FC, PropsWithChildren } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap';
import { complement, pipe } from 'ramda';
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../../utils/helpers/hooks';
import { mutableRefToElementRef } from '../../utils/helpers/components';
import { ServersImporter } from '../services/ServersImporter';
import { ServerData, ServersMap } from '../data';
import { DuplicatedServersModal } from './DuplicatedServersModal';
import './ImportServersBtn.scss';
type Ref<T> = RefObject<T> | MutableRefObject<T>;
export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void;
onImportError?: (error: Error) => void;
@ -21,23 +20,21 @@ export type ImportServersBtnProps = PropsWithChildren<{
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
createServers: (servers: ServerData[]) => void;
servers: ServersMap;
fileRef: Ref<HTMLInputElement>;
}
const serversFiltering = (servers: ServerData[]) =>
({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey);
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
createServers,
servers,
fileRef,
children,
onImport = () => {},
onImportError = () => {},
tooltipPlacement = 'bottom',
className = '',
}) => {
const ref = fileRef ?? useRef<HTMLInputElement>();
const ref = useRef<HTMLInputElement>();
const [serversToCreate, setServersToCreate] = useState<ServerData[] | undefined>();
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle();
@ -78,7 +75,13 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
</UncontrolledTooltip>
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onFile} />
<input
type="file"
accept="text/csv"
className="import-servers-btn__csv-select"
ref={mutableRefToElementRef(ref)}
onChange={onFile}
/>
<DuplicatedServersModal
isOpen={isModalOpen}
@ -89,5 +92,3 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
</>
);
};
export default ImportServersBtn;

View file

@ -1,7 +1,7 @@
import { FC } from 'react';
import { Link } from 'react-router-dom';
import Message from '../../utils/Message';
import ServersListGroup from '../ServersListGroup';
import { Message } from '../../utils/Message';
import { ServersListGroup } from '../ServersListGroup';
import { DeleteServerButtonProps } from '../DeleteServerButton';
import { isServerWithId, SelectedServer, ServersMap } from '../data';
import { NoMenuLayout } from '../../common/NoMenuLayout';

View file

@ -1,6 +1,6 @@
import { FC, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import Message from '../../utils/Message';
import { Message } from '../../utils/Message';
import { isNotFoundServer, SelectedServer } from '../data';
import { NoMenuLayout } from '../../common/NoMenuLayout';

View file

@ -1,5 +1,5 @@
import { values } from 'ramda';
import LocalStorage from '../../utils/services/LocalStorage';
import { LocalStorage } from '../../utils/services/LocalStorage';
import { ServersMap, serverWithIdToServerData } from '../data';
import { saveCsv } from '../../utils/helpers/files';
import { JsonToCsv } from '../../utils/helpers/csvjson';

View file

@ -1,10 +1,10 @@
import Bottle from 'bottlejs';
import CreateServer from '../CreateServer';
import { CreateServer } from '../CreateServer';
import { ServersDropdown } from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal';
import DeleteServerButton from '../DeleteServerButton';
import { DeleteServerModal } from '../DeleteServerModal';
import { DeleteServerButton } from '../DeleteServerButton';
import { EditServer } from '../EditServer';
import ImportServersBtn from '../helpers/ImportServersBtn';
import { ImportServersBtn } from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
import { fetchServers } from '../reducers/remoteServers';
@ -25,7 +25,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
ManageServers,
'ServersExporter',
'ImportServersBtn',
'useStateFlagTimeout',
'useTimeoutToggle',
'ManageServersRow',
);
bottle.decorator('ManageServers', withoutSelectedServer);
@ -36,7 +36,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect']));
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useTimeoutToggle');
bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServer', 'resetSelectedServer']));

View file

@ -1,10 +1,11 @@
import { FormGroup, Input } from 'reactstrap';
import classNames from 'classnames';
import ToggleSwitch from '../utils/ToggleSwitch';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import { SimpleCard } from '../utils/SimpleCard';
import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings } from './reducers/settings';
import { useDomId } from '../utils/helpers/hooks';
interface RealTimeUpdatesProps {
settings: Settings;
@ -14,43 +15,48 @@ interface RealTimeUpdatesProps {
const intervalValue = (interval?: number) => (!interval ? '' : `${interval}`);
const RealTimeUpdatesSettings = (
export const RealTimeUpdatesSettings = (
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
) => (
<SimpleCard title="Real-time updates" className="h-100">
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates.
<FormText>
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup
noMargin
label="Real-time updates frequency (in minutes):"
labelClassName={classNames('form-label', { 'text-muted': !realTimeUpdates.enabled })}
>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
/>
{realTimeUpdates.enabled && (
<FormText>
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</FormText>
)}
</LabeledFormGroup>
</SimpleCard>
);
) => {
const inputId = useDomId();
export default RealTimeUpdatesSettings;
return (
<SimpleCard title="Real-time updates" className="h-100">
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates.
<FormText>
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup
noMargin
label="Real-time updates frequency (in minutes):"
labelClassName={classNames('form-label', { 'text-muted': !realTimeUpdates.enabled })}
id={inputId}
>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
id={inputId}
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
/>
{realTimeUpdates.enabled && (
<FormText>
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI
every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</FormText>
)}
</LabeledFormGroup>
</SimpleCard>
);
};

View file

@ -9,7 +9,7 @@ const SettingsSections: FC<{ items: ReactNode[] }> = ({ items }) => (
</>
);
const Settings = (
export const Settings = (
RealTimeUpdates: FC,
ShortUrlCreation: FC,
ShortUrlsList: FC,
@ -32,5 +32,3 @@ const Settings = (
</Routes>
</NoMenuLayout>
);
export default Settings;

View file

@ -1,7 +1,7 @@
import { FC, ReactNode } from 'react';
import { DropdownItem, FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import { DropdownBtn } from '../utils/DropdownBtn';
import { FormText } from '../utils/forms/FormText';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';

View file

@ -2,7 +2,7 @@ import { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import { changeThemeInMarkup, Theme } from '../utils/theme';
import { Settings, UiSettings } from './reducers/settings';
import './UserInterfaceSettings.scss';

View file

@ -1,6 +1,6 @@
import Bottle from 'bottlejs';
import RealTimeUpdatesSettings from '../RealTimeUpdatesSettings';
import Settings from '../Settings';
import { RealTimeUpdatesSettings } from '../RealTimeUpdatesSettings';
import { Settings } from '../Settings';
import {
setRealTimeUpdatesInterval,
setShortUrlCreationSettings,

View file

@ -33,7 +33,10 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
forwardQuery: settings?.forwardQuery ?? true,
});
const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({
export const CreateShortUrl = (
ShortUrlForm: FC<ShortUrlFormProps>,
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
) => ({
createShortUrl,
shortUrlCreationResult,
resetCreateShortUrl,
@ -52,7 +55,6 @@ const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResul
mode={basicMode ? 'create-basic' : 'create'}
onSave={async (data: ShortUrlData) => {
resetCreateShortUrl();
return createShortUrl(data);
}}
/>
@ -64,5 +66,3 @@ const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResul
</>
);
};
export default CreateShortUrl;

View file

@ -5,17 +5,18 @@ import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link';
import { useLocation, useParams } from 'react-router-dom';
import { SelectedServer } from '../servers/data';
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
import { Settings } from '../settings/reducers/settings';
import { OptionalString } from '../utils/utils';
import { parseQuery } from '../utils/helpers/query';
import Message from '../utils/Message';
import { Message } from '../utils/Message';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { useGoBack, useToggle } from '../utils/helpers/hooks';
import { ShortUrlFormProps } from './ShortUrlForm';
import { ShortUrlDetail } from './reducers/shortUrlDetail';
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
import { EditShortUrlData } from './data';
import { ShortUrlEdition } from './reducers/shortUrlEdition';
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
interface EditShortUrlConnectProps {
settings: Settings;
@ -26,27 +27,6 @@ interface EditShortUrlConnectProps {
editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise<void>;
}
const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => {
const validateUrl = settings?.validateUrls ?? false;
if (!shortUrl) {
return { longUrl: '', validateUrl };
}
return {
longUrl: shortUrl.longUrl,
tags: shortUrl.tags,
title: shortUrl.title ?? undefined,
domain: shortUrl.domain ?? undefined,
validSince: shortUrl.meta.validSince ?? undefined,
validUntil: shortUrl.meta.validUntil ?? undefined,
maxVisits: shortUrl.meta.maxVisits ?? undefined,
crawlable: shortUrl.crawlable,
forwardQuery: shortUrl.forwardQuery,
validateUrl,
};
};
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
settings: { shortUrlCreation: shortUrlCreationSettings },
selectedServer,
@ -62,13 +42,13 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition;
const { domain } = parseQuery<{ domain?: string }>(search);
const initialState = useMemo(
() => getInitialState(shortUrl, shortUrlCreationSettings),
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
[shortUrl, shortUrlCreationSettings],
);
const [savingSucceeded,, isSuccessful, isNotSuccessful] = useToggle();
useEffect(() => {
params.shortCode && getShortUrlDetail(params.shortCode, domain);
params.shortCode && getShortUrlDetail(urlDecodeShortCode(params.shortCode), domain);
}, []);
if (loading) {

View file

@ -15,7 +15,7 @@ interface PaginatorProps {
currentQueryString?: string;
}
const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
export const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
const urlForPage = (pageNumber: NumberOrEllipsis) =>
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
@ -49,5 +49,3 @@ const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorPr
</Pagination>
);
};
export default Paginator;

View file

@ -3,11 +3,11 @@ import { InputType } from 'reactstrap/types/lib/Input';
import { Button, FormGroup, Input, Row } from 'reactstrap';
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
import { parseISO } from 'date-fns';
import DateInput, { DateInputProps } from '../utils/DateInput';
import { DateInput, DateInputProps } from '../utils/DateInput';
import { supportsCrawlableVisits, supportsForwardQuery } from '../utils/helpers/features';
import { SimpleCard } from '../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
import Checkbox from '../utils/Checkbox';
import { Checkbox } from '../utils/Checkbox';
import { SelectedServer } from '../servers/data';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { DomainSelectorProps } from '../domains/DomainSelector';
@ -118,7 +118,7 @@ export const ShortUrlForm = (
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
return (
<form className="short-url-form" onSubmit={submit}>
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
{isBasicMode && basicComponents}
{!isBasicMode && (
<>

View file

@ -11,7 +11,7 @@ import { ShlinkShortUrlsListParams } from '../api/types';
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsTableProps } from './ShortUrlsTable';
import Paginator from './Paginator';
import { Paginator } from './Paginator';
import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrderableFields } from './data';
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
@ -23,7 +23,7 @@ interface ShortUrlsListProps {
settings: Settings;
}
const ShortUrlsList = (
export const ShortUrlsList = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
@ -83,5 +83,3 @@ const ShortUrlsList = (
</>
);
}, () => [Topics.visits]);
export default ShortUrlsList;

View file

@ -6,7 +6,7 @@ import { useEffect } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { Tooltip } from 'reactstrap';
import { ShortUrlCreation } from '../reducers/shortUrlCreation';
import { StateFlagTimeout } from '../../utils/helpers/hooks';
import { TimeoutToggle } from '../../utils/helpers/hooks';
import { Result } from '../../utils/Result';
import './CreateShortUrlResult.scss';
import { ShlinkApiError } from '../../api/ShlinkApiError';
@ -16,10 +16,10 @@ export interface CreateShortUrlResultProps extends ShortUrlCreation {
canBeClosed?: boolean;
}
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => (
{ error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
) => {
const [showCopyTooltip, setShowCopyTooltip] = useStateFlagTimeout();
const [showCopyTooltip, setShowCopyTooltip] = useTimeoutToggle();
useEffect(() => {
resetCreateShortUrl();
@ -43,7 +43,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
return (
<Result type="success" className="mt-3">
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
<b>Great!</b> The short URL is <b>{shortUrl}</b>
<span><b>Great!</b> The short URL is <b>{shortUrl}</b></span>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
<button
@ -61,5 +61,3 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
</Result>
);
};
export default CreateShortUrlResult;

View file

@ -14,7 +14,7 @@ interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
resetDeleteShortUrl: () => void;
}
const DeleteShortUrlModal = (
export const DeleteShortUrlModal = (
{ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }: DeleteShortUrlModalConnectProps,
) => {
const [inputValue, setInputValue] = useState('');
@ -70,5 +70,3 @@ const DeleteShortUrlModal = (
</Modal>
);
};
export default DeleteShortUrlModal;

View file

@ -17,7 +17,7 @@ interface QrCodeModalConnectProps extends ShortUrlModalProps {
selectedServer: SelectedServer;
}
const QrCodeModal = (imageDownloader: ImageDownloader) => (
export const QrCodeModal = (imageDownloader: ImageDownloader) => (
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
) => {
const [size, setSize] = useState(300);
@ -107,5 +107,3 @@ const QrCodeModal = (imageDownloader: ImageDownloader) => (
</Modal>
);
};
export default QrCodeModal;

View file

@ -2,6 +2,7 @@ import { FC } from 'react';
import { Link } from 'react-router-dom';
import { isServerWithId, SelectedServer, ServerWithId } from '../../servers/data';
import { ShortUrl } from '../data';
import { urlEncodeShortCode } from './index';
export type LinkSuffix = 'visits' | 'edit';
@ -13,11 +14,10 @@ export interface ShortUrlDetailLinkProps {
const buildUrl = ({ id }: ServerWithId, { shortCode, domain }: ShortUrl, suffix: LinkSuffix) => {
const query = domain ? `?domain=${domain}` : '';
return `/server/${id}/short-code/${shortCode}/${suffix}${query}`;
return `/server/${id}/short-code/${urlEncodeShortCode(shortCode)}/${suffix}${query}`;
};
const ShortUrlDetailLink: FC<ShortUrlDetailLinkProps & Record<string | number, any>> = (
export const ShortUrlDetailLink: FC<ShortUrlDetailLinkProps & Record<string | number, any>> = (
{ selectedServer, shortUrl, suffix, children, ...rest },
) => {
if (!selectedServer || !isServerWithId(selectedServer) || !shortUrl) {
@ -26,5 +26,3 @@ const ShortUrlDetailLink: FC<ShortUrlDetailLinkProps & Record<string | number, a
return <Link to={buildUrl(selectedServer, shortUrl, suffix)} {...rest}>{children}</Link>;
};
export default ShortUrlDetailLink;

View file

@ -1,5 +1,5 @@
import { ChangeEvent, FC, PropsWithChildren } from 'react';
import Checkbox from '../../utils/Checkbox';
import { Checkbox } from '../../utils/Checkbox';
import { InfoTooltip } from '../../utils/InfoTooltip';
type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{

View file

@ -6,8 +6,9 @@ import classNames from 'classnames';
import { prettify } from '../../utils/helpers/numbers';
import { ShortUrl } from '../data';
import { SelectedServer } from '../../servers/data';
import ShortUrlDetailLink from './ShortUrlDetailLink';
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
import './ShortUrlVisitsCount.scss';
import { mutableRefToElementRef } from '../../utils/helpers/components';
interface ShortUrlVisitsCountProps {
shortUrl?: ShortUrl | null;
@ -16,7 +17,9 @@ interface ShortUrlVisitsCountProps {
active?: boolean;
}
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps) => {
export const ShortUrlVisitsCount = (
{ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps,
) => {
const maxVisits = shortUrl?.meta?.maxVisits;
const visitsLink = (
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
@ -33,7 +36,7 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = f
}
const prettifiedMaxVisits = prettify(maxVisits);
const tooltipRef = useRef<HTMLElement | null>();
const tooltipRef = useRef<HTMLElement | undefined>();
return (
<>
@ -41,9 +44,7 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = f
{visitsLink}
<small
className="short-urls-visits-count__max-visits-control"
ref={(el) => {
tooltipRef.current = el;
}}
ref={mutableRefToElementRef(tooltipRef)}
>
{' '}/ {prettifiedMaxVisits}{' '}
<sup>
@ -57,5 +58,3 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = f
</>
);
};
export default ShortUrlVisitsCount;

View file

@ -1,14 +1,14 @@
import { FC, useEffect, useRef } from 'react';
import { isEmpty } from 'ramda';
import { ExternalLink } from 'react-external-link';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { StateFlagTimeout } from '../../utils/helpers/hooks';
import Tag from '../../tags/helpers/Tag';
import { ColorGenerator } from '../../utils/services/ColorGenerator';
import { TimeoutToggle } from '../../utils/helpers/hooks';
import { Tag } from '../../tags/helpers/Tag';
import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { ShortUrl } from '../data';
import { Time } from '../../utils/Time';
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
import './ShortUrlsRow.scss';
@ -18,13 +18,13 @@ export interface ShortUrlsRowProps {
shortUrl: ShortUrl;
}
const ShortUrlsRow = (
export const ShortUrlsRow = (
ShortUrlsRowMenu: FC<ShortUrlsRowMenuProps>,
colorGenerator: ColorGenerator,
useStateFlagTimeout: StateFlagTimeout,
useTimeoutToggle: TimeoutToggle,
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => {
const [copiedToClipboard, setCopiedToClipboard] = useStateFlagTimeout();
const [active, setActive] = useStateFlagTimeout(false, 500);
const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle();
const [active, setActive] = useTimeoutToggle(false, 500);
const isFirstRun = useRef(true);
const renderTags = (tags: string[]) => {
@ -87,5 +87,3 @@ const ShortUrlsRow = (
</tr>
);
};
export default ShortUrlsRow;

View file

@ -11,7 +11,7 @@ import { useToggle } from '../../utils/helpers/hooks';
import { ShortUrl, ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data';
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
import ShortUrlDetailLink from './ShortUrlDetailLink';
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
export interface ShortUrlsRowMenuProps {
selectedServer: SelectedServer;
@ -19,7 +19,7 @@ export interface ShortUrlsRowMenuProps {
}
type ShortUrlModal = FC<ShortUrlModalProps>;
const ShortUrlsRowMenu = (
export const ShortUrlsRowMenu = (
DeleteShortUrlModal: ShortUrlModal,
QrCodeModal: ShortUrlModal,
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
@ -51,5 +51,3 @@ const ShortUrlsRowMenu = (
</DropdownBtnMenu>
);
};
export default ShortUrlsRowMenu;

View file

@ -1,7 +1,8 @@
import { isNil } from 'ramda';
import { ShortUrl } from '../data';
import { ShortUrl, ShortUrlData } from '../data';
import { OptionalString } from '../../utils/utils';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import { ShortUrlCreationSettings } from '../../settings/reducers/settings';
export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: OptionalString): boolean => {
if (isNil(domain)) {
@ -18,3 +19,30 @@ export const domainMatches = (shortUrl: ShortUrl, domain: string): boolean => {
return shortUrl.domain === domain;
};
export const shortUrlDataFromShortUrl = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => {
const validateUrl = settings?.validateUrls ?? false;
if (!shortUrl) {
return { longUrl: '', validateUrl };
}
return {
longUrl: shortUrl.longUrl,
tags: shortUrl.tags,
title: shortUrl.title ?? undefined,
domain: shortUrl.domain ?? undefined,
validSince: shortUrl.meta.validSince ?? undefined,
validUntil: shortUrl.meta.validUntil ?? undefined,
maxVisits: shortUrl.meta.maxVisits ?? undefined,
crawlable: shortUrl.crawlable,
forwardQuery: shortUrl.forwardQuery,
validateUrl,
};
};
const MULTI_SEGMENT_SEPARATOR = '__';
export const urlEncodeShortCode = (shortCode: string): string => shortCode.replaceAll('/', MULTI_SEGMENT_SEPARATOR);
export const urlDecodeShortCode = (shortCode: string): string => shortCode.replaceAll(MULTI_SEGMENT_SEPARATOR, '/');

View file

@ -1,18 +1,18 @@
import Bottle from 'bottlejs';
import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar';
import ShortUrlsList from '../ShortUrlsList';
import ShortUrlsRow from '../helpers/ShortUrlsRow';
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
import CreateShortUrl from '../CreateShortUrl';
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
import { ShortUrlsList } from '../ShortUrlsList';
import { ShortUrlsRow } from '../helpers/ShortUrlsRow';
import { ShortUrlsRowMenu } from '../helpers/ShortUrlsRowMenu';
import { CreateShortUrl } from '../CreateShortUrl';
import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal';
import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
import { editShortUrl } from '../reducers/shortUrlEdition';
import { ConnectDecorator } from '../../container/types';
import { ShortUrlsTable } from '../ShortUrlsTable';
import QrCodeModal from '../helpers/QrCodeModal';
import { QrCodeModal } from '../helpers/QrCodeModal';
import { ShortUrlForm } from '../ShortUrlForm';
import { EditShortUrl } from '../EditShortUrl';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
@ -27,9 +27,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
));
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle');
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle');
bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult');

View file

@ -5,11 +5,12 @@ import { FC, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { prettify } from '../utils/helpers/numbers';
import { useToggle } from '../utils/helpers/hooks';
import ColorGenerator from '../utils/services/ColorGenerator';
import { ColorGenerator } from '../utils/services/ColorGenerator';
import { getServerId, SelectedServer } from '../servers/data';
import TagBullet from './helpers/TagBullet';
import { TagBullet } from './helpers/TagBullet';
import { NormalizedTag, TagModalProps } from './data';
import './TagCard.scss';
import { mutableRefToElementRef } from '../utils/helpers/components';
export interface TagCardProps {
tag: NormalizedTag;
@ -20,7 +21,7 @@ export interface TagCardProps {
const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollWidth > el.clientWidth;
const TagCard = (
export const TagCard = (
DeleteTagConfirmModal: FC<TagModalProps>,
EditTagModal: FC<TagModalProps>,
colorGenerator: ColorGenerator,
@ -28,7 +29,7 @@ const TagCard = (
const [isDeleteModalOpen, toggleDelete] = useToggle();
const [isEditModalOpen, toggleEdit] = useToggle();
const [hasTitle,, displayTitle] = useToggle();
const titleRef = useRef<HTMLElement>();
const titleRef = useRef<HTMLHeadingElement | undefined>();
const serverId = getServerId(selectedServer);
useEffect(() => {
@ -40,18 +41,22 @@ const TagCard = (
return (
<Card className="tag-card">
<CardHeader className="tag-card__header">
<Button color="link" size="sm" className="tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
<Button
aria-label="Delete tag"
color="link"
size="sm"
className="tag-card__btn tag-card__btn--last"
onClick={toggleDelete}
>
<FontAwesomeIcon icon={deleteIcon} />
</Button>
<Button color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
<Button aria-label="Edit tag" color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} />
</Button>
<h5
className="tag-card__tag-title text-ellipsis"
title={hasTitle ? tag.tag : undefined}
ref={(el) => {
titleRef.current = el ?? undefined;
}}
ref={mutableRefToElementRef(titleRef)}
>
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} />
<span className="tag-card__tag-name" onClick={toggle}>{tag.tag}</span>
@ -82,5 +87,3 @@ const TagCard = (
</Card>
);
};
export default TagCard;

View file

@ -1,7 +1,7 @@
import { FC, useEffect, useState } from 'react';
import { Row } from 'reactstrap';
import { pipe } from 'ramda';
import Message from '../utils/Message';
import { Message } from '../utils/Message';
import { SearchField } from '../utils/SearchField';
import { SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
@ -30,7 +30,7 @@ export interface TagsListProps {
settings: Settings;
}
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
export const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => {
const [mode, setMode] = useState<TagsMode>(settings.tags?.defaultMode ?? 'cards');
@ -104,5 +104,3 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableP
</>
);
}, () => [Topics.visits]);
export default TagsList;

View file

@ -4,11 +4,11 @@ import { DropdownItem } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
import { getServerId, SelectedServer } from '../servers/data';
import ColorGenerator from '../utils/services/ColorGenerator';
import { ColorGenerator } from '../utils/services/ColorGenerator';
import { prettify } from '../utils/helpers/numbers';
import { useToggle } from '../utils/helpers/hooks';
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
import TagBullet from './helpers/TagBullet';
import { TagBullet } from './helpers/TagBullet';
import { NormalizedTag, TagModalProps } from './data';
export interface TagsTableRowProps {

View file

@ -10,7 +10,7 @@ interface DeleteTagConfirmModalProps extends TagModalProps {
tagDelete: TagDeletion;
}
const DeleteTagConfirmModal = (
export const DeleteTagConfirmModal = (
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
) => {
const { deleting, error, errorData } = tagDelete;
@ -22,9 +22,7 @@ const DeleteTagConfirmModal = (
return (
<Modal toggle={toggle} isOpen={isOpen} centered>
<ModalHeader toggle={toggle}>
<span className="text-danger">Delete tag</span>
</ModalHeader>
<ModalHeader toggle={toggle} className="text-danger">Delete tag</ModalHeader>
<ModalBody>
Are you sure you want to delete tag <b>{tag}</b>?
{error && (
@ -42,5 +40,3 @@ const DeleteTagConfirmModal = (
</Modal>
);
};
export default DeleteTagConfirmModal;

View file

@ -5,7 +5,7 @@ import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../../utils/helpers/hooks';
import { handleEventPreventingDefault } from '../../utils/utils';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { ColorGenerator } from '../../utils/services/ColorGenerator';
import { TagModalProps } from '../data';
import { TagEdition } from '../reducers/tagEdit';
import { Result } from '../../utils/Result';
@ -18,7 +18,7 @@ interface EditTagModalProps extends TagModalProps {
tagEdited: (oldName: string, newName: string, color: string) => void;
}
const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
export const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
{ tag, editTag, toggle, tagEdited, isOpen, tagEdit }: EditTagModalProps,
) => {
const [newTagName, setNewTagName] = useState(tag);
@ -34,7 +34,7 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
<form onSubmit={saveTag}>
<form name="editTag" onSubmit={saveTag}>
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
<ModalBody>
<InputGroup>
@ -78,5 +78,3 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
</Modal>
);
};
export default EditTagModal;

View file

@ -1,6 +1,6 @@
import { FC, MouseEventHandler, PropsWithChildren } from 'react';
import classNames from 'classnames';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { ColorGenerator } from '../../utils/services/ColorGenerator';
import './Tag.scss';
type TagProps = PropsWithChildren<{
@ -12,15 +12,15 @@ type TagProps = PropsWithChildren<{
onClose?: MouseEventHandler;
}>;
const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
export const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
<span
className={classNames('badge tag', className, { 'tag--light-bg': colorGenerator.isColorLightForKey(text) })}
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
onClick={onClick}
>
{children ?? text}
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>&times;</span>}
{clearable && (
<span aria-label={`Remove ${text}`} className="close tag__close-selected-tag" onClick={onClose}>&times;</span>
)}
</span>
);
export default Tag;

View file

@ -1,4 +1,4 @@
import ColorGenerator from '../../utils/services/ColorGenerator';
import { ColorGenerator } from '../../utils/services/ColorGenerator';
import './TagBullet.scss';
interface TagBulletProps {
@ -6,11 +6,9 @@ interface TagBulletProps {
colorGenerator: ColorGenerator;
}
const TagBullet = ({ tag, colorGenerator }: TagBulletProps) => (
export const TagBullet = ({ tag, colorGenerator }: TagBulletProps) => (
<div
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
className="tag-bullet"
/>
);
export default TagBullet;

View file

@ -1,10 +1,10 @@
import { useEffect } from 'react';
import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { ColorGenerator } from '../../utils/services/ColorGenerator';
import { Settings } from '../../settings/reducers/settings';
import { TagsList } from '../reducers/tagsList';
import TagBullet from './TagBullet';
import Tag from './Tag';
import { TagBullet } from './TagBullet';
import { Tag } from './Tag';
export interface TagsSelectorProps {
selectedTags: string[];
@ -21,7 +21,7 @@ interface TagsSelectorConnectProps extends TagsSelectorProps {
const toComponentTag = (tag: string) => ({ id: tag, name: tag });
const TagsSelector = (colorGenerator: ColorGenerator) => (
export const TagsSelector = (colorGenerator: ColorGenerator) => (
{ selectedTags, onChange, placeholder, listTags, tagsList, settings, allowNew = true }: TagsSelectorConnectProps,
) => {
useEffect(() => {
@ -68,5 +68,3 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
/>
);
};
export default TagsSelector;

View file

@ -2,7 +2,7 @@ import { pick } from 'ramda';
import { Action, Dispatch } from 'redux';
import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types';
import ColorGenerator from '../../utils/services/ColorGenerator';
import { ColorGenerator } from '../../utils/services/ColorGenerator';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';

View file

@ -96,7 +96,7 @@ export default buildReducer<TagsList, TagsCombinedAction>({
}),
[FILTER_TAGS]: (state, { searchTerm }) => ({
...state,
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)),
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())),
}),
[CREATE_VISITS]: (state, { createdVisits }) => ({
...state,

View file

@ -1,9 +1,9 @@
import Bottle, { IContainer } from 'bottlejs';
import TagsSelector from '../helpers/TagsSelector';
import TagCard from '../TagCard';
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
import EditTagModal from '../helpers/EditTagModal';
import TagsList from '../TagsList';
import { TagsSelector } from '../helpers/TagsSelector';
import { TagCard } from '../TagCard';
import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal';
import { EditTagModal } from '../helpers/EditTagModal';
import { TagsList } from '../TagsList';
import { filterTags, listTags } from '../reducers/tagsList';
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
import { editTag, tagEdited } from '../reducers/tagEdit';

View file

@ -31,6 +31,7 @@ $darkBorderInputColor: $darkBorderColor;
$darkTableHighlightColor: $darkBorderColor;
html:not([data-theme='dark']) {
--color-scheme: initial;
--primary-color: #{$lightPrimaryColor};
--primary-color-alfa: #{$lightPrimaryColorAlfa};
--secondary-color: #{$lightSecondaryColor};
@ -48,6 +49,7 @@ html:not([data-theme='dark']) {
}
html[data-theme='dark'] {
--color-scheme: dark;
--primary-color: #{$darkPrimaryColor};
--primary-color-alfa: #{$darkPrimaryColorAlfa};
--secondary-color: #{$darkSecondaryColor};

View file

@ -14,7 +14,7 @@ interface BooleanControlWithTypeProps extends BooleanControlProps {
type: 'switch' | 'checkbox';
}
const BooleanControl: FC<BooleanControlWithTypeProps> = (
export const BooleanControl: FC<BooleanControlWithTypeProps> = (
{ checked = false, onChange = identity, className, children, type, inline = false },
) => {
const id = useDomId();
@ -32,5 +32,3 @@ const BooleanControl: FC<BooleanControlWithTypeProps> = (
</span>
);
};
export default BooleanControl;

View file

@ -1,6 +1,4 @@
import { FC } from 'react';
import BooleanControl, { BooleanControlProps } from './BooleanControl';
import { BooleanControl, BooleanControlProps } from './BooleanControl';
const Checkbox: FC<BooleanControlProps> = (props) => <BooleanControl type="checkbox" {...props} />;
export default Checkbox;
export const Checkbox: FC<BooleanControlProps> = (props) => <BooleanControl type="checkbox" {...props} />;

View file

@ -8,7 +8,7 @@ import './DateInput.scss';
export type DateInputProps = ReactDatePickerProps;
const DateInput = (props: DateInputProps) => {
export const DateInput = (props: DateInputProps) => {
const { className, isClearable, selected } = props;
const showCalendarIcon = !isClearable || isNil(selected);
const ref = useRef<{ input: HTMLInputElement }>();
@ -32,5 +32,3 @@ const DateInput = (props: DateInputProps) => {
</div>
);
};
export default DateInput;

View file

@ -3,21 +3,19 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import { Placement } from '@popperjs/core';
import { mutableRefToElementRef } from './helpers/components';
type InfoTooltipProps = PropsWithChildren<{
export type InfoTooltipProps = PropsWithChildren<{
className?: string;
placement: Placement;
}>;
export const InfoTooltip: FC<InfoTooltipProps> = ({ className = '', placement, children }) => {
const ref = useRef<HTMLSpanElement | null>();
const refCallback = (el: HTMLSpanElement) => {
ref.current = el;
};
const ref = useRef<HTMLSpanElement | undefined>();
return (
<>
<span className={className} ref={refCallback}>
<span className={className} ref={mutableRefToElementRef(ref)}>
<FontAwesomeIcon icon={infoIcon} />
</span>
<UncontrolledTooltip target={(() => ref.current) as any} placement={placement}>{children}</UncontrolledTooltip>

View file

@ -30,7 +30,9 @@ export type MessageProps = PropsWithChildren<{
type?: MessageType;
}>;
const Message: FC<MessageProps> = ({ className, children, loading = false, type = 'default', fullWidth = false }) => {
export const Message: FC<MessageProps> = (
{ className, children, loading = false, type = 'default', fullWidth = false },
) => {
const classes = classNames({
'col-md-12': fullWidth,
'col-md-10 offset-md-1': !fullWidth,
@ -50,5 +52,3 @@ const Message: FC<MessageProps> = ({ className, children, loading = false, type
</Row>
);
};
export default Message;

View file

@ -7,7 +7,7 @@ interface PaginationDropdownProps {
toggleClassName?: string;
}
const PaginationDropdown = ({ toggleClassName, ranges, value, setValue }: PaginationDropdownProps) => (
export const PaginationDropdown = ({ toggleClassName, ranges, value, setValue }: PaginationDropdownProps) => (
<UncontrolledDropdown>
<DropdownToggle caret color="link" className={toggleClassName}>Paginate</DropdownToggle>
<DropdownMenu end>
@ -23,5 +23,3 @@ const PaginationDropdown = ({ toggleClassName, ranges, value, setValue }: Pagina
</DropdownMenu>
</UncontrolledDropdown>
);
export default PaginationDropdown;

View file

@ -15,6 +15,7 @@ export const Result: FC<ResultProps> = ({ children, type, className, small = fal
<Row className={className}>
<div className={classNames({ 'col-md-10 offset-md-1': !small, 'col-12': small })}>
<SimpleCard
role="document"
className={classNames('text-center', {
'bg-main': type === 'success',
'bg-danger': type === 'error',

View file

@ -1,13 +1,13 @@
import { parseISO, format as formatDate, getUnixTime, formatDistance } from 'date-fns';
import { isDateObject } from './helpers/date';
export interface DateProps {
export interface TimeProps {
date: Date | string;
format?: string;
relative?: boolean;
}
export const Time = ({ date, format = 'yyyy-MM-dd HH:mm', relative = false }: DateProps) => {
export const Time = ({ date, format = 'yyyy-MM-dd HH:mm', relative = false }: TimeProps) => {
const dateObject = isDateObject(date) ? date : parseISO(date);
return (

View file

@ -1,6 +1,4 @@
import { FC } from 'react';
import BooleanControl, { BooleanControlProps } from './BooleanControl';
import { BooleanControl, BooleanControlProps } from './BooleanControl';
const ToggleSwitch: FC<BooleanControlProps> = (props) => <BooleanControl type="switch" {...props} />;
export default ToggleSwitch;
export const ToggleSwitch: FC<BooleanControlProps> = (props) => <BooleanControl type="switch" {...props} />;

View file

@ -1,5 +1,5 @@
import { endOfDay } from 'date-fns';
import DateInput from '../DateInput';
import { DateInput } from '../DateInput';
import { DateRange } from './types';
interface DateRangeRowProps extends DateRange {
@ -8,7 +8,7 @@ interface DateRangeRowProps extends DateRange {
disabled?: boolean;
}
const DateRangeRow = (
export const DateRangeRow = (
{ startDate = null, endDate = null, disabled = false, onStartDateChange, onEndDateChange }: DateRangeRowProps,
) => (
<div className="row">
@ -35,5 +35,3 @@ const DateRangeRow = (
</div>
</div>
);
export default DateRangeRow;

View file

@ -10,7 +10,7 @@ import {
rangeIsInterval,
dateRangeIsEmpty,
} from './types';
import DateRangeRow from './DateRangeRow';
import { DateRangeRow } from './DateRangeRow';
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
export interface DateRangeSelectorProps {

View file

@ -1,6 +1,7 @@
import { FC, PropsWithChildren } from 'react';
import { InputType } from 'reactstrap/types/lib/Input';
import { LabeledFormGroup } from './LabeledFormGroup';
import { useDomId } from '../helpers/hooks';
export type InputFormGroupProps = PropsWithChildren<{
value: string;
@ -14,15 +15,20 @@ export type InputFormGroupProps = PropsWithChildren<{
export const InputFormGroup: FC<InputFormGroupProps> = (
{ children, value, onChange, type, required, placeholder, className, labelClassName },
) => (
<LabeledFormGroup label={<>{children}:</>} className={className ?? ''} labelClassName={labelClassName}>
<input
className="form-control"
type={type ?? 'text'}
value={value}
required={required ?? true}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</LabeledFormGroup>
);
) => {
const id = useDomId();
return (
<LabeledFormGroup label={<>{children}:</>} className={className ?? ''} labelClassName={labelClassName} id={id}>
<input
id={id}
className="form-control"
type={type ?? 'text'}
value={value}
required={required ?? true}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</LabeledFormGroup>
);
};

View file

@ -5,14 +5,15 @@ type LabeledFormGroupProps = PropsWithChildren<{
noMargin?: boolean;
className?: string;
labelClassName?: string;
id?: string;
}>;
/* eslint-disable jsx-a11y/label-has-associated-control */
export const LabeledFormGroup: FC<LabeledFormGroupProps> = (
{ children, label, className = '', labelClassName = '', noMargin = false },
{ children, label, className = '', labelClassName = '', noMargin = false, id },
) => (
<div className={`${className} ${noMargin ? '' : 'mb-3'}`}>
<label className={`form-label ${labelClassName}`}>{label}</label>
<label className={`form-label ${labelClassName}`} htmlFor={id}>{label}</label>
{children}
</div>
);

View file

@ -0,0 +1,5 @@
import { MutableRefObject, Ref } from 'react';
export const mutableRefToElementRef = <T>(ref: MutableRefObject<T | undefined>): Ref<T> => (el) => {
ref.current = el ?? undefined; // eslint-disable-line no-param-reassign
};

View file

@ -6,12 +6,12 @@ import { parseQuery, stringifyQuery } from './query';
const DEFAULT_DELAY = 2000;
export type StateFlagTimeout = (initialValue?: boolean, delay?: number) => [ boolean, () => void ];
export type TimeoutToggle = (initialValue?: boolean, delay?: number) => [boolean, () => void];
export const useStateFlagTimeout = (
export const useTimeoutToggle = (
setTimeout: (callback: Function, timeout: number) => number,
clearTimeout: (timer: number) => void,
): StateFlagTimeout => (initialValue = false, delay = DEFAULT_DELAY) => {
): TimeoutToggle => (initialValue = false, delay = DEFAULT_DELAY) => {
const [flag, setFlag] = useState<boolean>(initialValue);
const timeout = useRef<number | undefined>(undefined);
const callback = () => {
@ -31,7 +31,6 @@ type ToggleResult = [ boolean, () => void, () => void, () => void ];
export const useToggle = (initialValue = false): ToggleResult => {
const [flag, setFlag] = useState<boolean>(initialValue);
return [flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false)];
};
@ -80,7 +79,6 @@ export const useEffectExceptFirstTime = (callback: EffectCallback, deps: Depende
export const useGoBack = () => {
const navigate = useNavigate();
return () => navigate(-1);
};

View file

@ -1,6 +1,6 @@
import { isNil } from 'ramda';
import { rangeOf } from '../utils';
import LocalStorage from './LocalStorage';
import { LocalStorage } from './LocalStorage';
const HEX_COLOR_LENGTH = 6;
const HEX_DIGITS = '0123456789ABCDEF';
@ -15,7 +15,7 @@ const hexColorToRgbArray = (colorHex: string): number[] =>
// HSP by Darel Rex Finley https://alienryderflex.com/hsp.html
const perceivedLightness = (r = 0, g = 0, b = 0) => round(sqrt(0.299 * r ** 2 + 0.587 * g ** 2 + 0.114 * b ** 2));
export default class ColorGenerator {
export class ColorGenerator {
private readonly colors: Record<string, string>;
private readonly lights: Record<string, boolean>;

View file

@ -1,7 +1,7 @@
const PREFIX = 'shlink';
const buildPath = (path: string) => `${PREFIX}.${path}`;
export default class LocalStorage {
export class LocalStorage {
public constructor(private readonly localStorage: Storage) {}
public readonly get = <T>(key: string): T | undefined => {

View file

@ -1,7 +1,7 @@
import Bottle from 'bottlejs';
import { useStateFlagTimeout } from '../helpers/hooks';
import LocalStorage from './LocalStorage';
import ColorGenerator from './ColorGenerator';
import { useTimeoutToggle } from '../helpers/hooks';
import { LocalStorage } from './LocalStorage';
import { ColorGenerator } from './ColorGenerator';
import { csvToJson, jsonToCsv } from '../helpers/csvjson';
const provideServices = (bottle: Bottle) => {
@ -14,7 +14,7 @@ const provideServices = (bottle: Bottle) => {
bottle.constant('setTimeout', global.setTimeout);
bottle.constant('clearTimeout', global.clearTimeout);
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout');
bottle.serviceFactory('useTimeoutToggle', useTimeoutToggle, 'setTimeout', 'clearTimeout');
};
export default provideServices;

View file

@ -8,8 +8,8 @@ import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks';
import { toApiParams } from './types/helpers';
import { NormalizedVisit } from './types';
import VisitsStats from './VisitsStats';
import VisitsHeader from './VisitsHeader';
import { VisitsStats } from './VisitsStats';
import { VisitsHeader } from './VisitsHeader';
export interface DomainVisitsProps extends CommonVisitsProps {
getDomainVisits: (domain: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;

View file

@ -3,11 +3,11 @@ import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import VisitsStats from './VisitsStats';
import { VisitsStats } from './VisitsStats';
import { NormalizedVisit, VisitsInfo, VisitsParams } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import VisitsHeader from './VisitsHeader';
import { VisitsHeader } from './VisitsHeader';
export interface NonOrphanVisitsProps extends CommonVisitsProps {
getNonOrphanVisits: (params?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;

View file

@ -3,11 +3,11 @@ import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import VisitsStats from './VisitsStats';
import { VisitsStats } from './VisitsStats';
import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import VisitsHeader from './VisitsHeader';
import { VisitsHeader } from './VisitsHeader';
export interface OrphanVisitsProps extends CommonVisitsProps {
getOrphanVisits: (

View file

@ -8,11 +8,12 @@ import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
import VisitsStats from './VisitsStats';
import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader';
import { VisitsStats } from './VisitsStats';
import { NormalizedVisit, VisitsParams } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import { urlDecodeShortCode } from '../short-urls/helpers';
export interface ShortUrlVisitsProps extends CommonVisitsProps {
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
@ -22,7 +23,7 @@ export interface ShortUrlVisitsProps extends CommonVisitsProps {
cancelGetShortUrlVisits: () => void;
}
const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
shortUrlVisits,
shortUrlDetail,
getShortUrlVisits,
@ -36,14 +37,14 @@ const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub((
const goBack = useGoBack();
const { domain } = parseQuery<{ domain?: string }>(search);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
getShortUrlVisits(shortCode, { ...toApiParams(params), domain }, doIntervalFallback);
getShortUrlVisits(urlDecodeShortCode(shortCode), { ...toApiParams(params), domain }, doIntervalFallback);
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
visits,
);
useEffect(() => {
getShortUrlDetail(shortCode, domain);
getShortUrlDetail(urlDecodeShortCode(shortCode), domain);
}, []);
return (
@ -59,6 +60,4 @@ const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub((
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats>
);
}, (_, params) => [Topics.shortUrlVisits(params.shortCode)]);
export default ShortUrlVisits;
}, (_, params) => (params.shortCode ? [Topics.shortUrlVisits(urlDecodeShortCode(params.shortCode))] : []));

View file

@ -3,7 +3,7 @@ import { ExternalLink } from 'react-external-link';
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import { Time } from '../utils/Time';
import { ShortUrlVisits } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader';
import { VisitsHeader } from './VisitsHeader';
import './ShortUrlVisitsHeader.scss';
interface ShortUrlVisitsHeaderProps {
@ -12,7 +12,7 @@ interface ShortUrlVisitsHeaderProps {
goBack: () => void;
}
const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortUrlVisitsHeaderProps) => {
export const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortUrlVisitsHeaderProps) => {
const { shortUrl, loading } = shortUrlDetail;
const { visits } = shortUrlVisits;
const shortLink = shortUrl?.shortUrl ?? '';
@ -35,7 +35,7 @@ const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortU
<VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} shortUrl={shortUrl}>
<hr />
<div>Created: {renderDate()}</div>
<div>
<div className="long-url-container">
{`${title ? 'Title' : 'Long URL'}: `}
{loading && <small>Loading...</small>}
{!loading && <ExternalLink href={longLink}>{title ?? longLink}</ExternalLink>}
@ -43,5 +43,3 @@ const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortU
</VisitsHeader>
);
};
export default ShortUrlVisitsHeader;

View file

@ -1,13 +1,13 @@
import { useParams } from 'react-router-dom';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import ColorGenerator from '../utils/services/ColorGenerator';
import { ColorGenerator } from '../utils/services/ColorGenerator';
import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import { TagVisits as TagVisitsState } from './reducers/tagVisits';
import TagVisitsHeader from './TagVisitsHeader';
import VisitsStats from './VisitsStats';
import { TagVisitsHeader } from './TagVisitsHeader';
import { VisitsStats } from './VisitsStats';
import { NormalizedVisit } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
@ -18,7 +18,7 @@ export interface TagVisitsProps extends CommonVisitsProps {
cancelGetTagVisits: () => void;
}
const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: ReportExporter) => boundToMercureHub(({
export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: ReportExporter) => boundToMercureHub(({
getTagVisits,
tagVisits,
cancelGetTagVisits,
@ -44,5 +44,3 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: ReportExpor
</VisitsStats>
);
}, () => [Topics.visits]);
export default TagVisits;

View file

@ -1,6 +1,6 @@
import Tag from '../tags/helpers/Tag';
import ColorGenerator from '../utils/services/ColorGenerator';
import VisitsHeader from './VisitsHeader';
import { Tag } from '../tags/helpers/Tag';
import { ColorGenerator } from '../utils/services/ColorGenerator';
import { VisitsHeader } from './VisitsHeader';
import { TagVisits } from './reducers/tagVisits';
import './ShortUrlVisitsHeader.scss';
@ -10,9 +10,8 @@ interface TagVisitsHeaderProps {
colorGenerator: ColorGenerator;
}
const TagVisitsHeader = ({ tagVisits, goBack, colorGenerator }: TagVisitsHeaderProps) => {
export const TagVisitsHeader = ({ tagVisits, goBack, colorGenerator }: TagVisitsHeaderProps) => {
const { visits, tag } = tagVisits;
const visitsStatsTitle = (
<span className="d-flex align-items-center justify-content-center">
<span className="me-2">Visits for</span>
@ -22,5 +21,3 @@ const TagVisitsHeader = ({ tagVisits, goBack, colorGenerator }: TagVisitsHeaderP
return <VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} />;
};
export default TagVisitsHeader;

View file

@ -2,7 +2,7 @@ import { Button, Card } from 'reactstrap';
import { FC, PropsWithChildren, ReactNode } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import ShortUrlVisitsCount from '../short-urls/helpers/ShortUrlVisitsCount';
import { ShortUrlVisitsCount } from '../short-urls/helpers/ShortUrlVisitsCount';
import { ShortUrl } from '../short-urls/data';
import { Visit } from './types';
@ -13,7 +13,7 @@ type VisitsHeaderProps = PropsWithChildren<{
shortUrl?: ShortUrl;
}>;
const VisitsHeader: FC<VisitsHeaderProps> = ({ visits, goBack, shortUrl, children, title }) => (
export const VisitsHeader: FC<VisitsHeaderProps> = ({ visits, goBack, shortUrl, children, title }) => (
<header>
<Card body>
<h2 className="d-flex justify-content-between align-items-center mb-0">
@ -36,5 +36,3 @@ const VisitsHeader: FC<VisitsHeaderProps> = ({ visits, goBack, shortUrl, childre
</Card>
</header>
);
export default VisitsHeader;

View file

@ -7,7 +7,7 @@ import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import { Route, Routes, Navigate } from 'react-router-dom';
import classNames from 'classnames';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import Message from '../utils/Message';
import { Message } from '../utils/Message';
import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
@ -17,10 +17,10 @@ import { supportsBotVisits } from '../utils/helpers/features';
import { prettify } from '../utils/helpers/numbers';
import { NavPillItem, NavPills } from '../utils/NavPills';
import { ExportBtn } from '../utils/ExportBtn';
import LineChartCard from './charts/LineChartCard';
import VisitsTable from './VisitsTable';
import { LineChartCard } from './charts/LineChartCard';
import { VisitsTable } from './VisitsTable';
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
import OpenMapModalBtn from './helpers/OpenMapModalBtn';
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
@ -55,7 +55,7 @@ const sections: Record<Section, VisitsNavLinkProps> = {
let selectedBar: string | undefined;
const VisitsStats: FC<VisitsStatsProps> = ({
export const VisitsStats: FC<VisitsStatsProps> = ({
children,
visitsInfo,
getVisits,
@ -139,7 +139,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
}
if (isEmpty(visits)) {
return <Message>There are no visits matching current filter :(</Message>;
return <Message>There are no visits matching current filter</Message>;
}
return (
@ -235,7 +235,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
stats={cities}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
highlightedLabel={highlightedLabel}
extraHeaderContent={(activeCities: string[]) => mapLocations.length > 0 && (
extraHeaderContent={(activeCities) => mapLocations.length > 0 && (
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
)}
sortingItems={{
@ -325,5 +325,3 @@ const VisitsStats: FC<VisitsStatsProps> = ({
</>
);
};
export default VisitsStats;

View file

@ -45,7 +45,7 @@ const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | unde
return { visitsGroups, total };
};
const VisitsTable = ({
export const VisitsTable = ({
visits,
selectedVisits = [],
setSelectedVisits,
@ -222,5 +222,3 @@ const VisitsTable = ({
</div>
);
};
export default VisitsTable;

View file

@ -8,7 +8,7 @@ type ChartCardProps = PropsWithChildren<{
}>;
export const ChartCard: FC<ChartCardProps> = ({ title, footer, children }) => (
<Card>
<Card role="document">
<CardHeader className="chart-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{children}</CardBody>
{footer && <CardFooter className="chart-card__footer--sticky">{footer}</CardFooter>}

View file

@ -26,7 +26,7 @@ import { NormalizedVisit, Stats } from '../types';
import { fillTheGaps } from '../../utils/helpers/visits';
import { useToggle } from '../../utils/helpers/hooks';
import { rangeOf } from '../../utils/utils';
import ToggleSwitch from '../../utils/ToggleSwitch';
import { ToggleSwitch } from '../../utils/ToggleSwitch';
import { prettify } from '../../utils/helpers/numbers';
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme';
@ -166,7 +166,7 @@ const chartElementAtEvent = (
}
};
const LineChartCard = (
export const LineChartCard = (
{ title, visits, highlightedVisits, highlightedLabel = 'Selected', setSelectedVisits }: LineChartCardProps,
) => {
const [step, setStep] = useState<Step>(
@ -235,7 +235,7 @@ const LineChartCard = (
return (
<Card>
<CardHeader>
<CardHeader role="heading">
{title}
<div className="float-end">
<UncontrolledDropdown>
@ -266,5 +266,3 @@ const LineChartCard = (
</Card>
);
};
export default LineChartCard;

View file

@ -1,11 +1,11 @@
import { FC, useState } from 'react';
import { FC, ReactNode, useState } from 'react';
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import { rangeOf } from '../../utils/utils';
import { Order } from '../../utils/helpers/ordering';
import { SimplePaginator } from '../../common/SimplePaginator';
import { roundTen } from '../../utils/helpers/numbers';
import { OrderingDropdown } from '../../utils/OrderingDropdown';
import PaginationDropdown from '../../utils/PaginationDropdown';
import { PaginationDropdown } from '../../utils/PaginationDropdown';
import { Stats, StatsRow } from '../types';
import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart';
import { ChartCard } from './ChartCard';
@ -14,7 +14,7 @@ interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'>
title: Function | string;
sortingItems: Record<string, string>;
withPagination?: boolean;
extraHeaderContent?: Function;
extraHeaderContent?: (activeCities?: string[]) => ReactNode;
}
const toLowerIfString = (value: any) => (type(value) === 'String' ? toLower(value) : value);

View file

@ -29,18 +29,18 @@ const calculateMapProps = (locations: CityStats[]): MapContainerProps => {
}
// When there's only one location, an error is thrown if trying to calculate the bounds.
// When that happens, we use zoom and center as a workaround
// When that happens, we use "zoom" and "center" as a workaround
const [{ latLong: center }] = locations;
return { zoom: 10, center };
};
const MapModal = ({ toggle, isOpen, title, locations = [] }: MapModalProps) => (
export const MapModal = ({ toggle, isOpen, title, locations = [] }: MapModalProps) => (
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
<ModalBody className="map-modal__modal-body">
<h3 className="map-modal__modal-title">
{title}
<button type="button" className="btn-close float-end" onClick={toggle} />
<button type="button" className="btn-close float-end" aria-label="Close" onClick={toggle} />
</h3>
<MapContainer {...calculateMapProps(locations)}>
<OpenStreetMapTile />
@ -53,5 +53,3 @@ const MapModal = ({ toggle, isOpen, title, locations = [] }: MapModalProps) => (
</ModalBody>
</Modal>
);
export default MapModal;

View file

@ -4,22 +4,24 @@ import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
import { Button, Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
import { useDomId, useToggle } from '../../utils/helpers/hooks';
import { CityStats } from '../types';
import MapModal from './MapModal';
import { MapModal } from './MapModal';
import './OpenMapModalBtn.scss';
interface OpenMapModalBtnProps {
modalTitle: string;
activeCities: string[];
activeCities?: string[];
locations?: CityStats[];
}
const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: OpenMapModalBtnProps) => {
export const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: OpenMapModalBtnProps) => {
const [mapIsOpened, , openMap, closeMap] = useToggle();
const [dropdownIsOpened, toggleDropdown, openDropdown] = useToggle();
const [locationsToShow, setLocationsToShow] = useState<CityStats[]>([]);
const id = useDomId();
const filterLocations = (cities: CityStats[]) => cities.filter(({ cityName }) => activeCities.includes(cityName));
const filterLocations = (cities: CityStats[]) => (
!activeCities ? cities : cities.filter(({ cityName }) => activeCities?.includes(cityName))
);
const onClick = () => {
if (!activeCities) {
setLocationsToShow(locations);
@ -51,5 +53,3 @@ const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: OpenMapMo
</>
);
};
export default OpenMapModalBtn;

View file

@ -1,8 +1,8 @@
import Bottle from 'bottlejs';
import MapModal from '../helpers/MapModal';
import { MapModal } from '../helpers/MapModal';
import { createNewVisits } from '../reducers/visitCreation';
import ShortUrlVisits from '../ShortUrlVisits';
import TagVisits from '../TagVisits';
import { ShortUrlVisits } from '../ShortUrlVisits';
import { TagVisits } from '../TagVisits';
import { OrphanVisits } from '../OrphanVisits';
import { NonOrphanVisits } from '../NonOrphanVisits';
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';

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