Merge pull request #853 from acelaya-forks/feature/shlink-web-component

Split reusable content into separated components
This commit is contained in:
Alejandro Celaya 2023-08-07 23:41:40 +02:00 committed by GitHub
commit a6134c6b42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
429 changed files with 2933 additions and 2593 deletions

View file

@ -7,8 +7,8 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"lint": "npm run lint:css && npm run lint:js", "lint": "npm run lint:css && npm run lint:js",
"lint:css": "stylelint src/*.scss src/**/*.scss", "lint:css": "stylelint src/*.scss src/**/*.scss shlink-web-component/*.scss shlink-web-component/**/*.scss shlink-frontend-kit/*.scss shlink-frontend-kit/**/*.scss",
"lint:js": "eslint --ext .js,.ts,.tsx src test", "lint:js": "eslint --ext .js,.ts,.tsx src shlink-web-component shlink-frontend-kit test",
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix", "lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix", "lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix", "lint:js:fix": "npm run lint:js -- --fix",

View file

@ -2,10 +2,10 @@ import type { ReactNode } from 'react';
import type { CardProps } from 'reactstrap'; import type { CardProps } from 'reactstrap';
import { Card, CardBody, CardHeader } from 'reactstrap'; import { Card, CardBody, CardHeader } from 'reactstrap';
interface SimpleCardProps extends Omit<CardProps, 'title'> { export type SimpleCardProps = Omit<CardProps, 'title'> & {
title?: ReactNode; title?: ReactNode;
bodyClassName?: string; bodyClassName?: string;
} };
export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => ( export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => (
<Card {...rest}> <Card {...rest}>

View file

@ -0,0 +1,3 @@
export * from './Message';
export * from './Result';
export * from './SimpleCard';

View file

@ -1,7 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { identity } from 'ramda'; import { identity } from 'ramda';
import type { ChangeEvent, FC, PropsWithChildren } from 'react'; import type { ChangeEvent, FC, PropsWithChildren } from 'react';
import { useDomId } from './helpers/hooks'; import { useDomId } from '../hooks';
export type BooleanControlProps = PropsWithChildren<{ export type BooleanControlProps = PropsWithChildren<{
checked?: boolean; checked?: boolean;
@ -10,9 +10,9 @@ export type BooleanControlProps = PropsWithChildren<{
inline?: boolean; inline?: boolean;
}>; }>;
interface BooleanControlWithTypeProps extends BooleanControlProps { type BooleanControlWithTypeProps = BooleanControlProps & {
type: 'switch' | 'checkbox'; type: 'switch' | 'checkbox';
} };
export const BooleanControl: FC<BooleanControlWithTypeProps> = ( export const BooleanControl: FC<BooleanControlWithTypeProps> = (
{ checked = false, onChange = identity, className, children, type, inline = false }, { checked = false, onChange = identity, className, children, type, inline = false },

View file

@ -1,6 +1,6 @@
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import type { InputType } from 'reactstrap/types/lib/Input'; import type { InputType } from 'reactstrap/types/lib/Input';
import { useDomId } from '../helpers/hooks'; import { useDomId } from '../hooks';
import { LabeledFormGroup } from './LabeledFormGroup'; import { LabeledFormGroup } from './LabeledFormGroup';
export type InputFormGroupProps = PropsWithChildren<{ export type InputFormGroupProps = PropsWithChildren<{

View file

@ -1,4 +1,4 @@
@import '../utils/mixins/vertical-align'; @import '../../../shlink-web-component/src/utils/mixins/vertical-align';
.search-field { .search-field {
position: relative; position: relative;

View file

@ -7,13 +7,13 @@ import './SearchField.scss';
const DEFAULT_SEARCH_INTERVAL = 500; const DEFAULT_SEARCH_INTERVAL = 500;
let timer: NodeJS.Timeout | null; let timer: NodeJS.Timeout | null;
interface SearchFieldProps { type SearchFieldProps = {
onChange: (value: string) => void; onChange: (value: string) => void;
className?: string; className?: string;
large?: boolean; large?: boolean;
noBorder?: boolean; noBorder?: boolean;
initialValue?: string; initialValue?: string;
} };
export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => { export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
const [searchTerm, setSearchTerm] = useState(initialValue); const [searchTerm, setSearchTerm] = useState(initialValue);

View file

@ -0,0 +1,5 @@
export * from './Checkbox';
export * from './ToggleSwitch';
export * from './InputFormGroup';
export * from './LabeledFormGroup';
export * from './SearchField';

View file

@ -0,0 +1,16 @@
import { useRef, useState } from 'react';
import { v4 as uuid } from 'uuid';
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)];
};
export const useDomId = (): string => {
const { current: id } = useRef(`dom-${uuid()}`);
return id;
};
export const useElementRef = <T>() => useRef<T | null>(null);

View file

@ -0,0 +1,219 @@
@import './utils/ResponsiveTable';
@import './theme/theme';
/* stylelint-disable no-descending-specificity */
a,
.btn-link {
text-decoration: none;
}
/* stylelint-disable-next-line selector-max-pseudo-class */
a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.btn):not(.dropdown-item):hover,
.btn-link:hover {
text-decoration: underline;
}
.bg-main {
background-color: $mainColor !important;
}
.bg-warning {
color: $lightTextColor;
}
.card-body,
.card-header,
.list-group-item {
background-color: transparent;
}
.card-footer {
background-color: var(--primary-color-alfa);
}
.card {
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
background-color: var(--primary-color);
border-color: var(--border-color);
}
.list-group {
background-color: var(--primary-color);
}
.modal-content,
.page-link,
.page-item.disabled .page-link,
.dropdown-menu {
background-color: var(--primary-color);
}
.modal-header,
.modal-footer,
.card-header,
.card-footer,
.table thead th,
.table th,
.table td,
.page-link,
.page-link:hover,
.page-item.disabled .page-link,
.dropdown-divider,
.dropdown-menu,
.list-group-item,
.modal-content,
hr {
border-color: var(--border-color);
}
.table-bordered,
.table-bordered thead th,
.table-bordered thead td {
border-color: var(--table-border-color);
}
.page-link:hover,
.page-link:focus {
background-color: var(--secondary-color);
}
.page-item.active .page-link {
background-color: var(--brand-color);
border-color: var(--brand-color);
}
.pagination .page-link {
cursor: pointer;
}
.container-xl {
@media (min-width: $xlgMin) {
max-width: 1320px;
}
@media (max-width: $smMax) {
padding-right: 0;
padding-left: 0;
}
}
/* Deprecated. Brought from bootstrap 4 */
.btn-block {
display: block;
width: 100%;
}
.btn-primary,
.btn-primary:hover,
.btn-primary:active,
.btn-primary.active,
.btn-outline-primary:hover,
.btn-outline-primary:active,
.btn-outline-primary.active, {
color: #ffffff;
}
.dropdown-item,
.dropdown-item-text {
color: var(--text-color);
}
.dropdown-item:not(:disabled) {
cursor: pointer;
}
.dropdown-item:focus:not(:disabled),
.dropdown-item:hover:not(:disabled),
.dropdown-item.active:not(:disabled),
.dropdown-item:active:not(:disabled) {
background-color: var(--active-color) !important;
color: var(--text-color) !important;
}
.dropdown-item--danger.dropdown-item--danger {
color: $dangerColor;
&:hover,
&:active,
&.active {
color: $dangerColor !important;
}
}
.badge-main {
color: #ffffff;
background-color: var(--brand-color);
}
.close,
.close:hover,
.table,
.table-hover > tbody > tr:hover > *,
.table-hover > tbody > tr > * {
color: var(--text-color);
}
.btn-close {
filter: var(--btn-close-filter);
}
.table-hover tbody tr:hover {
background-color: var(--secondary-color);
}
.form-control,
.form-control:focus {
background-color: var(--primary-color);
border-color: var(--input-border-color);
color: var(--input-text-color);
}
.form-control.disabled,
.form-control:disabled {
background-color: var(--input-disabled-color);
cursor: not-allowed;
}
.card .form-control:not(:disabled),
.card .form-control:not(:disabled):hover {
background-color: var(--input-color);
}
.table-active,
.table-active > th,
.table-active > td {
background-color: var(--table-highlight-color) !important;
}
.navbar-brand {
@media (max-width: $smMax) {
margin: 0 auto !important;
}
}
.indivisible {
white-space: nowrap;
}
.pointer {
cursor: pointer;
}
.progress-bar {
background-color: $mainColor;
}
.btn-xs-block {
@media (max-width: $xsMax) {
width: 100%;
display: block;
}
}
.btn-md-block {
@media (max-width: $mdMax) {
width: 100%;
display: block;
}
}

View file

@ -0,0 +1,7 @@
export * from './block';
export * from './form';
export * from './hooks';
export * from './navigation';
export * from './ordering';
export * from './theme';
export * from './utils';

View file

@ -1,6 +1,6 @@
/* stylelint-disable no-descending-specificity */ /* stylelint-disable no-descending-specificity */
@import '../utils/mixins/vertical-align'; @import '../../../shlink-web-component/src/utils/mixins/vertical-align';
.dropdown-btn__toggle.dropdown-btn__toggle { .dropdown-btn__toggle.dropdown-btn__toggle {
text-align: left; text-align: left;

View file

@ -2,7 +2,7 @@ import classNames from 'classnames';
import type { FC, PropsWithChildren, ReactNode } from 'react'; import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap'; import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
import type { DropdownToggleProps } from 'reactstrap/types/lib/DropdownToggle'; import type { DropdownToggleProps } from 'reactstrap/types/lib/DropdownToggle';
import { useToggle } from './helpers/hooks'; import { useToggle } from '../hooks';
import './DropdownBtn.scss'; import './DropdownBtn.scss';
export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & { export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & {

View file

@ -1,4 +1,4 @@
@import './base'; @import '../base';
.nav-pills__nav { .nav-pills__nav {
position: sticky !important; position: sticky !important;

View file

@ -0,0 +1,3 @@
export * from './DropdownBtn';
export * from './RowDropdownBtn';
export * from './NavPills';

View file

@ -3,18 +3,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames'; import classNames from 'classnames';
import { toPairs } from 'ramda'; import { toPairs } from 'ramda';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap'; import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { Order, OrderDir } from './helpers/ordering'; import type { Order, OrderDir } from './ordering';
import { determineOrderDir } from './helpers/ordering'; import { determineOrderDir } from './ordering';
import './OrderingDropdown.scss'; import './OrderingDropdown.scss';
export interface OrderingDropdownProps<T extends string = string> { export type OrderingDropdownProps<T extends string = string> = {
items: Record<T, string>; items: Record<T, string>;
order: Order<T>; order: Order<T>;
onChange: (orderField?: T, orderDir?: OrderDir) => void; onChange: (orderField?: T, orderDir?: OrderDir) => void;
isButton?: boolean; isButton?: boolean;
right?: boolean; right?: boolean;
prefixed?: boolean; prefixed?: boolean;
} };
export function OrderingDropdown<T extends string = string>( export function OrderingDropdown<T extends string = string>(
{ items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>, { items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>,

View file

@ -0,0 +1,2 @@
export * from './ordering';
export * from './OrderingDropdown';

View file

@ -1,9 +1,9 @@
export type OrderDir = 'ASC' | 'DESC' | undefined; export type OrderDir = 'ASC' | 'DESC' | undefined;
export interface Order<Fields> { export type Order<Fields> = {
field?: Fields; field?: Fields;
dir?: OrderDir; dir?: OrderDir;
} };
export const determineOrderDir = <T extends string = string>( export const determineOrderDir = <T extends string = string>(
currentField: T, currentField: T,
@ -22,7 +22,7 @@ export const determineOrderDir = <T extends string = string>(
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC'; return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
}; };
export const sortList = <List>(list: List[], { field, dir }: Order<Partial<keyof List>>) => ( export const sortList = <List>(list: List[], { field, dir }: Order<keyof List>) => (
!field || !dir ? list : list.sort((a, b) => { !field || !dir ? list : list.sort((a, b) => {
const greaterThan = dir === 'ASC' ? 1 : -1; const greaterThan = dir === 'ASC' ? 1 : -1;
const smallerThan = dir === 'ASC' ? -1 : 1; const smallerThan = dir === 'ASC' ? -1 : 1;

View file

@ -12,8 +12,6 @@ export const PRIMARY_DARK_COLOR = '#161b22';
export type Theme = 'dark' | 'light'; export type Theme = 'dark' | 'light';
export const changeThemeInMarkup = (theme: Theme) => export const changeThemeInMarkup = (theme: Theme) => document.querySelector('html')?.setAttribute('data-theme', theme);
document.getElementsByTagName('html')?.[0]?.setAttribute('data-theme', theme);
export const isDarkThemeEnabled = (): boolean => export const isDarkThemeEnabled = (): boolean => document.querySelector('html')?.getAttribute('data-theme') === 'dark';
document.getElementsByTagName('html')?.[0]?.getAttribute('data-theme') === 'dark';

View file

@ -1,4 +1,4 @@
@import '../../utils/base'; @import '../base';
.responsive-table__header { .responsive-table__header {
@media (max-width: $responsiveTableBreakpoint) { @media (max-width: $responsiveTableBreakpoint) {

View file

@ -1,5 +1,7 @@
import qs from 'qs'; import qs from 'qs';
// FIXME Use URLSearchParams instead of qs package
export const parseQuery = <T>(search: string) => qs.parse(search, { ignoreQueryPrefix: true }) as unknown as T; export const parseQuery = <T>(search: string) => qs.parse(search, { ignoreQueryPrefix: true }) as unknown as T;
export const stringifyQuery = (query: any): string => qs.stringify(query, { arrayFormat: 'brackets' }); export const stringifyQuery = (query: any): string => qs.stringify(query, { arrayFormat: 'brackets' });

View file

@ -0,0 +1,8 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
export const renderWithEvents = (element: ReactElement) => ({
user: userEvent.setup(),
...render(element),
});

View file

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import type { MessageProps } from '../../src/utils/Message'; import type { MessageProps } from '../../src';
import { Message } from '../../src/utils/Message'; import { Message } from '../../src';
describe('<Message />', () => { describe('<Message />', () => {
const setUp = (props: PropsWithChildren<MessageProps> = {}) => render(<Message {...props} />); const setUp = (props: PropsWithChildren<MessageProps> = {}) => render(<Message {...props} />);

View file

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import type { ResultProps, ResultType } from '../../src/utils/Result'; import type { ResultProps, ResultType } from '../../src';
import { Result } from '../../src/utils/Result'; import { Result } from '../../src';
describe('<Result />', () => { describe('<Result />', () => {
const setUp = (props: ResultProps) => render(<Result {...props} />); const setUp = (props: ResultProps) => render(<Result {...props} />);

View file

@ -1,24 +1,27 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { SimpleCard } from '../../src/utils/SimpleCard'; import type { SimpleCardProps } from '../../src';
import { SimpleCard } from '../../src';
const setUp = ({ children, ...rest }: SimpleCardProps = {}) => render(<SimpleCard {...rest}>{children}</SimpleCard>);
describe('<SimpleCard />', () => { describe('<SimpleCard />', () => {
it('does not render title if not provided', () => { it('does not render title if not provided', () => {
render(<SimpleCard />); setUp();
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
}); });
it('renders provided title', () => { it('renders provided title', () => {
render(<SimpleCard title="Cool title" />); setUp({ title: 'Cool title' });
expect(screen.getByRole('heading')).toHaveTextContent('Cool title'); expect(screen.getByRole('heading')).toHaveTextContent('Cool title');
}); });
it('renders children inside body', () => { it('renders children inside body', () => {
render(<SimpleCard>Hello world</SimpleCard>); setUp({ children: 'Hello world' });
expect(screen.getByText('Hello world')).toBeInTheDocument(); expect(screen.getByText('Hello world')).toBeInTheDocument();
}); });
it.each(['primary', 'danger', 'warning'])('passes extra props to nested card', (color) => { it.each(['primary', 'danger', 'warning'])('passes extra props to nested card', (color) => {
const { container } = render(<SimpleCard className="foo" color={color}>Hello world</SimpleCard>); const { container } = setUp({ className: 'foo', color, children: 'Hello world' });
expect(container.firstChild).toHaveAttribute('class', `foo card bg-${color}`); expect(container.firstChild).toHaveAttribute('class', `foo card bg-${color}`);
}); });
}); });

View file

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { Checkbox } from '../../src/utils/Checkbox'; import { Checkbox } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<Checkbox />', () => { describe('<Checkbox />', () => {

View file

@ -1,7 +1,7 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import type { DropdownBtnProps } from '../../src/utils/DropdownBtn'; import type { DropdownBtnProps } from '../../src';
import { DropdownBtn } from '../../src/utils/DropdownBtn'; import { DropdownBtn } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<DropdownBtn />', () => { describe('<DropdownBtn />', () => {

View file

@ -1,7 +1,7 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { NavPillItem, NavPills } from '../../src/utils/NavPills'; import { NavPillItem, NavPills } from '../../src';
describe('<NavPills />', () => { describe('<NavPills />', () => {
let originalError: typeof console.error; let originalError: typeof console.error;

View file

@ -1,7 +1,7 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import type { DropdownBtnMenuProps } from '../../src/utils/RowDropdownBtn'; import type { DropdownBtnMenuProps } from '../../src';
import { RowDropdownBtn } from '../../src/utils/RowDropdownBtn'; import { RowDropdownBtn } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<RowDropdownBtn />', () => { describe('<RowDropdownBtn />', () => {

View file

@ -1,8 +1,7 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { values } from 'ramda'; import { values } from 'ramda';
import type { OrderDir } from '../../src/utils/helpers/ordering'; import type { OrderDir, OrderingDropdownProps } from '../../src';
import type { OrderingDropdownProps } from '../../src/utils/OrderingDropdown'; import { OrderingDropdown } from '../../src';
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<OrderingDropdown />', () => { describe('<OrderingDropdown />', () => {

View file

@ -1,5 +1,5 @@
import type { OrderDir } from '../../../src/utils/helpers/ordering'; import type { OrderDir } from '../../src';
import { determineOrderDir, orderToString, stringToOrder } from '../../../src/utils/helpers/ordering'; import { determineOrderDir, orderToString, stringToOrder } from '../../src';
describe('ordering', () => { describe('ordering', () => {
describe('determineOrderDir', () => { describe('determineOrderDir', () => {

View file

@ -1,4 +1,4 @@
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query'; import { parseQuery, stringifyQuery } from '../../src/utils';
describe('query', () => { describe('query', () => {
describe('parseQuery', () => { describe('parseQuery', () => {

View file

@ -1,14 +1,14 @@
@import '../utils/base'; @import '@shlinkio/shlink-frontend-kit/base';
.menu-layout__swipeable { .shlink-layout__swipeable {
height: 100%; height: 100%;
} }
.menu-layout__swipeable-inner { .shlink-layout__swipeable-inner {
height: 100%; height: 100%;
} }
.menu-layout__burger-icon { .shlink-layout__burger-icon {
display: none; display: none;
transition: color 300ms; transition: color 300ms;
position: fixed; position: fixed;
@ -23,11 +23,11 @@
} }
} }
.menu-layout__burger-icon--active { .shlink-layout__burger-icon--active {
color: white; color: white;
} }
.menu-layout__container.menu-layout__container { .shlink-layout__container.shlink-layout__container {
padding: 20px 0 0; padding: 20px 0 0;
min-height: 100%; min-height: 100%;

View file

@ -0,0 +1,79 @@
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import classNames from 'classnames';
import type { FC, ReactNode } from 'react';
import { Fragment, useEffect, useMemo } from 'react';
import { BrowserRouter, Navigate, Route, Routes, useInRouterContext, useLocation } from 'react-router-dom';
import { AsideMenu } from './common/AsideMenu';
import { useFeature } from './utils/features';
import { useSwipeable } from './utils/helpers/hooks';
import { useRoutesPrefix } from './utils/routesPrefix';
import './Main.scss';
export type MainProps = {
createNotFound?: (nonPrefixedHomePath: string) => ReactNode;
};
export const Main = (
TagsList: FC,
ShortUrlsList: FC,
CreateShortUrl: FC,
ShortUrlVisits: FC,
TagVisits: FC,
DomainVisits: FC,
OrphanVisits: FC,
NonOrphanVisits: FC,
Overview: FC,
EditShortUrl: FC,
ManageDomains: FC,
): FC<MainProps> => ({ createNotFound }) => {
const location = useLocation();
const routesPrefix = useRoutesPrefix();
const inRouterContext = useInRouterContext();
const [Wrapper, props] = useMemo(() => (
inRouterContext
? [Fragment, {}]
: [BrowserRouter, { basename: routesPrefix }]
), [inRouterContext]);
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
useEffect(() => hideSidebar(), [location]);
const addDomainVisitsRoute = useFeature('domainVisits');
const burgerClasses = classNames('shlink-layout__burger-icon', { 'shlink-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
// FIXME Check if this works when not currently wrapped in a router
return (
<Wrapper {...props}>
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
<div {...swipeableProps} className="shlink-layout__swipeable">
<div className="shlink-layout__swipeable-inner">
<AsideMenu routePrefix={routesPrefix} showOnMobile={sidebarVisible} />
<div className="shlink-layout__container" onClick={() => hideSidebar()}>
<div className="container-xl">
<Routes>
<Route index element={<Navigate replace to="overview" />} />
<Route path="/overview" element={<Overview />} />
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
<Route path="/create-short-url" element={<CreateShortUrl />} />
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
<Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />
<Route path="/manage-tags" element={<TagsList />} />
<Route path="/manage-domains" element={<ManageDomains />} />
{createNotFound && <Route path="*" element={createNotFound('/list-short-urls/1')} />}
</Routes>
</div>
</div>
</div>
</div>
</Wrapper>
);
};

View file

@ -0,0 +1,67 @@
import type { Store } from '@reduxjs/toolkit';
import type Bottle from 'bottlejs';
import type { FC, ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import type { ShlinkApiClient } from './api-contract';
import { FeaturesProvider, useFeatures } from './utils/features';
import type { SemVer } from './utils/helpers/version';
import { RoutesPrefixProvider } from './utils/routesPrefix';
import type { TagColorsStorage } from './utils/services/TagColorsStorage';
import type { Settings } from './utils/settings';
import { SettingsProvider } from './utils/settings';
type ShlinkWebComponentProps = {
serverVersion: SemVer; // FIXME Consider making this optional and trying to resolve it if not set
apiClient: ShlinkApiClient;
tagColorsStorage?: TagColorsStorage;
routesPrefix?: string;
settings?: Settings;
createNotFound?: (nonPrefixedHomePath: string) => ReactNode;
};
// FIXME This allows to track the reference to be resolved by the container, but it's hacky and relies on not more than
// one ShlinkWebComponent rendered at the same time.
// Works for now, but should be addressed.
let apiClientRef: ShlinkApiClient;
export const createShlinkWebComponent = (
bottle: Bottle,
): FC<ShlinkWebComponentProps> => (
{ serverVersion, apiClient, settings, routesPrefix = '', createNotFound, tagColorsStorage },
) => {
const features = useFeatures(serverVersion);
const mainContent = useRef<ReactNode>();
const [theStore, setStore] = useState<Store | undefined>();
useEffect(() => {
apiClientRef = apiClient;
bottle.value('apiClientFactory', () => apiClientRef);
if (tagColorsStorage) {
bottle.value('TagColorsStorage', tagColorsStorage);
}
// It's important to not try to resolve services before the API client has been registered, as many other services
// depend on it
const { container } = bottle;
const { Main, store, loadMercureInfo } = container;
mainContent.current = <Main createNotFound={createNotFound} />;
setStore(store);
// Load mercure info
store.dispatch(loadMercureInfo(settings));
}, [apiClient, tagColorsStorage]);
return !theStore ? <></> : (
<ReduxStoreProvider store={theStore}>
<SettingsProvider value={settings}>
<FeaturesProvider value={features}>
<RoutesPrefixProvider value={routesPrefix}>
{mainContent.current}
</RoutesPrefixProvider>
</FeaturesProvider>
</SettingsProvider>
</ReduxStoreProvider>
);
};

View file

@ -0,0 +1,63 @@
import type {
ShlinkCreateShortUrlData,
ShlinkDomainRedirects,
ShlinkDomainsResponse,
ShlinkEditDomainRedirects,
ShlinkEditShortUrlData,
ShlinkHealth,
ShlinkMercureInfo,
ShlinkShortUrl,
ShlinkShortUrlsListParams,
ShlinkShortUrlsResponse,
ShlinkTags,
ShlinkVisits,
ShlinkVisitsOverview,
ShlinkVisitsParams,
} from './types';
export type ShlinkApiClient = {
readonly baseUrl: string;
readonly apiKey: string;
listShortUrls(params?: ShlinkShortUrlsListParams): Promise<ShlinkShortUrlsResponse>;
createShortUrl(options: ShlinkCreateShortUrlData): Promise<ShlinkShortUrl>;
getShortUrlVisits(shortCode: string, query?: ShlinkVisitsParams): Promise<ShlinkVisits>;
getTagVisits(tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
getDomainVisits(domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
getOrphanVisits(query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
getNonOrphanVisits(query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits>;
getVisitsOverview(): Promise<ShlinkVisitsOverview>;
getShortUrl(shortCode: string, domain?: string | null): Promise<ShlinkShortUrl>;
deleteShortUrl(shortCode: string, domain?: string | null): Promise<void>;
updateShortUrl(
shortCode: string,
domain: string | null | undefined,
body: ShlinkEditShortUrlData,
): Promise<ShlinkShortUrl>;
listTags(): Promise<ShlinkTags>;
tagsStats(): Promise<ShlinkTags>;
deleteTags(tags: string[]): Promise<{ tags: string[] }>;
editTag(oldName: string, newName: string): Promise<{ oldName: string; newName: string }>;
health(authority?: string): Promise<ShlinkHealth>;
mercureInfo(): Promise<ShlinkMercureInfo>;
listDomains(): Promise<ShlinkDomainsResponse>;
editDomainRedirects(domainRedirects: ShlinkEditDomainRedirects): Promise<ShlinkDomainRedirects>;
};

View file

@ -0,0 +1,3 @@
export * from './errors';
export * from './ShlinkApiClient';
export * from './types';

View file

@ -1,10 +1,66 @@
import type { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; import type { Order } from '@shlinkio/shlink-frontend-kit';
import type { Order } from '../../utils/helpers/ordering'; import type { Nullable, OptionalString } from '../utils/helpers';
import type { OptionalString } from '../../utils/utils'; import type { Visit } from '../visits/types';
import type { Visit } from '../../visits/types';
export interface ShlinkDeviceLongUrls {
android?: OptionalString;
ios?: OptionalString;
desktop?: OptionalString;
}
export interface ShlinkShortUrlMeta {
validSince?: string;
validUntil?: string;
maxVisits?: number;
}
export interface ShlinkShortUrl {
shortCode: string;
shortUrl: string;
longUrl: string;
deviceLongUrls?: Required<ShlinkDeviceLongUrls>, // Optional only before Shlink 3.5.0
dateCreated: string;
/** @deprecated */
visitsCount: number; // Deprecated since Shlink 3.4.0
visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.4.0
meta: Required<Nullable<ShlinkShortUrlMeta>>;
tags: string[];
domain: string | null;
title?: string | null;
crawlable?: boolean;
forwardQuery?: boolean;
}
export interface ShlinkEditShortUrlData {
longUrl?: string;
title?: string | null;
tags?: string[];
deviceLongUrls?: ShlinkDeviceLongUrls;
crawlable?: boolean;
forwardQuery?: boolean;
validSince?: string | null;
validUntil?: string | null;
maxVisits?: number | null;
/** @deprecated */
validateUrl?: boolean;
}
export interface ShlinkCreateShortUrlData extends Omit<ShlinkEditShortUrlData, 'deviceLongUrls'> {
longUrl: string;
customSlug?: string;
shortCodeLength?: number;
domain?: string;
findIfExists?: boolean;
deviceLongUrls?: {
android?: string;
ios?: string;
desktop?: string;
}
}
export interface ShlinkShortUrlsResponse { export interface ShlinkShortUrlsResponse {
data: ShortUrl[]; data: ShlinkShortUrl[];
pagination: ShlinkPaginator; pagination: ShlinkPaginator;
} }
@ -70,7 +126,7 @@ export interface ShlinkVisitsOverview {
} }
export interface ShlinkVisitsParams { export interface ShlinkVisitsParams {
domain?: OptionalString; domain?: string | null;
page?: number; page?: number;
itemsPerPage?: number; itemsPerPage?: number;
startDate?: string; startDate?: string;
@ -78,13 +134,6 @@ export interface ShlinkVisitsParams {
excludeBots?: boolean; excludeBots?: boolean;
} }
export interface ShlinkShortUrlData extends ShortUrlMeta {
longUrl?: string;
title?: string;
validateUrl?: boolean;
tags?: string[];
}
export interface ShlinkDomainRedirects { export interface ShlinkDomainRedirects {
baseUrlRedirect: string | null; baseUrlRedirect: string | null;
regular404Redirect: string | null; regular404Redirect: string | null;
@ -98,12 +147,12 @@ export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects
export interface ShlinkDomain { export interface ShlinkDomain {
domain: string; domain: string;
isDefault: boolean; isDefault: boolean;
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8 redirects: ShlinkDomainRedirects;
} }
export interface ShlinkDomainsResponse { export interface ShlinkDomainsResponse {
data: ShlinkDomain[]; data: ShlinkDomain[];
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10 defaultRedirects: ShlinkDomainRedirects;
} }
export type TagsFilteringMode = 'all' | 'any'; export type TagsFilteringMode = 'all' | 'any';

View file

@ -2,16 +2,11 @@ import type {
InvalidArgumentError, InvalidArgumentError,
InvalidShortUrlDeletion, InvalidShortUrlDeletion,
ProblemDetailsError, ProblemDetailsError,
RegularNotFound } from '../types/errors'; RegularNotFound } from './errors';
import { import {
ErrorTypeV2, ErrorTypeV2,
ErrorTypeV3, ErrorTypeV3,
} from '../types/errors'; } from './errors';
const isProblemDetails = (e: unknown): e is ProblemDetailsError =>
!!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e);
export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined);
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError => export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT; error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT;
@ -23,3 +18,8 @@ export const isInvalidDeletionError = (error?: ProblemDetailsError): error is In
export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound => export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound =>
(error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404; (error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404;
const isProblemDetails = (e: unknown): e is ProblemDetailsError =>
!!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e);
export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined);

View file

@ -1,4 +1,4 @@
@import '../utils/base'; @import '@shlinkio/shlink-frontend-kit/base';
@import '../utils/mixins/vertical-align'; @import '../utils/mixins/vertical-align';
.aside-menu { .aside-menu {
@ -58,24 +58,6 @@
background-color: var(--brand-color); background-color: var(--brand-color);
} }
.aside-menu__item--divider {
border-bottom: 1px solid #eeeeee;
margin: 20px 0;
}
.aside-menu__item--danger {
color: $dangerColor;
}
.aside-menu__item--push {
margin-top: auto;
}
.aside-menu__item--danger:hover {
color: #ffffff;
background-color: $dangerColor;
}
.aside-menu__item-text { .aside-menu__item-text {
margin-left: 8px; margin-left: 8px;
} }

View file

@ -3,7 +3,6 @@ import {
faHome as overviewIcon, faHome as overviewIcon,
faLink as createIcon, faLink as createIcon,
faList as listIcon, faList as listIcon,
faPen as editIcon,
faTags as tagsIcon, faTags as tagsIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -11,13 +10,10 @@ import classNames from 'classnames';
import type { FC } from 'react'; import type { FC } from 'react';
import type { NavLinkProps } from 'react-router-dom'; import type { NavLinkProps } from 'react-router-dom';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import type { SelectedServer } from '../servers/data';
import { isServerWithId } from '../servers/data';
import type { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import './AsideMenu.scss'; import './AsideMenu.scss';
export interface AsideMenuProps { export interface AsideMenuProps {
selectedServer: SelectedServer; routePrefix: string;
showOnMobile?: boolean; showOnMobile?: boolean;
} }
@ -36,16 +32,12 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
</NavLink> </NavLink>
); );
export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => ( export const AsideMenu: FC<AsideMenuProps> = ({ routePrefix, showOnMobile = false }) => {
{ selectedServer, showOnMobile = false }: AsideMenuProps,
) => {
const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : '';
const { pathname } = useLocation(); const { pathname } = useLocation();
const asideClass = classNames('aside-menu', { const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile, 'aside-menu--hidden': !showOnMobile,
}); });
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`; const buildPath = (suffix: string) => `${routePrefix}${suffix}`;
return ( return (
<aside className={asideClass}> <aside className={asideClass}>
@ -73,17 +65,6 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={domainsIcon} /> <FontAwesomeIcon fixedWidth icon={domainsIcon} />
<span className="aside-menu__item-text">Manage domains</span> <span className="aside-menu__item-text">Manage domains</span>
</AsideMenuItem> </AsideMenuItem>
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<FontAwesomeIcon fixedWidth icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span>
</AsideMenuItem>
{hasId && (
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text"
server={selectedServer}
/>
)}
</nav> </nav>
</aside> </aside>
); );

View file

@ -1,5 +1,5 @@
import type { ProblemDetailsError } from './types/errors'; import type { ProblemDetailsError } from '../api-contract';
import { isInvalidArgumentError } from './utils'; import { isInvalidArgumentError } from '../api-contract/utils';
export interface ShlinkApiErrorProps { export interface ShlinkApiErrorProps {
errorData?: ProblemDetailsError; errorData?: ProblemDetailsError;

View file

@ -0,0 +1,42 @@
import type { IContainer } from 'bottlejs';
import Bottle from 'bottlejs';
import { pick } from 'ramda';
import { connect as reduxConnect } from 'react-redux';
import { provideServices as provideDomainsServices } from '../domains/services/provideServices';
import { provideServices as provideMercureServices } from '../mercure/services/provideServices';
import { provideServices as provideOverviewServices } from '../overview/services/provideServices';
import { provideServices as provideShortUrlsServices } from '../short-urls/services/provideServices';
import { provideServices as provideTagsServices } from '../tags/services/provideServices';
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
import { provideServices as provideVisitsServices } from '../visits/services/provideServices';
import { provideServices as provideWebComponentServices } from './provideServices';
type LazyActionMap = Record<string, Function>;
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
export const bottle = new Bottle();
export const { container } = bottle;
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: lazyService(container, actionName),
});
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
reduxConnect(
propsFromState ? pick(propsFromState) : null,
actionServiceNames.reduce(mapActionService, {}),
);
provideWebComponentServices(bottle);
provideShortUrlsServices(bottle, connect);
provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect);
provideMercureServices(bottle);
provideDomainsServices(bottle, connect);
provideOverviewServices(bottle, connect);
provideUtilsServices(bottle);

View file

@ -0,0 +1,23 @@
import type Bottle from 'bottlejs';
import { Main } from '../Main';
import { setUpStore } from './store';
export const provideServices = (bottle: Bottle) => {
bottle.serviceFactory(
'Main',
Main,
'TagsList',
'ShortUrlsList',
'CreateShortUrl',
'ShortUrlVisits',
'TagVisits',
'DomainVisits',
'OrphanVisits',
'NonOrphanVisits',
'Overview',
'EditShortUrl',
'ManageDomains',
);
bottle.factory('store', setUpStore);
};

View file

@ -0,0 +1,65 @@
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import type { IContainer } from 'bottlejs';
import type { DomainsList } from '../domains/reducers/domainsList';
import type { MercureInfo } from '../mercure/reducers/mercureInfo';
import type { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
import type { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import type { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
import type { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
import type { TagDeletion } from '../tags/reducers/tagDelete';
import type { TagEdition } from '../tags/reducers/tagEdit';
import type { TagsList } from '../tags/reducers/tagsList';
import type { DomainVisits } from '../visits/reducers/domainVisits';
import type { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import type { TagVisits } from '../visits/reducers/tagVisits';
import type { VisitsInfo } from '../visits/reducers/types';
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
const isProduction = process.env.NODE_ENV === 'production';
export const setUpStore = (container: IContainer) => configureStore({
devTools: !isProduction,
reducer: combineReducers({
mercureInfo: container.mercureInfoReducer,
shortUrlsList: container.shortUrlsListReducer,
shortUrlCreation: container.shortUrlCreationReducer,
shortUrlDeletion: container.shortUrlDeletionReducer,
shortUrlEdition: container.shortUrlEditionReducer,
shortUrlDetail: container.shortUrlDetailReducer,
shortUrlVisits: container.shortUrlVisitsReducer,
tagVisits: container.tagVisitsReducer,
domainVisits: container.domainVisitsReducer,
orphanVisits: container.orphanVisitsReducer,
nonOrphanVisits: container.nonOrphanVisitsReducer,
tagsList: container.tagsListReducer,
tagDelete: container.tagDeleteReducer,
tagEdit: container.tagEditReducer,
domainsList: container.domainsListReducer,
visitsOverview: container.visitsOverviewReducer,
}),
middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk({
// State is too big for these
immutableCheck: false,
serializableCheck: false,
}),
});
export type RootState = {
shortUrlsList: ShortUrlsList;
shortUrlCreation: ShortUrlCreation;
shortUrlDeletion: ShortUrlDeletion;
shortUrlEdition: ShortUrlEdition;
shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits;
domainVisits: DomainVisits;
orphanVisits: VisitsInfo;
nonOrphanVisits: VisitsInfo;
shortUrlDetail: ShortUrlDetail;
tagsList: TagsList;
tagDelete: TagDeletion;
tagEdit: TagEdition;
mercureInfo: MercureInfo;
domainsList: DomainsList;
visitsOverview: VisitsOverview;
};

View file

@ -3,9 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import type { ShlinkDomainRedirects } from '../api/types'; import type { ShlinkDomainRedirects } from '../api-contract';
import type { SelectedServer } from '../servers/data';
import type { OptionalString } from '../utils/utils';
import type { Domain } from './data'; import type { Domain } from './data';
import { DomainDropdown } from './helpers/DomainDropdown'; import { DomainDropdown } from './helpers/DomainDropdown';
import { DomainStatusIcon } from './helpers/DomainStatusIcon'; import { DomainStatusIcon } from './helpers/DomainStatusIcon';
@ -16,10 +14,9 @@ interface DomainRowProps {
defaultRedirects?: ShlinkDomainRedirects; defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void; checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer;
} }
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => ( const Nr: FC<{ fallback?: string | null }> = ({ fallback }) => (
<span className="text-muted"> <span className="text-muted">
{!fallback && <small>No redirect</small>} {!fallback && <small>No redirect</small>}
{fallback && <>{fallback} <small>(as fallback)</small></>} {fallback && <>{fallback} <small>(as fallback)</small></>}
@ -33,7 +30,7 @@ const DefaultDomain: FC = () => (
); );
export const DomainRow: FC<DomainRowProps> = ( export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer }, { domain, editDomainRedirects, checkDomainHealth, defaultRedirects },
) => { ) => {
const { domain: authority, isDefault, redirects, status } = domain; const { domain: authority, isDefault, redirects, status } = domain;
@ -58,7 +55,7 @@ export const DomainRow: FC<DomainRowProps> = (
<DomainStatusIcon status={status} /> <DomainStatusIcon status={status} />
</td> </td>
<td className="responsive-table__cell text-end"> <td className="responsive-table__cell text-end">
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} /> <DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} />
</td> </td>
</tr> </tr>
); );

View file

@ -1,4 +1,4 @@
@import '../utils/base'; @import '@shlinkio/shlink-frontend-kit/base';
@import '../utils/mixins/vertical-align'; @import '../utils/mixins/vertical-align';
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn, .domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,

View file

@ -1,11 +1,10 @@
import { faUndo } from '@fortawesome/free-solid-svg-icons'; import { faUndo } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { DropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
import { isEmpty, pipe } from 'ramda'; import { isEmpty, pipe } from 'ramda';
import { useEffect } from 'react'; import { useEffect } from 'react';
import type { InputProps } from 'reactstrap'; import type { InputProps } from 'reactstrap';
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap'; import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap';
import { DropdownBtn } from '../utils/DropdownBtn';
import { useToggle } from '../utils/helpers/hooks';
import type { DomainsList } from './reducers/domainsList'; import type { DomainsList } from './reducers/domainsList';
import './DomainSelector.scss'; import './DomainSelector.scss';

View file

@ -1,11 +1,7 @@
import { Message, Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../common/ShlinkApiError';
import type { SelectedServer } from '../servers/data';
import { Message } from '../utils/Message';
import { Result } from '../utils/Result';
import { SearchField } from '../utils/SearchField';
import { SimpleCard } from '../utils/SimpleCard';
import { DomainRow } from './DomainRow'; import { DomainRow } from './DomainRow';
import type { EditDomainRedirects } from './reducers/domainRedirects'; import type { EditDomainRedirects } from './reducers/domainRedirects';
import type { DomainsList } from './reducers/domainsList'; import type { DomainsList } from './reducers/domainsList';
@ -16,13 +12,12 @@ interface ManageDomainsProps {
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void; checkDomainHealth: (domain: string) => void;
domainsList: DomainsList; domainsList: DomainsList;
selectedServer: SelectedServer;
} }
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '']; const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
export const ManageDomains: FC<ManageDomainsProps> = ( export const ManageDomains: FC<ManageDomainsProps> = (
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer }, { listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth },
) => { ) => {
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList; const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects; const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
@ -59,7 +54,6 @@ export const ManageDomains: FC<ManageDomainsProps> = (
editDomainRedirects={editDomainRedirects} editDomainRedirects={editDomainRedirects}
checkDomainHealth={checkDomainHealth} checkDomainHealth={checkDomainHealth}
defaultRedirects={resolvedDefaultRedirects} defaultRedirects={resolvedDefaultRedirects}
selectedServer={selectedServer}
/> />
))} ))}
</tbody> </tbody>

View file

@ -1,4 +1,4 @@
import type { ShlinkDomain } from '../../api/types'; import type { ShlinkDomain } from '../../api-contract';
export type DomainStatus = 'validating' | 'valid' | 'invalid'; export type DomainStatus = 'validating' | 'valid' | 'invalid';

View file

@ -1,13 +1,11 @@
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons'; import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap'; import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../servers/data'; import { useFeature } from '../../utils/features';
import { getServerId } from '../../servers/data'; import { useRoutesPrefix } from '../../utils/routesPrefix';
import { useFeature } from '../../utils/helpers/features';
import { useToggle } from '../../utils/helpers/hooks';
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import type { Domain } from '../data'; import type { Domain } from '../data';
import type { EditDomainRedirects } from '../reducers/domainRedirects'; import type { EditDomainRedirects } from '../reducers/domainRedirects';
@ -16,27 +14,24 @@ import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
interface DomainDropdownProps { interface DomainDropdownProps {
domain: Domain; domain: Domain;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
selectedServer: SelectedServer;
} }
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => { export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects }) => {
const [isModalOpen, toggleModal] = useToggle(); const [isModalOpen, toggleModal] = useToggle();
const { isDefault } = domain; const withVisits = useFeature('domainVisits');
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer); const routesPrefix = useRoutesPrefix();
const withVisits = useFeature('domainVisits', selectedServer);
const serverId = getServerId(selectedServer);
return ( return (
<RowDropdownBtn> <RowDropdownBtn>
{withVisits && ( {withVisits && (
<DropdownItem <DropdownItem
tag={Link} tag={Link}
to={`/server/${serverId}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`} to={`${routesPrefix}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
> >
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats <FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem> </DropdownItem>
)} )}
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}> <DropdownItem onClick={toggleModal}>
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects <FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
</DropdownItem> </DropdownItem>

View file

@ -4,11 +4,11 @@ import {
faTimes as invalidIcon, faTimes as invalidIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import { useElementRef } from '../../utils/helpers/hooks';
import type { MediaMatcher } from '../../utils/types'; import type { MediaMatcher } from '../../utils/types';
import type { DomainStatus } from '../data'; import type { DomainStatus } from '../data';

View file

@ -1,11 +1,11 @@
import type { InputFormGroupProps } from '@shlinkio/shlink-frontend-kit';
import { InputFormGroup } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ShlinkDomain } from '../../api/types'; import type { ShlinkDomain } from '../../api-contract';
import type { InputFormGroupProps } from '../../utils/forms/InputFormGroup'; import { InfoTooltip } from '../../utils/components/InfoTooltip';
import { InputFormGroup } from '../../utils/forms/InputFormGroup'; import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/helpers';
import { InfoTooltip } from '../../utils/InfoTooltip';
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
import type { EditDomainRedirects } from '../reducers/domainRedirects'; import type { EditDomainRedirects } from '../reducers/domainRedirects';
interface EditDomainRedirectsModalProps { interface EditDomainRedirectsModalProps {

View file

@ -0,0 +1,20 @@
import type { ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract';
import { createAsyncThunk } from '../../utils/redux';
const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
export interface EditDomainRedirects {
domain: string;
redirects: ShlinkDomainRedirects;
}
export const editDomainRedirects = (
apiClientFactory: () => ShlinkApiClient,
) => createAsyncThunk(
EDIT_DOMAIN_REDIRECTS,
async ({ domain, redirects: providedRedirects }: EditDomainRedirects): Promise<EditDomainRedirects> => {
const apiClient = apiClientFactory();
const redirects = await apiClient.editDomainRedirects({ domain, ...providedRedirects });
return { domain, redirects };
},
);

View file

@ -1,12 +1,8 @@
import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit'; import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit';
import { createAction, createSlice } from '@reduxjs/toolkit'; import { createAction, createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import type { ProblemDetailsError, ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract';
import type { ShlinkDomainRedirects } from '../../api/types'; import { parseApiError } from '../../api-contract/utils';
import type { ProblemDetailsError } from '../../api/types/errors'; import { createAsyncThunk } from '../../utils/redux';
import { parseApiError } from '../../api/utils';
import { hasServerData } from '../../servers/data';
import { createAsyncThunk } from '../../utils/helpers/redux';
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
import type { Domain, DomainStatus } from '../data'; import type { Domain, DomainStatus } from '../data';
import type { EditDomainRedirects } from './domainRedirects'; import type { EditDomainRedirects } from './domainRedirects';
@ -45,12 +41,11 @@ export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status }); (d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
export const domainsListReducerCreator = ( export const domainsListReducerCreator = (
buildShlinkApiClient: ShlinkApiClientBuilder, apiClientFactory: () => ShlinkApiClient,
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>, editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
) => { ) => {
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise<ListDomains> => { const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (): Promise<ListDomains> => {
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState); const { data, defaultRedirects } = await apiClientFactory().listDomains();
const { data, defaultRedirects } = await shlinkListDomains();
return { return {
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
@ -60,22 +55,9 @@ export const domainsListReducerCreator = (
const checkDomainHealth = createAsyncThunk( const checkDomainHealth = createAsyncThunk(
`${REDUCER_PREFIX}/checkDomainHealth`, `${REDUCER_PREFIX}/checkDomainHealth`,
async (domain: string, { getState }): Promise<ValidateDomain> => { async (domain: string): Promise<ValidateDomain> => {
const { selectedServer } = getState();
if (!hasServerData(selectedServer)) {
return { domain, status: 'invalid' };
}
try { try {
const { url, ...rest } = selectedServer; const { status } = await apiClientFactory().health(domain);
const { health } = buildShlinkApiClient({
...rest,
url: replaceAuthorityFromUri(url, domain),
});
const { status } = await health();
return { domain, status: status === 'pass' ? 'valid' : 'invalid' }; return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
} catch (e) { } catch (e) {
return { domain, status: 'invalid' }; return { domain, status: 'invalid' };

View file

@ -1,6 +1,6 @@
import type Bottle from 'bottlejs'; import type Bottle from 'bottlejs';
import { prop } from 'ramda'; import { prop } from 'ramda';
import type { ConnectDecorator } from '../../container/types'; import type { ConnectDecorator } from '../../container';
import { DomainSelector } from '../DomainSelector'; import { DomainSelector } from '../DomainSelector';
import { ManageDomains } from '../ManageDomains'; import { ManageDomains } from '../ManageDomains';
import { editDomainRedirects } from '../reducers/domainRedirects'; import { editDomainRedirects } from '../reducers/domainRedirects';
@ -13,7 +13,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ManageDomains', () => ManageDomains); bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect( bottle.decorator('ManageDomains', connect(
['domainsList', 'selectedServer'], ['domainsList'],
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'], ['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
)); ));
@ -21,7 +21,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory( bottle.serviceFactory(
'domainsListReducerCreator', 'domainsListReducerCreator',
domainsListReducerCreator, domainsListReducerCreator,
'buildShlinkApiClient', 'apiClientFactory',
'editDomainRedirects', 'editDomainRedirects',
); );
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator'); bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
@ -29,6 +29,6 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Actions // Actions
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator'); bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator'); bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient'); bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'apiClientFactory');
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator'); bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
}; };

View file

@ -0,0 +1,2 @@
@import './tags/react-tag-autocomplete';
@import './utils/StickyCardPaginator.scss';

View file

@ -0,0 +1,18 @@
import { bottle } from './container';
import { createShlinkWebComponent } from './ShlinkWebComponent';
export const ShlinkWebComponent = createShlinkWebComponent(bottle);
export type ShlinkWebComponentType = typeof ShlinkWebComponent;
export type {
RealTimeUpdatesSettings,
ShortUrlCreationSettings,
ShortUrlsListSettings,
UiSettings,
VisitsSettings,
TagsSettings,
Settings,
} from './utils/settings';
export type { TagColorsStorage } from './utils/services/TagColorsStorage';

View file

@ -23,6 +23,7 @@ export function boundToMercureHub<T = {}>(
const { interval } = mercureInfo; const { interval } = mercureInfo;
const params = useParams(); const params = useParams();
// Every time mercure info changes, re-bind
useEffect(() => { useEffect(() => {
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit])); const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
const topics = getTopicsForProps(props, params); const topics = getTopicsForProps(props, params);

View file

@ -1,7 +1,7 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import type { ShlinkApiClient, ShlinkMercureInfo } from '../../api-contract';
import type { ShlinkMercureInfo } from '../../api/types'; import { createAsyncThunk } from '../../utils/redux';
import { createAsyncThunk } from '../../utils/helpers/redux'; import type { Settings } from '../../utils/settings';
const REDUCER_PREFIX = 'shlink/mercure'; const REDUCER_PREFIX = 'shlink/mercure';
@ -16,16 +16,15 @@ const initialState: MercureInfo = {
error: false, error: false,
}; };
export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { export const mercureInfoReducerCreator = (apiClientFactory: () => ShlinkApiClient) => {
const loadMercureInfo = createAsyncThunk( const loadMercureInfo = createAsyncThunk(
`${REDUCER_PREFIX}/loadMercureInfo`, `${REDUCER_PREFIX}/loadMercureInfo`,
(_: void, { getState }): Promise<ShlinkMercureInfo> => { ({ realTimeUpdates }: Settings): Promise<ShlinkMercureInfo> => {
const { settings } = getState(); if (realTimeUpdates && !realTimeUpdates.enabled) {
if (!settings.realTimeUpdates.enabled) {
throw new Error('Real time updates not enabled'); throw new Error('Real time updates not enabled');
} }
return buildShlinkApiClient(getState).mercureInfo(); return apiClientFactory().mercureInfo();
}, },
); );

View file

@ -4,7 +4,7 @@ import { mercureInfoReducerCreator } from '../reducers/mercureInfo';
export const provideServices = (bottle: Bottle) => { export const provideServices = (bottle: Bottle) => {
// Reducer // Reducer
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'apiClientFactory');
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator'); bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
// Actions // Actions

View file

@ -2,20 +2,18 @@ import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Card, CardBody, CardHeader, Row } from 'reactstrap'; import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import type { ShlinkShortUrlsListParams } from '../api/types'; import type { ShlinkShortUrlsListParams } from '../api-contract';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import type { Settings } from '../settings/reducers/settings';
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList'; import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable'; import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
import type { TagsList } from '../tags/reducers/tagsList'; import type { TagsList } from '../tags/reducers/tagsList';
import { useFeature } from '../utils/helpers/features';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { useRoutesPrefix } from '../utils/routesPrefix';
import { useSetting } from '../utils/settings';
import type { VisitsOverview } from '../visits/reducers/visitsOverview'; import type { VisitsOverview } from '../visits/reducers/visitsOverview';
import type { SelectedServer } from './data';
import { getServerId } from './data';
import { HighlightCard } from './helpers/HighlightCard'; import { HighlightCard } from './helpers/HighlightCard';
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard'; import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
@ -24,10 +22,8 @@ interface OverviewConnectProps {
listShortUrls: (params: ShlinkShortUrlsListParams) => void; listShortUrls: (params: ShlinkShortUrlsListParams) => void;
listTags: Function; listTags: Function;
tagsList: TagsList; tagsList: TagsList;
selectedServer: SelectedServer;
visitsOverview: VisitsOverview; visitsOverview: VisitsOverview;
loadVisitsOverview: Function; loadVisitsOverview: Function;
settings: Settings;
} }
export const Overview = ( export const Overview = (
@ -38,17 +34,15 @@ export const Overview = (
listShortUrls, listShortUrls,
listTags, listTags,
tagsList, tagsList,
selectedServer,
loadVisitsOverview, loadVisitsOverview,
visitsOverview, visitsOverview,
settings: { visits },
}: OverviewConnectProps) => { }: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList; const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList; const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview; const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
const serverId = getServerId(selectedServer); const routesPrefix = useRoutesPrefix();
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
const navigate = useNavigate(); const navigate = useNavigate();
const visits = useSetting('visits');
useEffect(() => { useEffect(() => {
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } }); listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
@ -62,7 +56,7 @@ export const Overview = (
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<VisitsHighlightCard <VisitsHighlightCard
title="Visits" title="Visits"
link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined} link={`${routesPrefix}/non-orphan-visits`}
excludeBots={visits?.excludeBots ?? false} excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits} loading={loadingVisits}
visitsSummary={nonOrphanVisits} visitsSummary={nonOrphanVisits}
@ -71,19 +65,19 @@ export const Overview = (
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<VisitsHighlightCard <VisitsHighlightCard
title="Orphan visits" title="Orphan visits"
link={`/server/${serverId}/orphan-visits`} link={`${routesPrefix}/orphan-visits`}
excludeBots={visits?.excludeBots ?? false} excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits} loading={loadingVisits}
visitsSummary={orphanVisits} visitsSummary={orphanVisits}
/> />
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}> <HighlightCard title="Short URLs" link={`${routesPrefix}/list-short-urls/1`}>
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)} {loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</HighlightCard> </HighlightCard>
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}> <HighlightCard title="Tags" link={`${routesPrefix}/manage-tags`}>
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)} {loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
</HighlightCard> </HighlightCard>
</div> </div>
@ -93,7 +87,7 @@ export const Overview = (
<CardHeader> <CardHeader>
<span className="d-sm-none">Create a short URL</span> <span className="d-sm-none">Create a short URL</span>
<h5 className="d-none d-sm-inline">Create a short URL</h5> <h5 className="d-none d-sm-inline">Create a short URL</h5>
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link> <Link className="float-end" to={`${routesPrefix}/create-short-url`}>Advanced options &raquo;</Link>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<CreateShortUrl basicMode /> <CreateShortUrl basicMode />
@ -103,14 +97,13 @@ export const Overview = (
<CardHeader> <CardHeader>
<span className="d-sm-none">Recently created URLs</span> <span className="d-sm-none">Recently created URLs</span>
<h5 className="d-none d-sm-inline">Recently created URLs</h5> <h5 className="d-none d-sm-inline">Recently created URLs</h5>
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link> <Link className="float-end" to={`${routesPrefix}/list-short-urls/1`}>See all &raquo;</Link>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<ShortUrlsTable <ShortUrlsTable
shortUrlsList={shortUrlsList} shortUrlsList={shortUrlsList}
selectedServer={selectedServer}
className="mb-0" className="mb-0"
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)} onTagClick={(tag) => navigate(`${routesPrefix}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
/> />
</CardBody> </CardBody>
</Card> </Card>

View file

@ -1,4 +1,4 @@
@import '../../utils/base'; @import '@shlinkio/shlink-frontend-kit/base';
.highlight-card.highlight-card { .highlight-card.highlight-card {
text-align: center; text-align: center;
@ -11,7 +11,7 @@
position: absolute; position: absolute;
right: 5px; right: 5px;
bottom: 5px; bottom: 5px;
opacity: 0.1; opacity: .1;
transform: rotate(-45deg); transform: rotate(-45deg);
} }

View file

@ -1,18 +1,18 @@
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons'; import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
import type { FC, PropsWithChildren, ReactNode } from 'react'; import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap'; import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
import { useElementRef } from '../../utils/helpers/hooks';
import './HighlightCard.scss'; import './HighlightCard.scss';
export type HighlightCardProps = PropsWithChildren<{ export type HighlightCardProps = PropsWithChildren<{
title: string; title: string;
link?: string; link: string;
tooltip?: ReactNode; tooltip?: ReactNode;
}>; }>;
const buildExtraProps = (link?: string) => (!link ? {} : { tag: Link, to: link }); const buildExtraProps = (link: string) => ({ tag: Link, to: link });
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, tooltip }) => { export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, tooltip }) => {
const ref = useElementRef<HTMLElement>(); const ref = useElementRef<HTMLElement>();
@ -20,7 +20,7 @@ export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, t
return ( return (
<> <>
<Card innerRef={ref} className="highlight-card" body {...buildExtraProps(link)}> <Card innerRef={ref} className="highlight-card" body {...buildExtraProps(link)}>
{link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />} <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle> <CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
<CardText tag="h2">{children}</CardText> <CardText tag="h2">{children}</CardText>
</Card> </Card>

View file

@ -14,7 +14,7 @@ export const VisitsHighlightCard: FC<VisitsHighlightCardProps> = ({ loading, exc
<HighlightCard <HighlightCard
tooltip={ tooltip={
visitsSummary.bots !== undefined visitsSummary.bots !== undefined
? <>{excludeBots ? 'Plus' : 'Including'} <b>{prettify(visitsSummary.bots)}</b> potential bot visits</> ? <>{excludeBots ? 'Plus' : 'Including'} <strong>{prettify(visitsSummary.bots)}</strong> potential bot visits</>
: undefined : undefined
} }
{...rest} {...rest}

View file

@ -0,0 +1,11 @@
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container';
import { Overview } from '../Overview';
export function provideServices(bottle: Bottle, connect: ConnectDecorator) {
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
bottle.decorator('Overview', connect(
['shortUrlsList', 'tagsList', 'mercureInfo', 'visitsOverview'],
['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
));
}

View file

@ -1,8 +1,8 @@
import type { ShlinkCreateShortUrlData } from '@shlinkio/shlink-web-component/api-contract';
import type { FC } from 'react'; import type { FC } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { SelectedServer } from '../servers/data'; import type { ShortUrlCreationSettings } from '../utils/settings';
import type { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings'; import { useSetting } from '../utils/settings';
import type { ShortUrlData } from './data';
import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult'; import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
import type { ShortUrlCreation } from './reducers/shortUrlCreation'; import type { ShortUrlCreation } from './reducers/shortUrlCreation';
import type { ShortUrlFormProps } from './ShortUrlForm'; import type { ShortUrlFormProps } from './ShortUrlForm';
@ -12,14 +12,12 @@ export interface CreateShortUrlProps {
} }
interface CreateShortUrlConnectProps extends CreateShortUrlProps { interface CreateShortUrlConnectProps extends CreateShortUrlProps {
settings: Settings;
shortUrlCreation: ShortUrlCreation; shortUrlCreation: ShortUrlCreation;
selectedServer: SelectedServer; createShortUrl: (data: ShlinkCreateShortUrlData) => Promise<void>;
createShortUrl: (data: ShortUrlData) => Promise<void>;
resetCreateShortUrl: () => void; resetCreateShortUrl: () => void;
} }
const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({ const getInitialState = (settings?: ShortUrlCreationSettings): ShlinkCreateShortUrlData => ({
longUrl: '', longUrl: '',
tags: [], tags: [],
customSlug: '', customSlug: '',
@ -35,16 +33,15 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
}); });
export const CreateShortUrl = ( export const CreateShortUrl = (
ShortUrlForm: FC<ShortUrlFormProps>, ShortUrlForm: FC<ShortUrlFormProps<ShlinkCreateShortUrlData>>,
CreateShortUrlResult: FC<CreateShortUrlResultProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>,
) => ({ ) => ({
createShortUrl, createShortUrl,
shortUrlCreation, shortUrlCreation,
resetCreateShortUrl, resetCreateShortUrl,
selectedServer,
basicMode = false, basicMode = false,
settings: { shortUrlCreation: shortUrlCreationSettings },
}: CreateShortUrlConnectProps) => { }: CreateShortUrlConnectProps) => {
const shortUrlCreationSettings = useSetting('shortUrlCreation');
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]); const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]);
return ( return (
@ -52,9 +49,8 @@ export const CreateShortUrl = (
<ShortUrlForm <ShortUrlForm
initialState={initialState} initialState={initialState}
saving={shortUrlCreation.saving} saving={shortUrlCreation.saving}
selectedServer={selectedServer}
mode={basicMode ? 'create-basic' : 'create'} mode={basicMode ? 'create-basic' : 'create'}
onSave={async (data: ShortUrlData) => { onSave={async (data) => {
resetCreateShortUrl(); resetCreateShortUrl();
return createShortUrl(data); return createShortUrl(data);
}} }}

View file

@ -1,17 +1,15 @@
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Message, parseQuery, Result } from '@shlinkio/shlink-frontend-kit';
import type { ShlinkEditShortUrlData } from '@shlinkio/shlink-web-component/api-contract';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { Button, Card } from 'reactstrap'; import { Button, Card } from 'reactstrap';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../common/ShlinkApiError';
import type { SelectedServer } from '../servers/data';
import type { Settings } from '../settings/reducers/settings';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import { parseQuery } from '../utils/helpers/query'; import { useSetting } from '../utils/settings';
import { Message } from '../utils/Message';
import { Result } from '../utils/Result';
import type { ShortUrlIdentifier } from './data'; import type { ShortUrlIdentifier } from './data';
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers'; import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
import type { ShortUrlDetail } from './reducers/shortUrlDetail'; import type { ShortUrlDetail } from './reducers/shortUrlDetail';
@ -19,17 +17,13 @@ import type { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reduce
import type { ShortUrlFormProps } from './ShortUrlForm'; import type { ShortUrlFormProps } from './ShortUrlForm';
interface EditShortUrlConnectProps { interface EditShortUrlConnectProps {
settings: Settings;
selectedServer: SelectedServer;
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;
shortUrlEdition: ShortUrlEdition; shortUrlEdition: ShortUrlEdition;
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void; getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
editShortUrl: (editShortUrl: EditShortUrlInfo) => void; editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
} }
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps<ShlinkEditShortUrlData>>) => ({
settings: { shortUrlCreation: shortUrlCreationSettings },
selectedServer,
shortUrlDetail, shortUrlDetail,
getShortUrlDetail, getShortUrlDetail,
shortUrlEdition, shortUrlEdition,
@ -41,6 +35,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
const { loading, error, errorData, shortUrl } = shortUrlDetail; const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition; const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
const { domain } = parseQuery<{ domain?: string }>(search); const { domain } = parseQuery<{ domain?: string }>(search);
const shortUrlCreationSettings = useSetting('shortUrlCreation');
const initialState = useMemo( const initialState = useMemo(
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings), () => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
[shortUrl, shortUrlCreationSettings], [shortUrl, shortUrlCreationSettings],
@ -80,7 +75,6 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
<ShortUrlForm <ShortUrlForm
initialState={initialState} initialState={initialState}
saving={saving} saving={saving}
selectedServer={selectedServer}
mode="edit" mode="edit"
onSave={async (shortUrlData) => { onSave={async (shortUrlData) => {
if (!shortUrl) { if (!shortUrl) {

View file

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import type { ShlinkPaginator } from '../api/types'; import type { ShlinkPaginator } from '../api-contract';
import type { import type {
NumberOrEllipsis } from '../utils/helpers/pagination'; NumberOrEllipsis } from '../utils/helpers/pagination';
import { import {
@ -9,17 +9,18 @@ import {
prettifyPageNumber, prettifyPageNumber,
progressivePagination, progressivePagination,
} from '../utils/helpers/pagination'; } from '../utils/helpers/pagination';
import { useRoutesPrefix } from '../utils/routesPrefix';
interface PaginatorProps { interface PaginatorProps {
paginator?: ShlinkPaginator; paginator?: ShlinkPaginator;
serverId: string;
currentQueryString?: string; currentQueryString?: string;
} }
export const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => { export const Paginator = ({ paginator, currentQueryString = '' }: PaginatorProps) => {
const { currentPage = 0, pagesCount = 0 } = paginator ?? {}; const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
const routesPrefix = useRoutesPrefix();
const urlForPage = (pageNumber: NumberOrEllipsis) => const urlForPage = (pageNumber: NumberOrEllipsis) =>
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`; `${routesPrefix}/list-short-urls/${pageNumber}${currentQueryString}`;
if (pagesCount <= 1) { if (pagesCount <= 1) {
return <div className="pb-3" />; // Return some space return <div className="pb-3" />; // Return some space

View file

@ -1,4 +1,4 @@
@import '../utils/base'; @import '@shlinkio/shlink-frontend-kit/base';
.short-url-form p:last-child { .short-url-form p:last-child {
margin-bottom: 0; margin-bottom: 0;

View file

@ -1,6 +1,7 @@
import type { IconProp } from '@fortawesome/fontawesome-svg-core'; import type { IconProp } from '@fortawesome/fontawesome-svg-core';
import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons'; import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons';
import { faDesktop } from '@fortawesome/free-solid-svg-icons'; import { faDesktop } from '@fortawesome/free-solid-svg-icons';
import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import classNames from 'classnames'; import classNames from 'classnames';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { isEmpty, pipe, replace, trim } from 'ramda'; import { isEmpty, pipe, replace, trim } from 'ramda';
@ -8,18 +9,15 @@ import type { ChangeEvent, FC } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, FormGroup, Input, Row } from 'reactstrap'; import { Button, FormGroup, Input, Row } from 'reactstrap';
import type { InputType } from 'reactstrap/types/lib/Input'; import type { InputType } from 'reactstrap/types/lib/Input';
import type { ShlinkCreateShortUrlData, ShlinkDeviceLongUrls, ShlinkEditShortUrlData } from '../api-contract';
import type { DomainSelectorProps } from '../domains/DomainSelector'; import type { DomainSelectorProps } from '../domains/DomainSelector';
import type { SelectedServer } from '../servers/data';
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { Checkbox } from '../utils/Checkbox'; import { IconInput } from '../utils/components/IconInput';
import type { DateTimeInputProps } from '../utils/dates/DateTimeInput'; import type { DateTimeInputProps } from '../utils/dates/DateTimeInput';
import { DateTimeInput } from '../utils/dates/DateTimeInput'; import { DateTimeInput } from '../utils/dates/DateTimeInput';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/dates/helpers/date';
import { useFeature } from '../utils/helpers/features'; import { useFeature } from '../utils/features';
import { IconInput } from '../utils/IconInput'; import { handleEventPreventingDefault, hasValue } from '../utils/helpers';
import { SimpleCard } from '../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import type { DeviceLongUrls, ShortUrlData } from './data';
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup'; import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon'; import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
import './ShortUrlForm.scss'; import './ShortUrlForm.scss';
@ -29,26 +27,32 @@ export type Mode = 'create' | 'create-basic' | 'edit';
type DateFields = 'validSince' | 'validUntil'; type DateFields = 'validSince' | 'validUntil';
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title'; type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title';
export interface ShortUrlFormProps { export interface ShortUrlFormProps<T extends ShlinkCreateShortUrlData | ShlinkEditShortUrlData> {
// FIXME Try to get rid of the mode param, and infer creation or edition from initialState if possible
mode: Mode; mode: Mode;
saving: boolean; saving: boolean;
initialState: ShortUrlData; initialState: T;
onSave: (shortUrlData: ShortUrlData) => Promise<unknown>; onSave: (shortUrlData: T) => Promise<unknown>;
selectedServer: SelectedServer;
} }
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date); const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData): data is ShlinkCreateShortUrlData =>
'shortCodeLength' in data && 'customSlug' in data && 'domain' in data;
export const ShortUrlForm = ( export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>, TagsSelector: FC<TagsSelectorProps>,
DomainSelector: FC<DomainSelectorProps>, DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { ) => function ShortUrlFormComp<T extends ShlinkCreateShortUrlData | ShlinkEditShortUrlData>(
{ mode, saving, onSave, initialState }: ShortUrlFormProps<T>,
) {
const [shortUrlData, setShortUrlData] = useState(initialState); const [shortUrlData, setShortUrlData] = useState(initialState);
const reset = () => setShortUrlData(initialState); const reset = () => setShortUrlData(initialState);
const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer); const supportsDeviceLongUrls = useFeature('deviceLongUrls');
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const isCreation = isCreationData(shortUrlData);
const isBasicMode = mode === 'create-basic'; const isBasicMode = mode === 'create-basic';
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
const setResettableValue = (value: string, initialValue?: any) => { const setResettableValue = (value: string, initialValue?: any) => {
@ -84,13 +88,14 @@ export const ShortUrlForm = (
id={id} id={id}
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
// @ts-expect-error FIXME Make sure id is a key from T
value={shortUrlData[id] ?? ''} value={shortUrlData[id] ?? ''}
onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))} onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))}
{...props} {...props}
/> />
</FormGroup> </FormGroup>
); );
const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => ( const renderDeviceLongUrlInput = (id: keyof ShlinkDeviceLongUrls, placeholder: string, icon: IconProp) => (
<IconInput <IconInput
icon={icon} icon={icon}
id={id} id={id}
@ -136,8 +141,6 @@ export const ShortUrlForm = (
</> </>
); );
const showForwardQueryControl = useFeature('forwardQuery', selectedServer);
return ( return (
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}> <form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
{isBasicMode && basicComponents} {isBasicMode && basicComponents}
@ -175,7 +178,7 @@ export const ShortUrlForm = (
title: setResettableValue(target.value, initialState.title), title: setResettableValue(target.value, initialState.title),
}), }),
})} })}
{!isEdit && ( {!isEdit && isCreation && (
<> <>
<Row> <Row>
<div className="col-lg-6"> <div className="col-lg-6">
@ -220,7 +223,7 @@ export const ShortUrlForm = (
> >
Validate URL Validate URL
</ShortUrlFormCheckboxGroup> </ShortUrlFormCheckboxGroup>
{!isEdit && ( {!isEdit && isCreation && (
<p> <p>
<Checkbox <Checkbox
inline inline
@ -244,15 +247,13 @@ export const ShortUrlForm = (
> >
Make it crawlable Make it crawlable
</ShortUrlFormCheckboxGroup> </ShortUrlFormCheckboxGroup>
{showForwardQueryControl && ( <ShortUrlFormCheckboxGroup
<ShortUrlFormCheckboxGroup infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL." checked={shortUrlData.forwardQuery}
checked={shortUrlData.forwardQuery} onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })} >
> Forward query params on redirect
Forward query params on redirect </ShortUrlFormCheckboxGroup>
</ShortUrlFormCheckboxGroup>
)}
</SimpleCard> </SimpleCard>
</div> </div>
</Row> </Row>

View file

@ -1,20 +1,18 @@
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons'; import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { OrderDir } from '@shlinkio/shlink-frontend-kit';
import { OrderingDropdown, SearchField } from '@shlinkio/shlink-frontend-kit';
import classNames from 'classnames'; import classNames from 'classnames';
import { isEmpty, pipe } from 'ramda'; import { isEmpty, pipe } from 'ramda';
import type { FC } from 'react'; import type { FC } from 'react';
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap'; import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
import type { SelectedServer } from '../servers/data';
import type { Settings } from '../settings/reducers/settings';
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/dates/helpers/date';
import type { DateRange } from '../utils/helpers/dateIntervals'; import type { DateRange } from '../utils/dates/helpers/dateIntervals';
import { datesToDateRange } from '../utils/helpers/dateIntervals'; import { datesToDateRange } from '../utils/dates/helpers/dateIntervals';
import { useFeature } from '../utils/helpers/features'; import { useFeature } from '../utils/features';
import type { OrderDir } from '../utils/helpers/ordering'; import { useSetting } from '../utils/settings';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { SearchField } from '../utils/SearchField';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { SHORT_URLS_ORDERABLE_FIELDS } from './data'; import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn'; import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
@ -23,9 +21,7 @@ import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
import './ShortUrlsFilteringBar.scss'; import './ShortUrlsFilteringBar.scss';
interface ShortUrlsFilteringProps { interface ShortUrlsFilteringProps {
selectedServer: SelectedServer;
order: ShortUrlsOrder; order: ShortUrlsOrder;
settings: Settings;
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
className?: string; className?: string;
shortUrlsAmount?: number; shortUrlsAmount?: number;
@ -34,7 +30,7 @@ interface ShortUrlsFilteringProps {
export const ShortUrlsFilteringBar = ( export const ShortUrlsFilteringBar = (
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>, ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
TagsSelector: FC<TagsSelectorProps>, TagsSelector: FC<TagsSelectorProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => { ): FC<ShortUrlsFilteringProps> => ({ className, shortUrlsAmount, order, handleOrderBy }) => {
const [filter, toFirstPage] = useShortUrlsQuery(); const [filter, toFirstPage] = useShortUrlsQuery();
const { const {
search, search,
@ -46,7 +42,8 @@ export const ShortUrlsFilteringBar = (
excludePastValidUntil, excludePastValidUntil,
tagsMode = 'any', tagsMode = 'any',
} = filter; } = filter;
const supportsDisabledFiltering = useFeature('filterDisabledUrls', selectedServer); const supportsDisabledFiltering = useFeature('filterDisabledUrls');
const visitsSettings = useSetting('visits');
const setDates = pipe( const setDates = pipe(
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({ ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
@ -60,7 +57,6 @@ export const ShortUrlsFilteringBar = (
(searchTerm) => toFirstPage({ search: searchTerm }), (searchTerm) => toFirstPage({ search: searchTerm }),
); );
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags }); const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
const canChangeTagsMode = useFeature('allTagsFiltering', selectedServer);
const toggleTagsMode = pipe( const toggleTagsMode = pipe(
() => (tagsMode === 'any' ? 'all' : 'any'), () => (tagsMode === 'any' ? 'all' : 'any'),
(mode) => toFirstPage({ tagsMode: mode }), (mode) => toFirstPage({ tagsMode: mode }),
@ -72,7 +68,7 @@ export const ShortUrlsFilteringBar = (
<InputGroup className="mt-3"> <InputGroup className="mt-3">
<TagsSelector allowNew={false} placeholder="With tags..." selectedTags={tags} onChange={changeTagSelection} /> <TagsSelector allowNew={false} placeholder="With tags..." selectedTags={tags} onChange={changeTagSelection} />
{canChangeTagsMode && tags.length > 1 && ( {tags.length > 1 && (
<> <>
<Button outline color="secondary" onClick={toggleTagsMode} id="tagsModeBtn" aria-label="Change tags mode"> <Button outline color="secondary" onClick={toggleTagsMode} id="tagsModeBtn" aria-label="Change tags mode">
<FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} /> <FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
@ -97,7 +93,7 @@ export const ShortUrlsFilteringBar = (
<ShortUrlsFilterDropdown <ShortUrlsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0" className="ms-0 ms-md-2 mt-3 mt-md-0"
selected={{ selected={{
excludeBots: excludeBots ?? settings.visits?.excludeBots, excludeBots: excludeBots ?? visitsSettings?.excludeBots,
excludeMaxVisitsReached, excludeMaxVisitsReached,
excludePastValidUntil, excludePastValidUntil,
}} }}

View file

@ -1,17 +1,14 @@
import type { OrderDir } from '@shlinkio/shlink-frontend-kit';
import { determineOrderDir } from '@shlinkio/shlink-frontend-kit';
import { pipe } from 'ramda'; import { pipe } from 'ramda';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { Card } from 'reactstrap'; import { Card } from 'reactstrap';
import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api/types'; import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api-contract';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import type { SelectedServer } from '../servers/data'; import { useFeature } from '../utils/features';
import { getServerId } from '../servers/data'; import { useSettings } from '../utils/settings';
import type { Settings } from '../settings/reducers/settings';
import { DEFAULT_SHORT_URLS_ORDERING } from '../settings/reducers/settings';
import { useFeature } from '../utils/helpers/features';
import type { OrderDir } from '../utils/helpers/ordering';
import { determineOrderDir } from '../utils/helpers/ordering';
import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { useShortUrlsQuery } from './helpers/hooks'; import { useShortUrlsQuery } from './helpers/hooks';
@ -21,20 +18,23 @@ import type { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
import type { ShortUrlsTableType } from './ShortUrlsTable'; import type { ShortUrlsTableType } from './ShortUrlsTable';
interface ShortUrlsListProps { interface ShortUrlsListProps {
selectedServer: SelectedServer;
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShlinkShortUrlsListParams) => void; listShortUrls: (params: ShlinkShortUrlsListParams) => void;
settings: Settings;
} }
const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
field: 'dateCreated',
dir: 'DESC',
};
export const ShortUrlsList = ( export const ShortUrlsList = (
ShortUrlsTable: ShortUrlsTableType, ShortUrlsTable: ShortUrlsTableType,
ShortUrlsFilteringBar: ShortUrlsFilteringBarType, ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => { ) => boundToMercureHub(({ listShortUrls, shortUrlsList }: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer);
const { page } = useParams(); const { page } = useParams();
const location = useLocation(); const location = useLocation();
const [filter, toFirstPage] = useShortUrlsQuery(); const [filter, toFirstPage] = useShortUrlsQuery();
const settings = useSettings();
const { const {
tags, tags,
search, search,
@ -52,7 +52,7 @@ export const ShortUrlsList = (
); );
const { pagination } = shortUrlsList?.shortUrls ?? {}; const { pagination } = shortUrlsList?.shortUrls ?? {};
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots; const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls', selectedServer); const supportsExcludingBots = useFeature('excludeBotsOnShortUrls');
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => { const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } }); toFirstPage({ orderBy: { field, dir } });
setActualOrderBy({ field, dir }); setActualOrderBy({ field, dir });
@ -101,22 +101,19 @@ export const ShortUrlsList = (
return ( return (
<> <>
<ShortUrlsFilteringBar <ShortUrlsFilteringBar
selectedServer={selectedServer}
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems} shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy} order={actualOrderBy}
handleOrderBy={handleOrderBy} handleOrderBy={handleOrderBy}
settings={settings}
className="mb-3" className="mb-3"
/> />
<Card body className="pb-0"> <Card body className="pb-0">
<ShortUrlsTable <ShortUrlsTable
selectedServer={selectedServer}
shortUrlsList={shortUrlsList} shortUrlsList={shortUrlsList}
orderByColumn={orderByColumn} orderByColumn={orderByColumn}
renderOrderIcon={renderOrderIcon} renderOrderIcon={renderOrderIcon}
onTagClick={addTag} onTagClick={addTag}
/> />
<Paginator paginator={pagination} serverId={serverId} currentQueryString={location.search} /> <Paginator paginator={pagination} currentQueryString={location.search} />
</Card> </Card>
</> </>
); );

View file

@ -1,7 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { isEmpty } from 'ramda'; import { isEmpty } from 'ramda';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { SelectedServer } from '../servers/data';
import type { ShortUrlsOrderableFields } from './data'; import type { ShortUrlsOrderableFields } from './data';
import type { ShortUrlsRowType } from './helpers/ShortUrlsRow'; import type { ShortUrlsRowType } from './helpers/ShortUrlsRow';
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
@ -11,7 +10,6 @@ interface ShortUrlsTableProps {
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void; orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode; renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
selectedServer: SelectedServer;
onTagClick?: (tag: string) => void; onTagClick?: (tag: string) => void;
className?: string; className?: string;
} }
@ -21,7 +19,6 @@ export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
renderOrderIcon, renderOrderIcon,
shortUrlsList, shortUrlsList,
onTagClick, onTagClick,
selectedServer,
className, className,
}: ShortUrlsTableProps) => { }: ShortUrlsTableProps) => {
const { error, loading, shortUrls } = shortUrlsList; const { error, loading, shortUrls } = shortUrlsList;
@ -52,7 +49,6 @@ export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
<ShortUrlsRow <ShortUrlsRow
key={shortUrl.shortUrl} key={shortUrl.shortUrl}
shortUrl={shortUrl} shortUrl={shortUrl}
selectedServer={selectedServer}
onTagClick={onTagClick} onTagClick={onTagClick}
/> />
)); ));

View file

@ -1,7 +1,7 @@
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { Modal, ModalBody, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import { useToggle } from '../utils/helpers/hooks';
import './UseExistingIfFoundInfoIcon.scss'; import './UseExistingIfFoundInfoIcon.scss';
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => ( const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (

View file

@ -0,0 +1,43 @@
import type { Order } from '@shlinkio/shlink-frontend-kit';
import type { ShlinkShortUrl } from '../../api-contract';
import type { OptionalString } from '../../utils/helpers';
export interface ShortUrlIdentifier {
shortCode: string;
domain?: OptionalString;
}
export interface ShortUrlModalProps {
shortUrl: ShlinkShortUrl;
isOpen: boolean;
toggle: () => void;
}
export const SHORT_URLS_ORDERABLE_FIELDS = {
dateCreated: 'Created at',
shortCode: 'Short URL',
longUrl: 'Long URL',
title: 'Title',
visits: 'Visits',
};
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
export interface ExportableShortUrl {
createdAt: string;
title: string;
shortUrl: string;
domain?: string;
shortCode: string;
longUrl: string;
tags: string;
visits: number;
}
export interface ShortUrlsFilter {
excludeBots?: boolean;
excludeMaxVisitsReached?: boolean;
excludePastValidUntil?: boolean;
}

View file

@ -1,12 +1,12 @@
import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons'; import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons'; import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Result } from '@shlinkio/shlink-frontend-kit';
import { useEffect } from 'react'; import { useEffect } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard'; import CopyToClipboard from 'react-copy-to-clipboard';
import { Tooltip } from 'reactstrap'; import { Tooltip } from 'reactstrap';
import { ShlinkApiError } from '../../api/ShlinkApiError'; import { ShlinkApiError } from '../../common/ShlinkApiError';
import type { TimeoutToggle } from '../../utils/helpers/hooks'; import type { TimeoutToggle } from '../../utils/helpers/hooks';
import { Result } from '../../utils/Result';
import type { ShortUrlCreation } from '../reducers/shortUrlCreation'; import type { ShortUrlCreation } from '../reducers/shortUrlCreation';
import './CreateShortUrlResult.scss'; import './CreateShortUrlResult.scss';

View file

@ -1,10 +1,10 @@
import { Result } from '@shlinkio/shlink-frontend-kit';
import { pipe } from 'ramda'; import { pipe } from 'ramda';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ShlinkApiError } from '../../api/ShlinkApiError'; import { isInvalidDeletionError } from '../../api-contract/utils';
import { isInvalidDeletionError } from '../../api/utils'; import { ShlinkApiError } from '../../common/ShlinkApiError';
import { Result } from '../../utils/Result'; import { handleEventPreventingDefault } from '../../utils/helpers';
import { handleEventPreventingDefault } from '../../utils/utils';
import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data'; import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion'; import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion';

View file

@ -1,39 +1,27 @@
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import type { ShlinkApiClient, ShlinkShortUrl } from '../../api-contract';
import type { ReportExporter } from '../../common/services/ReportExporter'; import { ExportBtn } from '../../utils/components/ExportBtn';
import type { SelectedServer } from '../../servers/data'; import type { ReportExporter } from '../../utils/services/ReportExporter';
import { isServerWithId } from '../../servers/data';
import { ExportBtn } from '../../utils/ExportBtn';
import { useToggle } from '../../utils/helpers/hooks';
import type { ShortUrl } from '../data';
import { useShortUrlsQuery } from './hooks'; import { useShortUrlsQuery } from './hooks';
export interface ExportShortUrlsBtnProps { export interface ExportShortUrlsBtnProps {
amount?: number; amount?: number;
} }
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
selectedServer: SelectedServer;
}
const itemsPerPage = 20; const itemsPerPage = 20;
export const ExportShortUrlsBtn = ( export const ExportShortUrlsBtn = (
buildShlinkApiClient: ShlinkApiClientBuilder, apiClientFactory: () => ShlinkApiClient,
{ exportShortUrls }: ReportExporter, { exportShortUrls }: ReportExporter,
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => { ): FC<ExportShortUrlsBtnProps> => ({ amount = 0 }) => {
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery(); const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
const [loading,, startLoading, stopLoading] = useToggle(); const [loading,, startLoading, stopLoading] = useToggle();
const exportAllUrls = useCallback(async () => { const exportAllUrls = useCallback(async () => {
if (!isServerWithId(selectedServer)) {
return;
}
const totalPages = amount / itemsPerPage; const totalPages = amount / itemsPerPage;
const { listShortUrls } = buildShlinkApiClient(selectedServer); const loadAllUrls = async (page = 1): Promise<ShlinkShortUrl[]> => {
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => { const { data } = await apiClientFactory().listShortUrls(
const { data } = await listShortUrls(
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage }, { page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
); );
@ -64,7 +52,7 @@ export const ExportShortUrlsBtn = (
}; };
})); }));
stopLoading(); stopLoading();
}, [selectedServer]); }, []);
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />; return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
}; };

View file

@ -3,29 +3,22 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
import type { ImageDownloader } from '../../common/services/ImageDownloader'; import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon';
import type { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { useFeature } from '../../utils/helpers/features';
import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
import { buildQrCodeUrl } from '../../utils/helpers/qrCodes'; import { buildQrCodeUrl } from '../../utils/helpers/qrCodes';
import type { ImageDownloader } from '../../utils/services/ImageDownloader';
import type { ShortUrlModalProps } from '../data'; import type { ShortUrlModalProps } from '../data';
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown'; import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown'; import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
import './QrCodeModal.scss'; import './QrCodeModal.scss';
interface QrCodeModalConnectProps extends ShortUrlModalProps {
selectedServer: SelectedServer;
}
export const QrCodeModal = (imageDownloader: ImageDownloader) => ( export const QrCodeModal = (imageDownloader: ImageDownloader) => (
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps, { shortUrl: { shortUrl, shortCode }, toggle, isOpen }: ShortUrlModalProps,
) => { ) => {
const [size, setSize] = useState(300); const [size, setSize] = useState(300);
const [margin, setMargin] = useState(0); const [margin, setMargin] = useState(0);
const [format, setFormat] = useState<QrCodeFormat>('png'); const [format, setFormat] = useState<QrCodeFormat>('png');
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L'); const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
const displayDownloadBtn = useFeature('nonRestCors', selectedServer);
const qrCodeUrl = useMemo( const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }), () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
[shortUrl, size, format, margin, errorCorrection], [shortUrl, size, format, margin, errorCorrection],
@ -46,7 +39,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Row> <Row>
<FormGroup className="d-grid col-md-4"> <FormGroup className="d-grid col-md-6">
<label>Size: {size}px</label> <label>Size: {size}px</label>
<input <input
type="range" type="range"
@ -58,7 +51,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setSize(Number(e.target.value))} onChange={(e) => setSize(Number(e.target.value))}
/> />
</FormGroup> </FormGroup>
<FormGroup className="d-grid col-md-4"> <FormGroup className="d-grid col-md-6">
<label htmlFor="marginControl">Margin: {margin}px</label> <label htmlFor="marginControl">Margin: {margin}px</label>
<input <input
id="marginControl" id="marginControl"
@ -71,7 +64,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setMargin(Number(e.target.value))} onChange={(e) => setMargin(Number(e.target.value))}
/> />
</FormGroup> </FormGroup>
<FormGroup className="d-grid col-md-4"> <FormGroup className="d-grid col-md-6">
<QrFormatDropdown format={format} setFormat={setFormat} /> <QrFormatDropdown format={format} setFormat={setFormat} />
</FormGroup> </FormGroup>
<FormGroup className="col-md-6"> <FormGroup className="col-md-6">
@ -84,19 +77,17 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
<CopyToClipboardIcon text={qrCodeUrl} /> <CopyToClipboardIcon text={qrCodeUrl} />
</div> </div>
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" /> <img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
{displayDownloadBtn && ( <div className="mt-3">
<div className="mt-3"> <Button
<Button block
block color="primary"
color="primary" onClick={() => {
onClick={() => { imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {}); }}
}} >
> Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" /> </Button>
</Button> </div>
</div>
)}
</div> </div>
</ModalBody> </ModalBody>
</Modal> </Modal>

View file

@ -0,0 +1,29 @@
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import type { ShlinkShortUrl } from '../../api-contract';
import { useRoutesPrefix } from '../../utils/routesPrefix';
import { urlEncodeShortCode } from './index';
export type LinkSuffix = 'visits' | 'edit';
export interface ShortUrlDetailLinkProps {
shortUrl?: ShlinkShortUrl | null;
suffix: LinkSuffix;
asLink?: boolean;
}
const buildUrl = (routePrefix: string, { shortCode, domain }: ShlinkShortUrl, suffix: LinkSuffix) => {
const query = domain ? `?domain=${domain}` : '';
return `${routePrefix}/short-code/${urlEncodeShortCode(shortCode)}/${suffix}${query}`;
};
export const ShortUrlDetailLink: FC<ShortUrlDetailLinkProps & Record<string | number, any>> = (
{ shortUrl, suffix, asLink, children, ...rest },
) => {
const routePrefix = useRoutesPrefix();
if (!asLink || !shortUrl) {
return <span {...rest}>{children}</span>;
}
return <Link to={buildUrl(routePrefix, shortUrl, suffix)} {...rest}>{children}</Link>;
};

View file

@ -1,6 +1,6 @@
import { Checkbox } from '@shlinkio/shlink-frontend-kit';
import type { ChangeEvent, FC, PropsWithChildren } from 'react'; import type { ChangeEvent, FC, PropsWithChildren } from 'react';
import { Checkbox } from '../../utils/Checkbox'; import { InfoTooltip } from '../../utils/components/InfoTooltip';
import { InfoTooltip } from '../../utils/InfoTooltip';
type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{ type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{
checked?: boolean; checked?: boolean;

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