mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-31 21:38:19 +03:00
Merge pull request #853 from acelaya-forks/feature/shlink-web-component
Split reusable content into separated components
This commit is contained in:
commit
a6134c6b42
429 changed files with 2933 additions and 2593 deletions
|
@ -7,8 +7,8 @@
|
|||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||
"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 shlink-web-component shlink-frontend-kit test",
|
||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||
"lint:css:fix": "npm run lint:css -- --fix",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
|
|
|
@ -2,10 +2,10 @@ import type { ReactNode } from 'react';
|
|||
import type { CardProps } from 'reactstrap';
|
||||
import { Card, CardBody, CardHeader } from 'reactstrap';
|
||||
|
||||
interface SimpleCardProps extends Omit<CardProps, 'title'> {
|
||||
export type SimpleCardProps = Omit<CardProps, 'title'> & {
|
||||
title?: ReactNode;
|
||||
bodyClassName?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => (
|
||||
<Card {...rest}>
|
3
shlink-frontend-kit/src/block/index.ts
Normal file
3
shlink-frontend-kit/src/block/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './Message';
|
||||
export * from './Result';
|
||||
export * from './SimpleCard';
|
|
@ -1,7 +1,7 @@
|
|||
import classNames from 'classnames';
|
||||
import { identity } from 'ramda';
|
||||
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
||||
import { useDomId } from './helpers/hooks';
|
||||
import { useDomId } from '../hooks';
|
||||
|
||||
export type BooleanControlProps = PropsWithChildren<{
|
||||
checked?: boolean;
|
||||
|
@ -10,9 +10,9 @@ export type BooleanControlProps = PropsWithChildren<{
|
|||
inline?: boolean;
|
||||
}>;
|
||||
|
||||
interface BooleanControlWithTypeProps extends BooleanControlProps {
|
||||
type BooleanControlWithTypeProps = BooleanControlProps & {
|
||||
type: 'switch' | 'checkbox';
|
||||
}
|
||||
};
|
||||
|
||||
export const BooleanControl: FC<BooleanControlWithTypeProps> = (
|
||||
{ checked = false, onChange = identity, className, children, type, inline = false },
|
|
@ -1,6 +1,6 @@
|
|||
import type { FC, PropsWithChildren } from 'react';
|
||||
import type { InputType } from 'reactstrap/types/lib/Input';
|
||||
import { useDomId } from '../helpers/hooks';
|
||||
import { useDomId } from '../hooks';
|
||||
import { LabeledFormGroup } from './LabeledFormGroup';
|
||||
|
||||
export type InputFormGroupProps = PropsWithChildren<{
|
|
@ -1,4 +1,4 @@
|
|||
@import '../utils/mixins/vertical-align';
|
||||
@import '../../../shlink-web-component/src/utils/mixins/vertical-align';
|
||||
|
||||
.search-field {
|
||||
position: relative;
|
|
@ -7,13 +7,13 @@ import './SearchField.scss';
|
|||
const DEFAULT_SEARCH_INTERVAL = 500;
|
||||
let timer: NodeJS.Timeout | null;
|
||||
|
||||
interface SearchFieldProps {
|
||||
type SearchFieldProps = {
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
large?: boolean;
|
||||
noBorder?: boolean;
|
||||
initialValue?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState(initialValue);
|
5
shlink-frontend-kit/src/form/index.ts
Normal file
5
shlink-frontend-kit/src/form/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from './Checkbox';
|
||||
export * from './ToggleSwitch';
|
||||
export * from './InputFormGroup';
|
||||
export * from './LabeledFormGroup';
|
||||
export * from './SearchField';
|
16
shlink-frontend-kit/src/hooks/index.ts
Normal file
16
shlink-frontend-kit/src/hooks/index.ts
Normal 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);
|
219
shlink-frontend-kit/src/index.scss
Normal file
219
shlink-frontend-kit/src/index.scss
Normal 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;
|
||||
}
|
||||
}
|
7
shlink-frontend-kit/src/index.ts
Normal file
7
shlink-frontend-kit/src/index.ts
Normal 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';
|
|
@ -1,6 +1,6 @@
|
|||
/* 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 {
|
||||
text-align: left;
|
|
@ -2,7 +2,7 @@ import classNames from 'classnames';
|
|||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import type { DropdownToggleProps } from 'reactstrap/types/lib/DropdownToggle';
|
||||
import { useToggle } from './helpers/hooks';
|
||||
import { useToggle } from '../hooks';
|
||||
import './DropdownBtn.scss';
|
||||
|
||||
export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & {
|
|
@ -1,4 +1,4 @@
|
|||
@import './base';
|
||||
@import '../base';
|
||||
|
||||
.nav-pills__nav {
|
||||
position: sticky !important;
|
3
shlink-frontend-kit/src/navigation/index.ts
Normal file
3
shlink-frontend-kit/src/navigation/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './DropdownBtn';
|
||||
export * from './RowDropdownBtn';
|
||||
export * from './NavPills';
|
|
@ -3,18 +3,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||
import classNames from 'classnames';
|
||||
import { toPairs } from 'ramda';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import type { Order, OrderDir } from './helpers/ordering';
|
||||
import { determineOrderDir } from './helpers/ordering';
|
||||
import type { Order, OrderDir } from './ordering';
|
||||
import { determineOrderDir } from './ordering';
|
||||
import './OrderingDropdown.scss';
|
||||
|
||||
export interface OrderingDropdownProps<T extends string = string> {
|
||||
export type OrderingDropdownProps<T extends string = string> = {
|
||||
items: Record<T, string>;
|
||||
order: Order<T>;
|
||||
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
||||
isButton?: boolean;
|
||||
right?: boolean;
|
||||
prefixed?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export function OrderingDropdown<T extends string = string>(
|
||||
{ items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>,
|
2
shlink-frontend-kit/src/ordering/index.ts
Normal file
2
shlink-frontend-kit/src/ordering/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './ordering';
|
||||
export * from './OrderingDropdown';
|
|
@ -1,9 +1,9 @@
|
|||
export type OrderDir = 'ASC' | 'DESC' | undefined;
|
||||
|
||||
export interface Order<Fields> {
|
||||
export type Order<Fields> = {
|
||||
field?: Fields;
|
||||
dir?: OrderDir;
|
||||
}
|
||||
};
|
||||
|
||||
export const determineOrderDir = <T extends string = string>(
|
||||
currentField: T,
|
||||
|
@ -22,7 +22,7 @@ export const determineOrderDir = <T extends string = string>(
|
|||
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) => {
|
||||
const greaterThan = dir === 'ASC' ? 1 : -1;
|
||||
const smallerThan = dir === 'ASC' ? -1 : 1;
|
|
@ -12,8 +12,6 @@ export const PRIMARY_DARK_COLOR = '#161b22';
|
|||
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
export const changeThemeInMarkup = (theme: Theme) =>
|
||||
document.getElementsByTagName('html')?.[0]?.setAttribute('data-theme', theme);
|
||||
export const changeThemeInMarkup = (theme: Theme) => document.querySelector('html')?.setAttribute('data-theme', theme);
|
||||
|
||||
export const isDarkThemeEnabled = (): boolean =>
|
||||
document.getElementsByTagName('html')?.[0]?.getAttribute('data-theme') === 'dark';
|
||||
export const isDarkThemeEnabled = (): boolean => document.querySelector('html')?.getAttribute('data-theme') === 'dark';
|
|
@ -1,4 +1,4 @@
|
|||
@import '../../utils/base';
|
||||
@import '../base';
|
||||
|
||||
.responsive-table__header {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
@ -1,5 +1,7 @@
|
|||
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 stringifyQuery = (query: any): string => qs.stringify(query, { arrayFormat: 'brackets' });
|
8
shlink-frontend-kit/test/__helpers__/setUpTest.ts
Normal file
8
shlink-frontend-kit/test/__helpers__/setUpTest.ts
Normal 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),
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { MessageProps } from '../../src/utils/Message';
|
||||
import { Message } from '../../src/utils/Message';
|
||||
import type { MessageProps } from '../../src';
|
||||
import { Message } from '../../src';
|
||||
|
||||
describe('<Message />', () => {
|
||||
const setUp = (props: PropsWithChildren<MessageProps> = {}) => render(<Message {...props} />);
|
|
@ -1,6 +1,6 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import type { ResultProps, ResultType } from '../../src/utils/Result';
|
||||
import { Result } from '../../src/utils/Result';
|
||||
import type { ResultProps, ResultType } from '../../src';
|
||||
import { Result } from '../../src';
|
||||
|
||||
describe('<Result />', () => {
|
||||
const setUp = (props: ResultProps) => render(<Result {...props} />);
|
|
@ -1,24 +1,27 @@
|
|||
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 />', () => {
|
||||
it('does not render title if not provided', () => {
|
||||
render(<SimpleCard />);
|
||||
setUp();
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders provided title', () => {
|
||||
render(<SimpleCard title="Cool title" />);
|
||||
setUp({ title: 'Cool title' });
|
||||
expect(screen.getByRole('heading')).toHaveTextContent('Cool title');
|
||||
});
|
||||
|
||||
it('renders children inside body', () => {
|
||||
render(<SimpleCard>Hello world</SimpleCard>);
|
||||
setUp({ children: 'Hello world' });
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { Checkbox } from '../../src/utils/Checkbox';
|
||||
import { Checkbox } from '../../src';
|
||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
|
||||
describe('<Checkbox />', () => {
|
|
@ -1,7 +1,7 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { DropdownBtnProps } from '../../src/utils/DropdownBtn';
|
||||
import { DropdownBtn } from '../../src/utils/DropdownBtn';
|
||||
import type { DropdownBtnProps } from '../../src';
|
||||
import { DropdownBtn } from '../../src';
|
||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
|
||||
describe('<DropdownBtn />', () => {
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable no-console */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { NavPillItem, NavPills } from '../../src/utils/NavPills';
|
||||
import { NavPillItem, NavPills } from '../../src';
|
||||
|
||||
describe('<NavPills />', () => {
|
||||
let originalError: typeof console.error;
|
|
@ -1,7 +1,7 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import type { DropdownBtnMenuProps } from '../../src/utils/RowDropdownBtn';
|
||||
import { RowDropdownBtn } from '../../src/utils/RowDropdownBtn';
|
||||
import type { DropdownBtnMenuProps } from '../../src';
|
||||
import { RowDropdownBtn } from '../../src';
|
||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
|
||||
describe('<RowDropdownBtn />', () => {
|
|
@ -1,8 +1,7 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import { values } from 'ramda';
|
||||
import type { OrderDir } from '../../src/utils/helpers/ordering';
|
||||
import type { OrderingDropdownProps } from '../../src/utils/OrderingDropdown';
|
||||
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
|
||||
import type { OrderDir, OrderingDropdownProps } from '../../src';
|
||||
import { OrderingDropdown } from '../../src';
|
||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
|
||||
describe('<OrderingDropdown />', () => {
|
|
@ -1,5 +1,5 @@
|
|||
import type { OrderDir } from '../../../src/utils/helpers/ordering';
|
||||
import { determineOrderDir, orderToString, stringToOrder } from '../../../src/utils/helpers/ordering';
|
||||
import type { OrderDir } from '../../src';
|
||||
import { determineOrderDir, orderToString, stringToOrder } from '../../src';
|
||||
|
||||
describe('ordering', () => {
|
||||
describe('determineOrderDir', () => {
|
|
@ -1,4 +1,4 @@
|
|||
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query';
|
||||
import { parseQuery, stringifyQuery } from '../../src/utils';
|
||||
|
||||
describe('query', () => {
|
||||
describe('parseQuery', () => {
|
|
@ -1,14 +1,14 @@
|
|||
@import '../utils/base';
|
||||
@import '@shlinkio/shlink-frontend-kit/base';
|
||||
|
||||
.menu-layout__swipeable {
|
||||
.shlink-layout__swipeable {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.menu-layout__swipeable-inner {
|
||||
.shlink-layout__swipeable-inner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.menu-layout__burger-icon {
|
||||
.shlink-layout__burger-icon {
|
||||
display: none;
|
||||
transition: color 300ms;
|
||||
position: fixed;
|
||||
|
@ -23,11 +23,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.menu-layout__burger-icon--active {
|
||||
.shlink-layout__burger-icon--active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-layout__container.menu-layout__container {
|
||||
.shlink-layout__container.shlink-layout__container {
|
||||
padding: 20px 0 0;
|
||||
min-height: 100%;
|
||||
|
79
shlink-web-component/src/Main.tsx
Normal file
79
shlink-web-component/src/Main.tsx
Normal 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>
|
||||
);
|
||||
};
|
67
shlink-web-component/src/ShlinkWebComponent.tsx
Normal file
67
shlink-web-component/src/ShlinkWebComponent.tsx
Normal 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>
|
||||
);
|
||||
};
|
63
shlink-web-component/src/api-contract/ShlinkApiClient.ts
Normal file
63
shlink-web-component/src/api-contract/ShlinkApiClient.ts
Normal 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>;
|
||||
};
|
3
shlink-web-component/src/api-contract/index.ts
Normal file
3
shlink-web-component/src/api-contract/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './errors';
|
||||
export * from './ShlinkApiClient';
|
||||
export * from './types';
|
|
@ -1,10 +1,66 @@
|
|||
import type { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||
import type { Order } from '../../utils/helpers/ordering';
|
||||
import type { OptionalString } from '../../utils/utils';
|
||||
import type { Visit } from '../../visits/types';
|
||||
import type { Order } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { Nullable, OptionalString } from '../utils/helpers';
|
||||
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 {
|
||||
data: ShortUrl[];
|
||||
data: ShlinkShortUrl[];
|
||||
pagination: ShlinkPaginator;
|
||||
}
|
||||
|
||||
|
@ -70,7 +126,7 @@ export interface ShlinkVisitsOverview {
|
|||
}
|
||||
|
||||
export interface ShlinkVisitsParams {
|
||||
domain?: OptionalString;
|
||||
domain?: string | null;
|
||||
page?: number;
|
||||
itemsPerPage?: number;
|
||||
startDate?: string;
|
||||
|
@ -78,13 +134,6 @@ export interface ShlinkVisitsParams {
|
|||
excludeBots?: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlData extends ShortUrlMeta {
|
||||
longUrl?: string;
|
||||
title?: string;
|
||||
validateUrl?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface ShlinkDomainRedirects {
|
||||
baseUrlRedirect: string | null;
|
||||
regular404Redirect: string | null;
|
||||
|
@ -98,12 +147,12 @@ export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects
|
|||
export interface ShlinkDomain {
|
||||
domain: string;
|
||||
isDefault: boolean;
|
||||
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
|
||||
redirects: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
export interface ShlinkDomainsResponse {
|
||||
data: ShlinkDomain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
|
||||
defaultRedirects: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
export type TagsFilteringMode = 'all' | 'any';
|
|
@ -2,16 +2,11 @@ import type {
|
|||
InvalidArgumentError,
|
||||
InvalidShortUrlDeletion,
|
||||
ProblemDetailsError,
|
||||
RegularNotFound } from '../types/errors';
|
||||
RegularNotFound } from './errors';
|
||||
import {
|
||||
ErrorTypeV2,
|
||||
ErrorTypeV3,
|
||||
} from '../types/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);
|
||||
} from './errors';
|
||||
|
||||
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
||||
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 =>
|
||||
(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);
|
|
@ -1,4 +1,4 @@
|
|||
@import '../utils/base';
|
||||
@import '@shlinkio/shlink-frontend-kit/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.aside-menu {
|
||||
|
@ -58,24 +58,6 @@
|
|||
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 {
|
||||
margin-left: 8px;
|
||||
}
|
|
@ -3,7 +3,6 @@ import {
|
|||
faHome as overviewIcon,
|
||||
faLink as createIcon,
|
||||
faList as listIcon,
|
||||
faPen as editIcon,
|
||||
faTags as tagsIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
@ -11,13 +10,10 @@ import classNames from 'classnames';
|
|||
import type { FC } from 'react';
|
||||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
import { isServerWithId } from '../servers/data';
|
||||
import type { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
selectedServer: SelectedServer;
|
||||
routePrefix: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
||||
|
@ -36,16 +32,12 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
|||
</NavLink>
|
||||
);
|
||||
|
||||
export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||
) => {
|
||||
const hasId = isServerWithId(selectedServer);
|
||||
const serverId = hasId ? selectedServer.id : '';
|
||||
export const AsideMenu: FC<AsideMenuProps> = ({ routePrefix, showOnMobile = false }) => {
|
||||
const { pathname } = useLocation();
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
||||
const buildPath = (suffix: string) => `${routePrefix}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
|
@ -73,17 +65,6 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||
<span className="aside-menu__item-text">Manage domains</span>
|
||||
</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>
|
||||
</aside>
|
||||
);
|
|
@ -1,5 +1,5 @@
|
|||
import type { ProblemDetailsError } from './types/errors';
|
||||
import { isInvalidArgumentError } from './utils';
|
||||
import type { ProblemDetailsError } from '../api-contract';
|
||||
import { isInvalidArgumentError } from '../api-contract/utils';
|
||||
|
||||
export interface ShlinkApiErrorProps {
|
||||
errorData?: ProblemDetailsError;
|
42
shlink-web-component/src/container/index.ts
Normal file
42
shlink-web-component/src/container/index.ts
Normal 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);
|
23
shlink-web-component/src/container/provideServices.ts
Normal file
23
shlink-web-component/src/container/provideServices.ts
Normal 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);
|
||||
};
|
65
shlink-web-component/src/container/store.ts
Normal file
65
shlink-web-component/src/container/store.ts
Normal 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;
|
||||
};
|
|
@ -3,9 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||
import type { FC } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import type { ShlinkDomainRedirects } from '../api/types';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
import type { OptionalString } from '../utils/utils';
|
||||
import type { ShlinkDomainRedirects } from '../api-contract';
|
||||
import type { Domain } from './data';
|
||||
import { DomainDropdown } from './helpers/DomainDropdown';
|
||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||
|
@ -16,10 +14,9 @@ interface DomainRowProps {
|
|||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
||||
const Nr: FC<{ fallback?: string | null }> = ({ fallback }) => (
|
||||
<span className="text-muted">
|
||||
{!fallback && <small>No redirect</small>}
|
||||
{fallback && <>{fallback} <small>(as fallback)</small></>}
|
||||
|
@ -33,7 +30,7 @@ const DefaultDomain: FC = () => (
|
|||
);
|
||||
|
||||
export const DomainRow: FC<DomainRowProps> = (
|
||||
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
|
||||
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects },
|
||||
) => {
|
||||
const { domain: authority, isDefault, redirects, status } = domain;
|
||||
|
||||
|
@ -58,7 +55,7 @@ export const DomainRow: FC<DomainRowProps> = (
|
|||
<DomainStatusIcon status={status} />
|
||||
</td>
|
||||
<td className="responsive-table__cell text-end">
|
||||
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} />
|
||||
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
|
@ -1,4 +1,4 @@
|
|||
@import '../utils/base';
|
||||
@import '@shlinkio/shlink-frontend-kit/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
|
|
@ -1,11 +1,10 @@
|
|||
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { DropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { useEffect } from 'react';
|
||||
import type { InputProps } 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 './DomainSelector.scss';
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { Message, Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { ShlinkApiError } from '../api/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 { ShlinkApiError } from '../common/ShlinkApiError';
|
||||
import { DomainRow } from './DomainRow';
|
||||
import type { EditDomainRedirects } from './reducers/domainRedirects';
|
||||
import type { DomainsList } from './reducers/domainsList';
|
||||
|
@ -16,13 +12,12 @@ interface ManageDomainsProps {
|
|||
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
domainsList: DomainsList;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
|
||||
|
||||
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 resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||
|
@ -59,7 +54,6 @@ export const ManageDomains: FC<ManageDomainsProps> = (
|
|||
editDomainRedirects={editDomainRedirects}
|
||||
checkDomainHealth={checkDomainHealth}
|
||||
defaultRedirects={resolvedDefaultRedirects}
|
||||
selectedServer={selectedServer}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
|
@ -1,4 +1,4 @@
|
|||
import type { ShlinkDomain } from '../../api/types';
|
||||
import type { ShlinkDomain } from '../../api-contract';
|
||||
|
||||
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import type { SelectedServer } from '../../servers/data';
|
||||
import { getServerId } from '../../servers/data';
|
||||
import { useFeature } from '../../utils/helpers/features';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
|
||||
import { useFeature } from '../../utils/features';
|
||||
import { useRoutesPrefix } from '../../utils/routesPrefix';
|
||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||
import type { Domain } from '../data';
|
||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
@ -16,27 +14,24 @@ import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
|
|||
interface DomainDropdownProps {
|
||||
domain: Domain;
|
||||
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 { isDefault } = domain;
|
||||
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer);
|
||||
const withVisits = useFeature('domainVisits', selectedServer);
|
||||
const serverId = getServerId(selectedServer);
|
||||
const withVisits = useFeature('domainVisits');
|
||||
const routesPrefix = useRoutesPrefix();
|
||||
|
||||
return (
|
||||
<RowDropdownBtn>
|
||||
{withVisits && (
|
||||
<DropdownItem
|
||||
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
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
|
||||
<DropdownItem onClick={toggleModal}>
|
||||
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
|
||||
</DropdownItem>
|
||||
|
|
@ -4,11 +4,11 @@ import {
|
|||
faTimes as invalidIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { useElementRef } from '../../utils/helpers/hooks';
|
||||
import type { MediaMatcher } from '../../utils/types';
|
||||
import type { DomainStatus } from '../data';
|
||||
|
|
@ -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 { useState } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import type { ShlinkDomain } from '../../api/types';
|
||||
import type { InputFormGroupProps } from '../../utils/forms/InputFormGroup';
|
||||
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
|
||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||
import type { ShlinkDomain } from '../../api-contract';
|
||||
import { InfoTooltip } from '../../utils/components/InfoTooltip';
|
||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/helpers';
|
||||
import type { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
||||
interface EditDomainRedirectsModalProps {
|
20
shlink-web-component/src/domains/reducers/domainRedirects.ts
Normal file
20
shlink-web-component/src/domains/reducers/domainRedirects.ts
Normal 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 };
|
||||
},
|
||||
);
|
|
@ -1,12 +1,8 @@
|
|||
import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit';
|
||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import type { ShlinkDomainRedirects } from '../../api/types';
|
||||
import type { ProblemDetailsError } from '../../api/types/errors';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { hasServerData } from '../../servers/data';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||
import type { ProblemDetailsError, ShlinkApiClient, ShlinkDomainRedirects } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import type { Domain, DomainStatus } from '../data';
|
||||
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 });
|
||||
|
||||
export const domainsListReducerCreator = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
apiClientFactory: () => ShlinkApiClient,
|
||||
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
|
||||
) => {
|
||||
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise<ListDomains> => {
|
||||
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
|
||||
const { data, defaultRedirects } = await shlinkListDomains();
|
||||
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (): Promise<ListDomains> => {
|
||||
const { data, defaultRedirects } = await apiClientFactory().listDomains();
|
||||
|
||||
return {
|
||||
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||
|
@ -60,22 +55,9 @@ export const domainsListReducerCreator = (
|
|||
|
||||
const checkDomainHealth = createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/checkDomainHealth`,
|
||||
async (domain: string, { getState }): Promise<ValidateDomain> => {
|
||||
const { selectedServer } = getState();
|
||||
|
||||
if (!hasServerData(selectedServer)) {
|
||||
return { domain, status: 'invalid' };
|
||||
}
|
||||
|
||||
async (domain: string): Promise<ValidateDomain> => {
|
||||
try {
|
||||
const { url, ...rest } = selectedServer;
|
||||
const { health } = buildShlinkApiClient({
|
||||
...rest,
|
||||
url: replaceAuthorityFromUri(url, domain),
|
||||
});
|
||||
|
||||
const { status } = await health();
|
||||
|
||||
const { status } = await apiClientFactory().health(domain);
|
||||
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
|
||||
} catch (e) {
|
||||
return { domain, status: 'invalid' };
|
|
@ -1,6 +1,6 @@
|
|||
import type Bottle from 'bottlejs';
|
||||
import { prop } from 'ramda';
|
||||
import type { ConnectDecorator } from '../../container/types';
|
||||
import type { ConnectDecorator } from '../../container';
|
||||
import { DomainSelector } from '../DomainSelector';
|
||||
import { ManageDomains } from '../ManageDomains';
|
||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
@ -13,7 +13,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
|
||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||
bottle.decorator('ManageDomains', connect(
|
||||
['domainsList', 'selectedServer'],
|
||||
['domainsList'],
|
||||
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
||||
));
|
||||
|
||||
|
@ -21,7 +21,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
bottle.serviceFactory(
|
||||
'domainsListReducerCreator',
|
||||
domainsListReducerCreator,
|
||||
'buildShlinkApiClient',
|
||||
'apiClientFactory',
|
||||
'editDomainRedirects',
|
||||
);
|
||||
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
|
||||
|
@ -29,6 +29,6 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
// Actions
|
||||
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
|
||||
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
|
||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'apiClientFactory');
|
||||
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
|
||||
};
|
2
shlink-web-component/src/index.scss
Normal file
2
shlink-web-component/src/index.scss
Normal file
|
@ -0,0 +1,2 @@
|
|||
@import './tags/react-tag-autocomplete';
|
||||
@import './utils/StickyCardPaginator.scss';
|
18
shlink-web-component/src/index.ts
Normal file
18
shlink-web-component/src/index.ts
Normal 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';
|
|
@ -23,6 +23,7 @@ export function boundToMercureHub<T = {}>(
|
|||
const { interval } = mercureInfo;
|
||||
const params = useParams();
|
||||
|
||||
// Every time mercure info changes, re-bind
|
||||
useEffect(() => {
|
||||
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
|
||||
const topics = getTopicsForProps(props, params);
|
|
@ -1,7 +1,7 @@
|
|||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import type { ShlinkMercureInfo } from '../../api/types';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import type { ShlinkApiClient, ShlinkMercureInfo } from '../../api-contract';
|
||||
import { createAsyncThunk } from '../../utils/redux';
|
||||
import type { Settings } from '../../utils/settings';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/mercure';
|
||||
|
||||
|
@ -16,16 +16,15 @@ const initialState: MercureInfo = {
|
|||
error: false,
|
||||
};
|
||||
|
||||
export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
|
||||
export const mercureInfoReducerCreator = (apiClientFactory: () => ShlinkApiClient) => {
|
||||
const loadMercureInfo = createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/loadMercureInfo`,
|
||||
(_: void, { getState }): Promise<ShlinkMercureInfo> => {
|
||||
const { settings } = getState();
|
||||
if (!settings.realTimeUpdates.enabled) {
|
||||
({ realTimeUpdates }: Settings): Promise<ShlinkMercureInfo> => {
|
||||
if (realTimeUpdates && !realTimeUpdates.enabled) {
|
||||
throw new Error('Real time updates not enabled');
|
||||
}
|
||||
|
||||
return buildShlinkApiClient(getState).mercureInfo();
|
||||
return apiClientFactory().mercureInfo();
|
||||
},
|
||||
);
|
||||
|
|
@ -4,7 +4,7 @@ import { mercureInfoReducerCreator } from '../reducers/mercureInfo';
|
|||
|
||||
export const provideServices = (bottle: Bottle) => {
|
||||
// Reducer
|
||||
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'apiClientFactory');
|
||||
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
|
||||
|
||||
// Actions
|
|
@ -2,20 +2,18 @@ import type { FC } from 'react';
|
|||
import { useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
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 { Topics } from '../mercure/helpers/Topics';
|
||||
import type { Settings } from '../settings/reducers/settings';
|
||||
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
|
||||
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
|
||||
import type { TagsList } from '../tags/reducers/tagsList';
|
||||
import { useFeature } from '../utils/helpers/features';
|
||||
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 { SelectedServer } from './data';
|
||||
import { getServerId } from './data';
|
||||
import { HighlightCard } from './helpers/HighlightCard';
|
||||
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
|
||||
|
||||
|
@ -24,10 +22,8 @@ interface OverviewConnectProps {
|
|||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||
listTags: Function;
|
||||
tagsList: TagsList;
|
||||
selectedServer: SelectedServer;
|
||||
visitsOverview: VisitsOverview;
|
||||
loadVisitsOverview: Function;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export const Overview = (
|
||||
|
@ -38,17 +34,15 @@ export const Overview = (
|
|||
listShortUrls,
|
||||
listTags,
|
||||
tagsList,
|
||||
selectedServer,
|
||||
loadVisitsOverview,
|
||||
visitsOverview,
|
||||
settings: { visits },
|
||||
}: OverviewConnectProps) => {
|
||||
const { loading, shortUrls } = shortUrlsList;
|
||||
const { loading: loadingTags } = tagsList;
|
||||
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
|
||||
const serverId = getServerId(selectedServer);
|
||||
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
|
||||
const routesPrefix = useRoutesPrefix();
|
||||
const navigate = useNavigate();
|
||||
const visits = useSetting('visits');
|
||||
|
||||
useEffect(() => {
|
||||
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">
|
||||
<VisitsHighlightCard
|
||||
title="Visits"
|
||||
link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined}
|
||||
link={`${routesPrefix}/non-orphan-visits`}
|
||||
excludeBots={visits?.excludeBots ?? false}
|
||||
loading={loadingVisits}
|
||||
visitsSummary={nonOrphanVisits}
|
||||
|
@ -71,19 +65,19 @@ export const Overview = (
|
|||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<VisitsHighlightCard
|
||||
title="Orphan visits"
|
||||
link={`/server/${serverId}/orphan-visits`}
|
||||
link={`${routesPrefix}/orphan-visits`}
|
||||
excludeBots={visits?.excludeBots ?? false}
|
||||
loading={loadingVisits}
|
||||
visitsSummary={orphanVisits}
|
||||
/>
|
||||
</div>
|
||||
<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)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
<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)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
|
@ -93,7 +87,7 @@ export const Overview = (
|
|||
<CardHeader>
|
||||
<span className="d-sm-none">Create a short URL</span>
|
||||
<h5 className="d-none d-sm-inline">Create a short URL</h5>
|
||||
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options »</Link>
|
||||
<Link className="float-end" to={`${routesPrefix}/create-short-url`}>Advanced options »</Link>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CreateShortUrl basicMode />
|
||||
|
@ -103,14 +97,13 @@ export const Overview = (
|
|||
<CardHeader>
|
||||
<span className="d-sm-none">Recently created URLs</span>
|
||||
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
|
||||
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all »</Link>
|
||||
<Link className="float-end" to={`${routesPrefix}/list-short-urls/1`}>See all »</Link>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ShortUrlsTable
|
||||
shortUrlsList={shortUrlsList}
|
||||
selectedServer={selectedServer}
|
||||
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>
|
||||
</Card>
|
|
@ -1,4 +1,4 @@
|
|||
@import '../../utils/base';
|
||||
@import '@shlinkio/shlink-frontend-kit/base';
|
||||
|
||||
.highlight-card.highlight-card {
|
||||
text-align: center;
|
||||
|
@ -11,7 +11,7 @@
|
|||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
opacity: 0.1;
|
||||
opacity: .1;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
|
||||
import { useElementRef } from '../../utils/helpers/hooks';
|
||||
import './HighlightCard.scss';
|
||||
|
||||
export type HighlightCardProps = PropsWithChildren<{
|
||||
title: string;
|
||||
link?: string;
|
||||
link: string;
|
||||
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 }) => {
|
||||
const ref = useElementRef<HTMLElement>();
|
||||
|
@ -20,7 +20,7 @@ export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link, t
|
|||
return (
|
||||
<>
|
||||
<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>
|
||||
<CardText tag="h2">{children}</CardText>
|
||||
</Card>
|
|
@ -14,7 +14,7 @@ export const VisitsHighlightCard: FC<VisitsHighlightCardProps> = ({ loading, exc
|
|||
<HighlightCard
|
||||
tooltip={
|
||||
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
|
||||
}
|
||||
{...rest}
|
|
@ -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'],
|
||||
));
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import type { ShlinkCreateShortUrlData } from '@shlinkio/shlink-web-component/api-contract';
|
||||
import type { FC } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
import type { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
|
||||
import type { ShortUrlData } from './data';
|
||||
import type { ShortUrlCreationSettings } from '../utils/settings';
|
||||
import { useSetting } from '../utils/settings';
|
||||
import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
||||
import type { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||
import type { ShortUrlFormProps } from './ShortUrlForm';
|
||||
|
@ -12,14 +12,12 @@ export interface CreateShortUrlProps {
|
|||
}
|
||||
|
||||
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
|
||||
settings: Settings;
|
||||
shortUrlCreation: ShortUrlCreation;
|
||||
selectedServer: SelectedServer;
|
||||
createShortUrl: (data: ShortUrlData) => Promise<void>;
|
||||
createShortUrl: (data: ShlinkCreateShortUrlData) => Promise<void>;
|
||||
resetCreateShortUrl: () => void;
|
||||
}
|
||||
|
||||
const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({
|
||||
const getInitialState = (settings?: ShortUrlCreationSettings): ShlinkCreateShortUrlData => ({
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
customSlug: '',
|
||||
|
@ -35,16 +33,15 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
|
|||
});
|
||||
|
||||
export const CreateShortUrl = (
|
||||
ShortUrlForm: FC<ShortUrlFormProps>,
|
||||
ShortUrlForm: FC<ShortUrlFormProps<ShlinkCreateShortUrlData>>,
|
||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||
) => ({
|
||||
createShortUrl,
|
||||
shortUrlCreation,
|
||||
resetCreateShortUrl,
|
||||
selectedServer,
|
||||
basicMode = false,
|
||||
settings: { shortUrlCreation: shortUrlCreationSettings },
|
||||
}: CreateShortUrlConnectProps) => {
|
||||
const shortUrlCreationSettings = useSetting('shortUrlCreation');
|
||||
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]);
|
||||
|
||||
return (
|
||||
|
@ -52,9 +49,8 @@ export const CreateShortUrl = (
|
|||
<ShortUrlForm
|
||||
initialState={initialState}
|
||||
saving={shortUrlCreation.saving}
|
||||
selectedServer={selectedServer}
|
||||
mode={basicMode ? 'create-basic' : 'create'}
|
||||
onSave={async (data: ShortUrlData) => {
|
||||
onSave={async (data) => {
|
||||
resetCreateShortUrl();
|
||||
return createShortUrl(data);
|
||||
}}
|
|
@ -1,17 +1,15 @@
|
|||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
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 { useEffect, useMemo } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Button, Card } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
import type { Settings } from '../settings/reducers/settings';
|
||||
import { ShlinkApiError } from '../common/ShlinkApiError';
|
||||
import { useGoBack } from '../utils/helpers/hooks';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import { Message } from '../utils/Message';
|
||||
import { Result } from '../utils/Result';
|
||||
import { useSetting } from '../utils/settings';
|
||||
import type { ShortUrlIdentifier } from './data';
|
||||
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
|
||||
import type { ShortUrlDetail } from './reducers/shortUrlDetail';
|
||||
|
@ -19,17 +17,13 @@ import type { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reduce
|
|||
import type { ShortUrlFormProps } from './ShortUrlForm';
|
||||
|
||||
interface EditShortUrlConnectProps {
|
||||
settings: Settings;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
|
||||
editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
|
||||
}
|
||||
|
||||
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||
settings: { shortUrlCreation: shortUrlCreationSettings },
|
||||
selectedServer,
|
||||
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps<ShlinkEditShortUrlData>>) => ({
|
||||
shortUrlDetail,
|
||||
getShortUrlDetail,
|
||||
shortUrlEdition,
|
||||
|
@ -41,6 +35,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
||||
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||
const shortUrlCreationSettings = useSetting('shortUrlCreation');
|
||||
const initialState = useMemo(
|
||||
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
|
||||
[shortUrl, shortUrlCreationSettings],
|
||||
|
@ -80,7 +75,6 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
|||
<ShortUrlForm
|
||||
initialState={initialState}
|
||||
saving={saving}
|
||||
selectedServer={selectedServer}
|
||||
mode="edit"
|
||||
onSave={async (shortUrlData) => {
|
||||
if (!shortUrl) {
|
|
@ -1,6 +1,6 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import type { ShlinkPaginator } from '../api/types';
|
||||
import type { ShlinkPaginator } from '../api-contract';
|
||||
import type {
|
||||
NumberOrEllipsis } from '../utils/helpers/pagination';
|
||||
import {
|
||||
|
@ -9,17 +9,18 @@ import {
|
|||
prettifyPageNumber,
|
||||
progressivePagination,
|
||||
} from '../utils/helpers/pagination';
|
||||
import { useRoutesPrefix } from '../utils/routesPrefix';
|
||||
|
||||
interface PaginatorProps {
|
||||
paginator?: ShlinkPaginator;
|
||||
serverId: string;
|
||||
currentQueryString?: string;
|
||||
}
|
||||
|
||||
export const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
|
||||
export const Paginator = ({ paginator, currentQueryString = '' }: PaginatorProps) => {
|
||||
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
||||
const routesPrefix = useRoutesPrefix();
|
||||
const urlForPage = (pageNumber: NumberOrEllipsis) =>
|
||||
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||
`${routesPrefix}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||
|
||||
if (pagesCount <= 1) {
|
||||
return <div className="pb-3" />; // Return some space
|
|
@ -1,4 +1,4 @@
|
|||
@import '../utils/base';
|
||||
@import '@shlinkio/shlink-frontend-kit/base';
|
||||
|
||||
.short-url-form p:last-child {
|
||||
margin-bottom: 0;
|
|
@ -1,6 +1,7 @@
|
|||
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons';
|
||||
import { faDesktop } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import classNames from 'classnames';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { isEmpty, pipe, replace, trim } from 'ramda';
|
||||
|
@ -8,18 +9,15 @@ import type { ChangeEvent, FC } from 'react';
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||
import type { InputType } from 'reactstrap/types/lib/Input';
|
||||
import type { ShlinkCreateShortUrlData, ShlinkDeviceLongUrls, ShlinkEditShortUrlData } from '../api-contract';
|
||||
import type { DomainSelectorProps } from '../domains/DomainSelector';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
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 { DateTimeInput } from '../utils/dates/DateTimeInput';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import { useFeature } from '../utils/helpers/features';
|
||||
import { IconInput } from '../utils/IconInput';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||
import type { DeviceLongUrls, ShortUrlData } from './data';
|
||||
import { formatIsoDate } from '../utils/dates/helpers/date';
|
||||
import { useFeature } from '../utils/features';
|
||||
import { handleEventPreventingDefault, hasValue } from '../utils/helpers';
|
||||
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
|
||||
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
|
||||
import './ShortUrlForm.scss';
|
||||
|
@ -29,26 +27,32 @@ export type Mode = 'create' | 'create-basic' | 'edit';
|
|||
type DateFields = 'validSince' | 'validUntil';
|
||||
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;
|
||||
saving: boolean;
|
||||
initialState: ShortUrlData;
|
||||
onSave: (shortUrlData: ShortUrlData) => Promise<unknown>;
|
||||
selectedServer: SelectedServer;
|
||||
initialState: T;
|
||||
onSave: (shortUrlData: T) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
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 = (
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
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 reset = () => setShortUrlData(initialState);
|
||||
const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer);
|
||||
const supportsDeviceLongUrls = useFeature('deviceLongUrls');
|
||||
|
||||
const isEdit = mode === 'edit';
|
||||
const isCreation = isCreationData(shortUrlData);
|
||||
const isBasicMode = mode === 'create-basic';
|
||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||
const setResettableValue = (value: string, initialValue?: any) => {
|
||||
|
@ -84,13 +88,14 @@ export const ShortUrlForm = (
|
|||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
// @ts-expect-error FIXME Make sure id is a key from T
|
||||
value={shortUrlData[id] ?? ''}
|
||||
onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))}
|
||||
{...props}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => (
|
||||
const renderDeviceLongUrlInput = (id: keyof ShlinkDeviceLongUrls, placeholder: string, icon: IconProp) => (
|
||||
<IconInput
|
||||
icon={icon}
|
||||
id={id}
|
||||
|
@ -136,8 +141,6 @@ export const ShortUrlForm = (
|
|||
</>
|
||||
);
|
||||
|
||||
const showForwardQueryControl = useFeature('forwardQuery', selectedServer);
|
||||
|
||||
return (
|
||||
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
|
||||
{isBasicMode && basicComponents}
|
||||
|
@ -175,7 +178,7 @@ export const ShortUrlForm = (
|
|||
title: setResettableValue(target.value, initialState.title),
|
||||
}),
|
||||
})}
|
||||
{!isEdit && (
|
||||
{!isEdit && isCreation && (
|
||||
<>
|
||||
<Row>
|
||||
<div className="col-lg-6">
|
||||
|
@ -220,7 +223,7 @@ export const ShortUrlForm = (
|
|||
>
|
||||
Validate URL
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
{!isEdit && (
|
||||
{!isEdit && isCreation && (
|
||||
<p>
|
||||
<Checkbox
|
||||
inline
|
||||
|
@ -244,15 +247,13 @@ export const ShortUrlForm = (
|
|||
>
|
||||
Make it crawlable
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
{showForwardQueryControl && (
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
||||
checked={shortUrlData.forwardQuery}
|
||||
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
||||
>
|
||||
Forward query params on redirect
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
)}
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
||||
checked={shortUrlData.forwardQuery}
|
||||
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
||||
>
|
||||
Forward query params on redirect
|
||||
</ShortUrlFormCheckboxGroup>
|
||||
</SimpleCard>
|
||||
</div>
|
||||
</Row>
|
|
@ -1,20 +1,18 @@
|
|||
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||
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 { isEmpty, pipe } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
import type { Settings } from '../settings/reducers/settings';
|
||||
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import type { DateRange } from '../utils/helpers/dateIntervals';
|
||||
import { datesToDateRange } from '../utils/helpers/dateIntervals';
|
||||
import { useFeature } from '../utils/helpers/features';
|
||||
import type { OrderDir } from '../utils/helpers/ordering';
|
||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||
import { SearchField } from '../utils/SearchField';
|
||||
import { formatIsoDate } from '../utils/dates/helpers/date';
|
||||
import type { DateRange } from '../utils/dates/helpers/dateIntervals';
|
||||
import { datesToDateRange } from '../utils/dates/helpers/dateIntervals';
|
||||
import { useFeature } from '../utils/features';
|
||||
import { useSetting } from '../utils/settings';
|
||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||
import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
|
||||
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
||||
|
@ -23,9 +21,7 @@ import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
|
|||
import './ShortUrlsFilteringBar.scss';
|
||||
|
||||
interface ShortUrlsFilteringProps {
|
||||
selectedServer: SelectedServer;
|
||||
order: ShortUrlsOrder;
|
||||
settings: Settings;
|
||||
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
|
||||
className?: string;
|
||||
shortUrlsAmount?: number;
|
||||
|
@ -34,7 +30,7 @@ interface ShortUrlsFilteringProps {
|
|||
export const ShortUrlsFilteringBar = (
|
||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => {
|
||||
): FC<ShortUrlsFilteringProps> => ({ className, shortUrlsAmount, order, handleOrderBy }) => {
|
||||
const [filter, toFirstPage] = useShortUrlsQuery();
|
||||
const {
|
||||
search,
|
||||
|
@ -46,7 +42,8 @@ export const ShortUrlsFilteringBar = (
|
|||
excludePastValidUntil,
|
||||
tagsMode = 'any',
|
||||
} = filter;
|
||||
const supportsDisabledFiltering = useFeature('filterDisabledUrls', selectedServer);
|
||||
const supportsDisabledFiltering = useFeature('filterDisabledUrls');
|
||||
const visitsSettings = useSetting('visits');
|
||||
|
||||
const setDates = pipe(
|
||||
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
||||
|
@ -60,7 +57,6 @@ export const ShortUrlsFilteringBar = (
|
|||
(searchTerm) => toFirstPage({ search: searchTerm }),
|
||||
);
|
||||
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
|
||||
const canChangeTagsMode = useFeature('allTagsFiltering', selectedServer);
|
||||
const toggleTagsMode = pipe(
|
||||
() => (tagsMode === 'any' ? 'all' : 'any'),
|
||||
(mode) => toFirstPage({ tagsMode: mode }),
|
||||
|
@ -72,7 +68,7 @@ export const ShortUrlsFilteringBar = (
|
|||
|
||||
<InputGroup className="mt-3">
|
||||
<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">
|
||||
<FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
|
||||
|
@ -97,7 +93,7 @@ export const ShortUrlsFilteringBar = (
|
|||
<ShortUrlsFilterDropdown
|
||||
className="ms-0 ms-md-2 mt-3 mt-md-0"
|
||||
selected={{
|
||||
excludeBots: excludeBots ?? settings.visits?.excludeBots,
|
||||
excludeBots: excludeBots ?? visitsSettings?.excludeBots,
|
||||
excludeMaxVisitsReached,
|
||||
excludePastValidUntil,
|
||||
}}
|
|
@ -1,17 +1,14 @@
|
|||
import type { OrderDir } from '@shlinkio/shlink-frontend-kit';
|
||||
import { determineOrderDir } from '@shlinkio/shlink-frontend-kit';
|
||||
import { pipe } from 'ramda';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
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 { Topics } from '../mercure/helpers/Topics';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
import { getServerId } from '../servers/data';
|
||||
import type { Settings } from '../settings/reducers/settings';
|
||||
import { DEFAULT_SHORT_URLS_ORDERING } from '../settings/reducers/settings';
|
||||
import { useFeature } from '../utils/helpers/features';
|
||||
import type { OrderDir } from '../utils/helpers/ordering';
|
||||
import { determineOrderDir } from '../utils/helpers/ordering';
|
||||
import { useFeature } from '../utils/features';
|
||||
import { useSettings } from '../utils/settings';
|
||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
|
||||
import { useShortUrlsQuery } from './helpers/hooks';
|
||||
|
@ -21,20 +18,23 @@ import type { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
|
|||
import type { ShortUrlsTableType } from './ShortUrlsTable';
|
||||
|
||||
interface ShortUrlsListProps {
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
|
||||
field: 'dateCreated',
|
||||
dir: 'DESC',
|
||||
};
|
||||
|
||||
export const ShortUrlsList = (
|
||||
ShortUrlsTable: ShortUrlsTableType,
|
||||
ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
|
||||
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
|
||||
const serverId = getServerId(selectedServer);
|
||||
) => boundToMercureHub(({ listShortUrls, shortUrlsList }: ShortUrlsListProps) => {
|
||||
const { page } = useParams();
|
||||
const location = useLocation();
|
||||
const [filter, toFirstPage] = useShortUrlsQuery();
|
||||
const settings = useSettings();
|
||||
const {
|
||||
tags,
|
||||
search,
|
||||
|
@ -52,7 +52,7 @@ export const ShortUrlsList = (
|
|||
);
|
||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
|
||||
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls', selectedServer);
|
||||
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls');
|
||||
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
||||
toFirstPage({ orderBy: { field, dir } });
|
||||
setActualOrderBy({ field, dir });
|
||||
|
@ -101,22 +101,19 @@ export const ShortUrlsList = (
|
|||
return (
|
||||
<>
|
||||
<ShortUrlsFilteringBar
|
||||
selectedServer={selectedServer}
|
||||
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
|
||||
order={actualOrderBy}
|
||||
handleOrderBy={handleOrderBy}
|
||||
settings={settings}
|
||||
className="mb-3"
|
||||
/>
|
||||
<Card body className="pb-0">
|
||||
<ShortUrlsTable
|
||||
selectedServer={selectedServer}
|
||||
shortUrlsList={shortUrlsList}
|
||||
orderByColumn={orderByColumn}
|
||||
renderOrderIcon={renderOrderIcon}
|
||||
onTagClick={addTag}
|
||||
/>
|
||||
<Paginator paginator={pagination} serverId={serverId} currentQueryString={location.search} />
|
||||
<Paginator paginator={pagination} currentQueryString={location.search} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
|
@ -1,7 +1,6 @@
|
|||
import classNames from 'classnames';
|
||||
import { isEmpty } from 'ramda';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
import type { ShortUrlsOrderableFields } from './data';
|
||||
import type { ShortUrlsRowType } from './helpers/ShortUrlsRow';
|
||||
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||
|
@ -11,7 +10,6 @@ interface ShortUrlsTableProps {
|
|||
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
||||
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
selectedServer: SelectedServer;
|
||||
onTagClick?: (tag: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
@ -21,7 +19,6 @@ export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
|
|||
renderOrderIcon,
|
||||
shortUrlsList,
|
||||
onTagClick,
|
||||
selectedServer,
|
||||
className,
|
||||
}: ShortUrlsTableProps) => {
|
||||
const { error, loading, shortUrls } = shortUrlsList;
|
||||
|
@ -52,7 +49,6 @@ export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
|
|||
<ShortUrlsRow
|
||||
key={shortUrl.shortUrl}
|
||||
shortUrl={shortUrl}
|
||||
selectedServer={selectedServer}
|
||||
onTagClick={onTagClick}
|
||||
/>
|
||||
));
|
|
@ -1,7 +1,7 @@
|
|||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import './UseExistingIfFoundInfoIcon.scss';
|
||||
|
||||
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (
|
43
shlink-web-component/src/short-urls/data/index.ts
Normal file
43
shlink-web-component/src/short-urls/data/index.ts
Normal 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;
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Result } from '@shlinkio/shlink-frontend-kit';
|
||||
import { useEffect } from 'react';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { Tooltip } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
import { ShlinkApiError } from '../../common/ShlinkApiError';
|
||||
import type { TimeoutToggle } from '../../utils/helpers/hooks';
|
||||
import { Result } from '../../utils/Result';
|
||||
import type { ShortUrlCreation } from '../reducers/shortUrlCreation';
|
||||
import './CreateShortUrlResult.scss';
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { Result } from '@shlinkio/shlink-frontend-kit';
|
||||
import { pipe } from 'ramda';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||
import { isInvalidDeletionError } from '../../api/utils';
|
||||
import { Result } from '../../utils/Result';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { isInvalidDeletionError } from '../../api-contract/utils';
|
||||
import { ShlinkApiError } from '../../common/ShlinkApiError';
|
||||
import { handleEventPreventingDefault } from '../../utils/helpers';
|
||||
import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
|
||||
import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
||||
|
|
@ -1,39 +1,27 @@
|
|||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import type { ReportExporter } from '../../common/services/ReportExporter';
|
||||
import type { SelectedServer } from '../../servers/data';
|
||||
import { isServerWithId } from '../../servers/data';
|
||||
import { ExportBtn } from '../../utils/ExportBtn';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import type { ShortUrl } from '../data';
|
||||
import type { ShlinkApiClient, ShlinkShortUrl } from '../../api-contract';
|
||||
import { ExportBtn } from '../../utils/components/ExportBtn';
|
||||
import type { ReportExporter } from '../../utils/services/ReportExporter';
|
||||
import { useShortUrlsQuery } from './hooks';
|
||||
|
||||
export interface ExportShortUrlsBtnProps {
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const itemsPerPage = 20;
|
||||
|
||||
export const ExportShortUrlsBtn = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
apiClientFactory: () => ShlinkApiClient,
|
||||
{ exportShortUrls }: ReportExporter,
|
||||
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => {
|
||||
): FC<ExportShortUrlsBtnProps> => ({ amount = 0 }) => {
|
||||
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
|
||||
const [loading,, startLoading, stopLoading] = useToggle();
|
||||
const exportAllUrls = useCallback(async () => {
|
||||
if (!isServerWithId(selectedServer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = amount / itemsPerPage;
|
||||
const { listShortUrls } = buildShlinkApiClient(selectedServer);
|
||||
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => {
|
||||
const { data } = await listShortUrls(
|
||||
const loadAllUrls = async (page = 1): Promise<ShlinkShortUrl[]> => {
|
||||
const { data } = await apiClientFactory().listShortUrls(
|
||||
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
|
||||
);
|
||||
|
||||
|
@ -64,7 +52,7 @@ export const ExportShortUrlsBtn = (
|
|||
};
|
||||
}));
|
||||
stopLoading();
|
||||
}, [selectedServer]);
|
||||
}, []);
|
||||
|
||||
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
|
||||
};
|
|
@ -3,29 +3,22 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||
import { useMemo, useState } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
|
||||
import type { ImageDownloader } from '../../common/services/ImageDownloader';
|
||||
import type { SelectedServer } from '../../servers/data';
|
||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||
import { useFeature } from '../../utils/helpers/features';
|
||||
import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon';
|
||||
import type { QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
||||
import { buildQrCodeUrl } from '../../utils/helpers/qrCodes';
|
||||
import type { ImageDownloader } from '../../utils/services/ImageDownloader';
|
||||
import type { ShortUrlModalProps } from '../data';
|
||||
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
||||
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
|
||||
import './QrCodeModal.scss';
|
||||
|
||||
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
||||
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
|
||||
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen }: ShortUrlModalProps,
|
||||
) => {
|
||||
const [size, setSize] = useState(300);
|
||||
const [margin, setMargin] = useState(0);
|
||||
const [format, setFormat] = useState<QrCodeFormat>('png');
|
||||
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
|
||||
const displayDownloadBtn = useFeature('nonRestCors', selectedServer);
|
||||
const qrCodeUrl = useMemo(
|
||||
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
|
||||
[shortUrl, size, format, margin, errorCorrection],
|
||||
|
@ -46,7 +39,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
|||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Row>
|
||||
<FormGroup className="d-grid col-md-4">
|
||||
<FormGroup className="d-grid col-md-6">
|
||||
<label>Size: {size}px</label>
|
||||
<input
|
||||
type="range"
|
||||
|
@ -58,7 +51,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
|||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className="d-grid col-md-4">
|
||||
<FormGroup className="d-grid col-md-6">
|
||||
<label htmlFor="marginControl">Margin: {margin}px</label>
|
||||
<input
|
||||
id="marginControl"
|
||||
|
@ -71,7 +64,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
|||
onChange={(e) => setMargin(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className="d-grid col-md-4">
|
||||
<FormGroup className="d-grid col-md-6">
|
||||
<QrFormatDropdown format={format} setFormat={setFormat} />
|
||||
</FormGroup>
|
||||
<FormGroup className="col-md-6">
|
||||
|
@ -84,19 +77,17 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
|
|||
<CopyToClipboardIcon text={qrCodeUrl} />
|
||||
</div>
|
||||
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
||||
{displayDownloadBtn && (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
|
||||
}}
|
||||
>
|
||||
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
|
||||
}}
|
||||
>
|
||||
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
|
@ -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>;
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import { Checkbox } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
||||
import { Checkbox } from '../../utils/Checkbox';
|
||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||
import { InfoTooltip } from '../../utils/components/InfoTooltip';
|
||||
|
||||
type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{
|
||||
checked?: boolean;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue