diff --git a/package.json b/package.json index 4c5335bf..84fbc74b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/utils/base.scss b/shlink-frontend-kit/src/base.scss similarity index 100% rename from src/utils/base.scss rename to shlink-frontend-kit/src/base.scss diff --git a/src/utils/Message.tsx b/shlink-frontend-kit/src/block/Message.tsx similarity index 100% rename from src/utils/Message.tsx rename to shlink-frontend-kit/src/block/Message.tsx diff --git a/src/utils/Result.tsx b/shlink-frontend-kit/src/block/Result.tsx similarity index 100% rename from src/utils/Result.tsx rename to shlink-frontend-kit/src/block/Result.tsx diff --git a/src/utils/SimpleCard.tsx b/shlink-frontend-kit/src/block/SimpleCard.tsx similarity index 87% rename from src/utils/SimpleCard.tsx rename to shlink-frontend-kit/src/block/SimpleCard.tsx index 20d85d89..eea80173 100644 --- a/src/utils/SimpleCard.tsx +++ b/shlink-frontend-kit/src/block/SimpleCard.tsx @@ -2,10 +2,10 @@ import type { ReactNode } from 'react'; import type { CardProps } from 'reactstrap'; import { Card, CardBody, CardHeader } from 'reactstrap'; -interface SimpleCardProps extends Omit { +export type SimpleCardProps = Omit & { title?: ReactNode; bodyClassName?: string; -} +}; export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => ( diff --git a/shlink-frontend-kit/src/block/index.ts b/shlink-frontend-kit/src/block/index.ts new file mode 100644 index 00000000..b075331a --- /dev/null +++ b/shlink-frontend-kit/src/block/index.ts @@ -0,0 +1,3 @@ +export * from './Message'; +export * from './Result'; +export * from './SimpleCard'; diff --git a/src/utils/BooleanControl.tsx b/shlink-frontend-kit/src/form/BooleanControl.tsx similarity index 90% rename from src/utils/BooleanControl.tsx rename to shlink-frontend-kit/src/form/BooleanControl.tsx index e99dfbe1..1beb4039 100644 --- a/src/utils/BooleanControl.tsx +++ b/shlink-frontend-kit/src/form/BooleanControl.tsx @@ -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 = ( { checked = false, onChange = identity, className, children, type, inline = false }, diff --git a/src/utils/Checkbox.tsx b/shlink-frontend-kit/src/form/Checkbox.tsx similarity index 100% rename from src/utils/Checkbox.tsx rename to shlink-frontend-kit/src/form/Checkbox.tsx diff --git a/src/utils/forms/InputFormGroup.tsx b/shlink-frontend-kit/src/form/InputFormGroup.tsx similarity index 95% rename from src/utils/forms/InputFormGroup.tsx rename to shlink-frontend-kit/src/form/InputFormGroup.tsx index 97e6ccdf..e4e1ffe4 100644 --- a/src/utils/forms/InputFormGroup.tsx +++ b/shlink-frontend-kit/src/form/InputFormGroup.tsx @@ -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<{ diff --git a/src/utils/forms/LabeledFormGroup.tsx b/shlink-frontend-kit/src/form/LabeledFormGroup.tsx similarity index 100% rename from src/utils/forms/LabeledFormGroup.tsx rename to shlink-frontend-kit/src/form/LabeledFormGroup.tsx diff --git a/src/utils/SearchField.scss b/shlink-frontend-kit/src/form/SearchField.scss similarity index 86% rename from src/utils/SearchField.scss rename to shlink-frontend-kit/src/form/SearchField.scss index 3d8d2849..cc49151b 100644 --- a/src/utils/SearchField.scss +++ b/shlink-frontend-kit/src/form/SearchField.scss @@ -1,4 +1,4 @@ -@import '../utils/mixins/vertical-align'; +@import '../../../shlink-web-component/src/utils/mixins/vertical-align'; .search-field { position: relative; diff --git a/src/utils/SearchField.tsx b/shlink-frontend-kit/src/form/SearchField.tsx similarity index 98% rename from src/utils/SearchField.tsx rename to shlink-frontend-kit/src/form/SearchField.tsx index 4c8a742b..e1d3a75c 100644 --- a/src/utils/SearchField.tsx +++ b/shlink-frontend-kit/src/form/SearchField.tsx @@ -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); diff --git a/src/utils/ToggleSwitch.tsx b/shlink-frontend-kit/src/form/ToggleSwitch.tsx similarity index 100% rename from src/utils/ToggleSwitch.tsx rename to shlink-frontend-kit/src/form/ToggleSwitch.tsx diff --git a/shlink-frontend-kit/src/form/index.ts b/shlink-frontend-kit/src/form/index.ts new file mode 100644 index 00000000..160f856d --- /dev/null +++ b/shlink-frontend-kit/src/form/index.ts @@ -0,0 +1,5 @@ +export * from './Checkbox'; +export * from './ToggleSwitch'; +export * from './InputFormGroup'; +export * from './LabeledFormGroup'; +export * from './SearchField'; diff --git a/shlink-frontend-kit/src/hooks/index.ts b/shlink-frontend-kit/src/hooks/index.ts new file mode 100644 index 00000000..0e62687a --- /dev/null +++ b/shlink-frontend-kit/src/hooks/index.ts @@ -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(initialValue); + return [flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false)]; +}; + +export const useDomId = (): string => { + const { current: id } = useRef(`dom-${uuid()}`); + return id; +}; + +export const useElementRef = () => useRef(null); diff --git a/shlink-frontend-kit/src/index.scss b/shlink-frontend-kit/src/index.scss new file mode 100644 index 00000000..1fd1e917 --- /dev/null +++ b/shlink-frontend-kit/src/index.scss @@ -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; + } +} diff --git a/shlink-frontend-kit/src/index.ts b/shlink-frontend-kit/src/index.ts new file mode 100644 index 00000000..735725b8 --- /dev/null +++ b/shlink-frontend-kit/src/index.ts @@ -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'; diff --git a/src/utils/DropdownBtn.scss b/shlink-frontend-kit/src/navigation/DropdownBtn.scss similarity index 95% rename from src/utils/DropdownBtn.scss rename to shlink-frontend-kit/src/navigation/DropdownBtn.scss index 86395a84..8d3ea060 100644 --- a/src/utils/DropdownBtn.scss +++ b/shlink-frontend-kit/src/navigation/DropdownBtn.scss @@ -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; diff --git a/src/utils/DropdownBtn.tsx b/shlink-frontend-kit/src/navigation/DropdownBtn.tsx similarity index 96% rename from src/utils/DropdownBtn.tsx rename to shlink-frontend-kit/src/navigation/DropdownBtn.tsx index c6076a86..43f0056c 100644 --- a/src/utils/DropdownBtn.tsx +++ b/shlink-frontend-kit/src/navigation/DropdownBtn.tsx @@ -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 & { diff --git a/src/utils/NavPills.scss b/shlink-frontend-kit/src/navigation/NavPills.scss similarity index 97% rename from src/utils/NavPills.scss rename to shlink-frontend-kit/src/navigation/NavPills.scss index 95fedcd0..4151ebf1 100644 --- a/src/utils/NavPills.scss +++ b/shlink-frontend-kit/src/navigation/NavPills.scss @@ -1,4 +1,4 @@ -@import './base'; +@import '../base'; .nav-pills__nav { position: sticky !important; diff --git a/src/utils/NavPills.tsx b/shlink-frontend-kit/src/navigation/NavPills.tsx similarity index 100% rename from src/utils/NavPills.tsx rename to shlink-frontend-kit/src/navigation/NavPills.tsx diff --git a/src/utils/RowDropdownBtn.tsx b/shlink-frontend-kit/src/navigation/RowDropdownBtn.tsx similarity index 100% rename from src/utils/RowDropdownBtn.tsx rename to shlink-frontend-kit/src/navigation/RowDropdownBtn.tsx diff --git a/shlink-frontend-kit/src/navigation/index.ts b/shlink-frontend-kit/src/navigation/index.ts new file mode 100644 index 00000000..5dabcc11 --- /dev/null +++ b/shlink-frontend-kit/src/navigation/index.ts @@ -0,0 +1,3 @@ +export * from './DropdownBtn'; +export * from './RowDropdownBtn'; +export * from './NavPills'; diff --git a/src/utils/OrderingDropdown.scss b/shlink-frontend-kit/src/ordering/OrderingDropdown.scss similarity index 100% rename from src/utils/OrderingDropdown.scss rename to shlink-frontend-kit/src/ordering/OrderingDropdown.scss diff --git a/src/utils/OrderingDropdown.tsx b/shlink-frontend-kit/src/ordering/OrderingDropdown.tsx similarity index 92% rename from src/utils/OrderingDropdown.tsx rename to shlink-frontend-kit/src/ordering/OrderingDropdown.tsx index f2d73d2e..cd87e22d 100644 --- a/src/utils/OrderingDropdown.tsx +++ b/shlink-frontend-kit/src/ordering/OrderingDropdown.tsx @@ -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 { +export type OrderingDropdownProps = { items: Record; order: Order; onChange: (orderField?: T, orderDir?: OrderDir) => void; isButton?: boolean; right?: boolean; prefixed?: boolean; -} +}; export function OrderingDropdown( { items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps, diff --git a/shlink-frontend-kit/src/ordering/index.ts b/shlink-frontend-kit/src/ordering/index.ts new file mode 100644 index 00000000..dcc4f1a1 --- /dev/null +++ b/shlink-frontend-kit/src/ordering/index.ts @@ -0,0 +1,2 @@ +export * from './ordering'; +export * from './OrderingDropdown'; diff --git a/src/utils/helpers/ordering.ts b/shlink-frontend-kit/src/ordering/ordering.ts similarity index 94% rename from src/utils/helpers/ordering.ts rename to shlink-frontend-kit/src/ordering/ordering.ts index dafe7b69..77ac4ffc 100644 --- a/src/utils/helpers/ordering.ts +++ b/shlink-frontend-kit/src/ordering/ordering.ts @@ -1,9 +1,9 @@ export type OrderDir = 'ASC' | 'DESC' | undefined; -export interface Order { +export type Order = { field?: Fields; dir?: OrderDir; -} +}; export const determineOrderDir = ( currentField: T, @@ -22,7 +22,7 @@ export const determineOrderDir = ( return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC'; }; -export const sortList = (list: List[], { field, dir }: Order>) => ( +export const sortList = (list: List[], { field, dir }: Order) => ( !field || !dir ? list : list.sort((a, b) => { const greaterThan = dir === 'ASC' ? 1 : -1; const smallerThan = dir === 'ASC' ? -1 : 1; diff --git a/src/utils/theme/index.ts b/shlink-frontend-kit/src/theme/index.ts similarity index 55% rename from src/utils/theme/index.ts rename to shlink-frontend-kit/src/theme/index.ts index 39ebb02a..970ef398 100644 --- a/src/utils/theme/index.ts +++ b/shlink-frontend-kit/src/theme/index.ts @@ -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'; diff --git a/src/utils/theme/theme.scss b/shlink-frontend-kit/src/theme/theme.scss similarity index 100% rename from src/utils/theme/theme.scss rename to shlink-frontend-kit/src/theme/theme.scss diff --git a/src/utils/table/ResponsiveTable.scss b/shlink-frontend-kit/src/utils/ResponsiveTable.scss similarity index 97% rename from src/utils/table/ResponsiveTable.scss rename to shlink-frontend-kit/src/utils/ResponsiveTable.scss index 49ac01a9..a7e90583 100644 --- a/src/utils/table/ResponsiveTable.scss +++ b/shlink-frontend-kit/src/utils/ResponsiveTable.scss @@ -1,4 +1,4 @@ -@import '../../utils/base'; +@import '../base'; .responsive-table__header { @media (max-width: $responsiveTableBreakpoint) { diff --git a/src/utils/helpers/query.ts b/shlink-frontend-kit/src/utils/index.ts similarity index 82% rename from src/utils/helpers/query.ts rename to shlink-frontend-kit/src/utils/index.ts index 8c59f6eb..cb029acc 100644 --- a/src/utils/helpers/query.ts +++ b/shlink-frontend-kit/src/utils/index.ts @@ -1,5 +1,7 @@ import qs from 'qs'; +// FIXME Use URLSearchParams instead of qs package + export const parseQuery = (search: string) => qs.parse(search, { ignoreQueryPrefix: true }) as unknown as T; export const stringifyQuery = (query: any): string => qs.stringify(query, { arrayFormat: 'brackets' }); diff --git a/shlink-frontend-kit/test/__helpers__/setUpTest.ts b/shlink-frontend-kit/test/__helpers__/setUpTest.ts new file mode 100644 index 00000000..7fccbf98 --- /dev/null +++ b/shlink-frontend-kit/test/__helpers__/setUpTest.ts @@ -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), +}); diff --git a/test/utils/Message.test.tsx b/shlink-frontend-kit/test/block/Message.test.tsx similarity index 94% rename from test/utils/Message.test.tsx rename to shlink-frontend-kit/test/block/Message.test.tsx index 7adbe691..101542c8 100644 --- a/test/utils/Message.test.tsx +++ b/shlink-frontend-kit/test/block/Message.test.tsx @@ -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('', () => { const setUp = (props: PropsWithChildren = {}) => render(); diff --git a/test/utils/Result.test.tsx b/shlink-frontend-kit/test/block/Result.test.tsx similarity index 90% rename from test/utils/Result.test.tsx rename to shlink-frontend-kit/test/block/Result.test.tsx index 92a342a6..2747c746 100644 --- a/test/utils/Result.test.tsx +++ b/shlink-frontend-kit/test/block/Result.test.tsx @@ -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('', () => { const setUp = (props: ResultProps) => render(); diff --git a/test/utils/SimpleCard.test.tsx b/shlink-frontend-kit/test/block/SimpleCard.test.tsx similarity index 62% rename from test/utils/SimpleCard.test.tsx rename to shlink-frontend-kit/test/block/SimpleCard.test.tsx index b570fc28..1ca051f0 100644 --- a/test/utils/SimpleCard.test.tsx +++ b/shlink-frontend-kit/test/block/SimpleCard.test.tsx @@ -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({children}); describe('', () => { it('does not render title if not provided', () => { - render(); + setUp(); expect(screen.queryByRole('heading')).not.toBeInTheDocument(); }); it('renders provided title', () => { - render(); + setUp({ title: 'Cool title' }); expect(screen.getByRole('heading')).toHaveTextContent('Cool title'); }); it('renders children inside body', () => { - render(Hello world); + 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(Hello world); + const { container } = setUp({ className: 'foo', color, children: 'Hello world' }); expect(container.firstChild).toHaveAttribute('class', `foo card bg-${color}`); }); }); diff --git a/test/utils/Checkbox.test.tsx b/shlink-frontend-kit/test/form/Checkbox.test.tsx similarity index 97% rename from test/utils/Checkbox.test.tsx rename to shlink-frontend-kit/test/form/Checkbox.test.tsx index 09c248be..302d32be 100644 --- a/test/utils/Checkbox.test.tsx +++ b/shlink-frontend-kit/test/form/Checkbox.test.tsx @@ -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('', () => { diff --git a/test/utils/DropdownBtn.test.tsx b/shlink-frontend-kit/test/navigation/DropdownBtn.test.tsx similarity index 92% rename from test/utils/DropdownBtn.test.tsx rename to shlink-frontend-kit/test/navigation/DropdownBtn.test.tsx index a3c6309e..d51caef5 100644 --- a/test/utils/DropdownBtn.test.tsx +++ b/shlink-frontend-kit/test/navigation/DropdownBtn.test.tsx @@ -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('', () => { diff --git a/test/utils/NavPills.test.tsx b/shlink-frontend-kit/test/navigation/NavPills.test.tsx similarity index 96% rename from test/utils/NavPills.test.tsx rename to shlink-frontend-kit/test/navigation/NavPills.test.tsx index 63f45f9f..a23b3dcb 100644 --- a/test/utils/NavPills.test.tsx +++ b/shlink-frontend-kit/test/navigation/NavPills.test.tsx @@ -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('', () => { let originalError: typeof console.error; diff --git a/test/utils/RowDropdownBtn.test.tsx b/shlink-frontend-kit/test/navigation/RowDropdownBtn.test.tsx similarity index 86% rename from test/utils/RowDropdownBtn.test.tsx rename to shlink-frontend-kit/test/navigation/RowDropdownBtn.test.tsx index ae47a0ce..62cd6de9 100644 --- a/test/utils/RowDropdownBtn.test.tsx +++ b/shlink-frontend-kit/test/navigation/RowDropdownBtn.test.tsx @@ -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('', () => { diff --git a/test/utils/OrderingDropdown.test.tsx b/shlink-frontend-kit/test/ordering/OrderingDropdown.test.tsx similarity index 94% rename from test/utils/OrderingDropdown.test.tsx rename to shlink-frontend-kit/test/ordering/OrderingDropdown.test.tsx index 56ecba33..9b5aedf2 100644 --- a/test/utils/OrderingDropdown.test.tsx +++ b/shlink-frontend-kit/test/ordering/OrderingDropdown.test.tsx @@ -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('', () => { diff --git a/test/utils/helpers/ordering.test.ts b/shlink-frontend-kit/test/ordering/ordering.test.ts similarity index 94% rename from test/utils/helpers/ordering.test.ts rename to shlink-frontend-kit/test/ordering/ordering.test.ts index d604ab3a..899eb8ae 100644 --- a/test/utils/helpers/ordering.test.ts +++ b/shlink-frontend-kit/test/ordering/ordering.test.ts @@ -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', () => { diff --git a/test/utils/helpers/query.test.ts b/shlink-frontend-kit/test/utils/query.test.ts similarity index 90% rename from test/utils/helpers/query.test.ts rename to shlink-frontend-kit/test/utils/query.test.ts index 68e1350c..a22445d3 100644 --- a/test/utils/helpers/query.test.ts +++ b/shlink-frontend-kit/test/utils/query.test.ts @@ -1,4 +1,4 @@ -import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query'; +import { parseQuery, stringifyQuery } from '../../src/utils'; describe('query', () => { describe('parseQuery', () => { diff --git a/src/common/MenuLayout.scss b/shlink-web-component/src/Main.scss similarity index 63% rename from src/common/MenuLayout.scss rename to shlink-web-component/src/Main.scss index a7974a7c..03d3545c 100644 --- a/src/common/MenuLayout.scss +++ b/shlink-web-component/src/Main.scss @@ -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%; diff --git a/shlink-web-component/src/Main.tsx b/shlink-web-component/src/Main.tsx new file mode 100644 index 00000000..ca02c054 --- /dev/null +++ b/shlink-web-component/src/Main.tsx @@ -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 => ({ 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 ( + + + +
+
+ +
hideSidebar()}> +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {addDomainVisitsRoute && } />} + } /> + } /> + } /> + } /> + {createNotFound && } + +
+
+
+
+
+ ); +}; diff --git a/shlink-web-component/src/ShlinkWebComponent.tsx b/shlink-web-component/src/ShlinkWebComponent.tsx new file mode 100644 index 00000000..0dc1c47d --- /dev/null +++ b/shlink-web-component/src/ShlinkWebComponent.tsx @@ -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 => ( + { serverVersion, apiClient, settings, routesPrefix = '', createNotFound, tagColorsStorage }, +) => { + const features = useFeatures(serverVersion); + const mainContent = useRef(); + const [theStore, setStore] = useState(); + + 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 =
; + setStore(store); + + // Load mercure info + store.dispatch(loadMercureInfo(settings)); + }, [apiClient, tagColorsStorage]); + + return !theStore ? <> : ( + + + + + {mainContent.current} + + + + + ); +}; diff --git a/shlink-web-component/src/api-contract/ShlinkApiClient.ts b/shlink-web-component/src/api-contract/ShlinkApiClient.ts new file mode 100644 index 00000000..9ec85594 --- /dev/null +++ b/shlink-web-component/src/api-contract/ShlinkApiClient.ts @@ -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; + + createShortUrl(options: ShlinkCreateShortUrlData): Promise; + + getShortUrlVisits(shortCode: string, query?: ShlinkVisitsParams): Promise; + + getTagVisits(tag: string, query?: Omit): Promise; + + getDomainVisits(domain: string, query?: Omit): Promise; + + getOrphanVisits(query?: Omit): Promise; + + getNonOrphanVisits(query?: Omit): Promise; + + getVisitsOverview(): Promise; + + getShortUrl(shortCode: string, domain?: string | null): Promise; + + deleteShortUrl(shortCode: string, domain?: string | null): Promise; + + updateShortUrl( + shortCode: string, + domain: string | null | undefined, + body: ShlinkEditShortUrlData, + ): Promise; + + listTags(): Promise; + + tagsStats(): Promise; + + deleteTags(tags: string[]): Promise<{ tags: string[] }>; + + editTag(oldName: string, newName: string): Promise<{ oldName: string; newName: string }>; + + health(authority?: string): Promise; + + mercureInfo(): Promise; + + listDomains(): Promise; + + editDomainRedirects(domainRedirects: ShlinkEditDomainRedirects): Promise; +}; diff --git a/src/api/types/errors.ts b/shlink-web-component/src/api-contract/errors.ts similarity index 100% rename from src/api/types/errors.ts rename to shlink-web-component/src/api-contract/errors.ts diff --git a/shlink-web-component/src/api-contract/index.ts b/shlink-web-component/src/api-contract/index.ts new file mode 100644 index 00000000..850275d8 --- /dev/null +++ b/shlink-web-component/src/api-contract/index.ts @@ -0,0 +1,3 @@ +export * from './errors'; +export * from './ShlinkApiClient'; +export * from './types'; diff --git a/src/api/types/index.ts b/shlink-web-component/src/api-contract/types.ts similarity index 62% rename from src/api/types/index.ts rename to shlink-web-component/src/api-contract/types.ts index 06473dff..5fb2f692 100644 --- a/src/api/types/index.ts +++ b/shlink-web-component/src/api-contract/types.ts @@ -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, // 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>; + 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 { + 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 - !!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); diff --git a/src/common/AsideMenu.scss b/shlink-web-component/src/common/AsideMenu.scss similarity index 78% rename from src/common/AsideMenu.scss rename to shlink-web-component/src/common/AsideMenu.scss index 8f1f075e..2ddd3d13 100644 --- a/src/common/AsideMenu.scss +++ b/shlink-web-component/src/common/AsideMenu.scss @@ -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; } diff --git a/src/common/AsideMenu.tsx b/shlink-web-component/src/common/AsideMenu.tsx similarity index 69% rename from src/common/AsideMenu.tsx rename to shlink-web-component/src/common/AsideMenu.tsx index 9d6d1886..bdbc3f14 100644 --- a/src/common/AsideMenu.tsx +++ b/shlink-web-component/src/common/AsideMenu.tsx @@ -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 = ({ children, to, className, ...res ); -export const AsideMenu = (DeleteServerButton: FC) => ( - { selectedServer, showOnMobile = false }: AsideMenuProps, -) => { - const hasId = isServerWithId(selectedServer); - const serverId = hasId ? selectedServer.id : ''; +export const AsideMenu: FC = ({ 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 ( ); diff --git a/src/api/ShlinkApiError.tsx b/shlink-web-component/src/common/ShlinkApiError.tsx similarity index 76% rename from src/api/ShlinkApiError.tsx rename to shlink-web-component/src/common/ShlinkApiError.tsx index 057641da..18a1f95d 100644 --- a/src/api/ShlinkApiError.tsx +++ b/shlink-web-component/src/common/ShlinkApiError.tsx @@ -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; diff --git a/shlink-web-component/src/container/index.ts b/shlink-web-component/src/container/index.ts new file mode 100644 index 00000000..f5f6f9c4 --- /dev/null +++ b/shlink-web-component/src/container/index.ts @@ -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; + +export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any; + +export const bottle = new Bottle(); + +export const { container } = bottle; + +const lazyService = (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); diff --git a/shlink-web-component/src/container/provideServices.ts b/shlink-web-component/src/container/provideServices.ts new file mode 100644 index 00000000..11bbd36f --- /dev/null +++ b/shlink-web-component/src/container/provideServices.ts @@ -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); +}; diff --git a/shlink-web-component/src/container/store.ts b/shlink-web-component/src/container/store.ts new file mode 100644 index 00000000..db777abe --- /dev/null +++ b/shlink-web-component/src/container/store.ts @@ -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; +}; diff --git a/src/domains/DomainRow.tsx b/shlink-web-component/src/domains/DomainRow.tsx similarity index 87% rename from src/domains/DomainRow.tsx rename to shlink-web-component/src/domains/DomainRow.tsx index 464676cc..8ed162bc 100644 --- a/src/domains/DomainRow.tsx +++ b/shlink-web-component/src/domains/DomainRow.tsx @@ -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; checkDomainHealth: (domain: string) => void; - selectedServer: SelectedServer; } -const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => ( +const Nr: FC<{ fallback?: string | null }> = ({ fallback }) => ( {!fallback && No redirect} {fallback && <>{fallback} (as fallback)} @@ -33,7 +30,7 @@ const DefaultDomain: FC = () => ( ); export const DomainRow: FC = ( - { domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer }, + { domain, editDomainRedirects, checkDomainHealth, defaultRedirects }, ) => { const { domain: authority, isDefault, redirects, status } = domain; @@ -58,7 +55,7 @@ export const DomainRow: FC = ( - + ); diff --git a/src/domains/DomainSelector.scss b/shlink-web-component/src/domains/DomainSelector.scss similarity index 94% rename from src/domains/DomainSelector.scss rename to shlink-web-component/src/domains/DomainSelector.scss index 729f58ea..bdf04ef6 100644 --- a/src/domains/DomainSelector.scss +++ b/shlink-web-component/src/domains/DomainSelector.scss @@ -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, diff --git a/src/domains/DomainSelector.tsx b/shlink-web-component/src/domains/DomainSelector.tsx similarity index 95% rename from src/domains/DomainSelector.tsx rename to shlink-web-component/src/domains/DomainSelector.tsx index e25442c7..e89be930 100644 --- a/src/domains/DomainSelector.tsx +++ b/shlink-web-component/src/domains/DomainSelector.tsx @@ -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'; diff --git a/src/domains/ManageDomains.tsx b/shlink-web-component/src/domains/ManageDomains.tsx similarity index 83% rename from src/domains/ManageDomains.tsx rename to shlink-web-component/src/domains/ManageDomains.tsx index 3b54e22e..0dfa88bf 100644 --- a/src/domains/ManageDomains.tsx +++ b/shlink-web-component/src/domains/ManageDomains.tsx @@ -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; 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 = ( - { 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 = ( editDomainRedirects={editDomainRedirects} checkDomainHealth={checkDomainHealth} defaultRedirects={resolvedDefaultRedirects} - selectedServer={selectedServer} /> ))} diff --git a/src/domains/data/index.ts b/shlink-web-component/src/domains/data/index.ts similarity index 71% rename from src/domains/data/index.ts rename to shlink-web-component/src/domains/data/index.ts index a6600f15..6cc50587 100644 --- a/src/domains/data/index.ts +++ b/shlink-web-component/src/domains/data/index.ts @@ -1,4 +1,4 @@ -import type { ShlinkDomain } from '../../api/types'; +import type { ShlinkDomain } from '../../api-contract'; export type DomainStatus = 'validating' | 'valid' | 'invalid'; diff --git a/src/domains/helpers/DomainDropdown.tsx b/shlink-web-component/src/domains/helpers/DomainDropdown.tsx similarity index 61% rename from src/domains/helpers/DomainDropdown.tsx rename to shlink-web-component/src/domains/helpers/DomainDropdown.tsx index 8c99e2ac..73aeb47c 100644 --- a/src/domains/helpers/DomainDropdown.tsx +++ b/shlink-web-component/src/domains/helpers/DomainDropdown.tsx @@ -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; - selectedServer: SelectedServer; } -export const DomainDropdown: FC = ({ domain, editDomainRedirects, selectedServer }) => { +export const DomainDropdown: FC = ({ 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 ( {withVisits && ( Visit stats )} - + Edit redirects diff --git a/src/domains/helpers/DomainStatusIcon.tsx b/shlink-web-component/src/domains/helpers/DomainStatusIcon.tsx similarity index 97% rename from src/domains/helpers/DomainStatusIcon.tsx rename to shlink-web-component/src/domains/helpers/DomainStatusIcon.tsx index 9d7c071c..003c1733 100644 --- a/src/domains/helpers/DomainStatusIcon.tsx +++ b/shlink-web-component/src/domains/helpers/DomainStatusIcon.tsx @@ -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'; diff --git a/src/domains/helpers/EditDomainRedirectsModal.tsx b/shlink-web-component/src/domains/helpers/EditDomainRedirectsModal.tsx similarity index 91% rename from src/domains/helpers/EditDomainRedirectsModal.tsx rename to shlink-web-component/src/domains/helpers/EditDomainRedirectsModal.tsx index 726407f3..bb21dee7 100644 --- a/src/domains/helpers/EditDomainRedirectsModal.tsx +++ b/shlink-web-component/src/domains/helpers/EditDomainRedirectsModal.tsx @@ -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 { diff --git a/shlink-web-component/src/domains/reducers/domainRedirects.ts b/shlink-web-component/src/domains/reducers/domainRedirects.ts new file mode 100644 index 00000000..e8ae3cf2 --- /dev/null +++ b/shlink-web-component/src/domains/reducers/domainRedirects.ts @@ -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 => { + const apiClient = apiClientFactory(); + const redirects = await apiClient.editDomainRedirects({ domain, ...providedRedirects }); + return { domain, redirects }; + }, +); diff --git a/src/domains/reducers/domainsList.ts b/shlink-web-component/src/domains/reducers/domainsList.ts similarity index 73% rename from src/domains/reducers/domainsList.ts rename to shlink-web-component/src/domains/reducers/domainsList.ts index 1a9cdb6b..4a6f3311 100644 --- a/src/domains/reducers/domainsList.ts +++ b/shlink-web-component/src/domains/reducers/domainsList.ts @@ -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, ) => { - const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise => { - const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState); - const { data, defaultRedirects } = await shlinkListDomains(); + const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (): Promise => { + 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 => { - const { selectedServer } = getState(); - - if (!hasServerData(selectedServer)) { - return { domain, status: 'invalid' }; - } - + async (domain: string): Promise => { 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' }; diff --git a/src/domains/services/provideServices.ts b/shlink-web-component/src/domains/services/provideServices.ts similarity index 89% rename from src/domains/services/provideServices.ts rename to shlink-web-component/src/domains/services/provideServices.ts index f8a2988c..3731a81e 100644 --- a/src/domains/services/provideServices.ts +++ b/shlink-web-component/src/domains/services/provideServices.ts @@ -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'); }; diff --git a/shlink-web-component/src/index.scss b/shlink-web-component/src/index.scss new file mode 100644 index 00000000..07792cf4 --- /dev/null +++ b/shlink-web-component/src/index.scss @@ -0,0 +1,2 @@ +@import './tags/react-tag-autocomplete'; +@import './utils/StickyCardPaginator.scss'; diff --git a/shlink-web-component/src/index.ts b/shlink-web-component/src/index.ts new file mode 100644 index 00000000..7f586a6c --- /dev/null +++ b/shlink-web-component/src/index.ts @@ -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'; diff --git a/src/mercure/helpers/Topics.ts b/shlink-web-component/src/mercure/helpers/Topics.ts similarity index 100% rename from src/mercure/helpers/Topics.ts rename to shlink-web-component/src/mercure/helpers/Topics.ts diff --git a/src/mercure/helpers/boundToMercureHub.tsx b/shlink-web-component/src/mercure/helpers/boundToMercureHub.tsx similarity index 96% rename from src/mercure/helpers/boundToMercureHub.tsx rename to shlink-web-component/src/mercure/helpers/boundToMercureHub.tsx index 854c9a4c..230552b1 100644 --- a/src/mercure/helpers/boundToMercureHub.tsx +++ b/shlink-web-component/src/mercure/helpers/boundToMercureHub.tsx @@ -23,6 +23,7 @@ export function boundToMercureHub( 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); diff --git a/src/mercure/helpers/index.ts b/shlink-web-component/src/mercure/helpers/index.ts similarity index 100% rename from src/mercure/helpers/index.ts rename to shlink-web-component/src/mercure/helpers/index.ts diff --git a/src/mercure/reducers/mercureInfo.ts b/shlink-web-component/src/mercure/reducers/mercureInfo.ts similarity index 65% rename from src/mercure/reducers/mercureInfo.ts rename to shlink-web-component/src/mercure/reducers/mercureInfo.ts index 6b3898fe..ac94f780 100644 --- a/src/mercure/reducers/mercureInfo.ts +++ b/shlink-web-component/src/mercure/reducers/mercureInfo.ts @@ -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 => { - const { settings } = getState(); - if (!settings.realTimeUpdates.enabled) { + ({ realTimeUpdates }: Settings): Promise => { + if (realTimeUpdates && !realTimeUpdates.enabled) { throw new Error('Real time updates not enabled'); } - return buildShlinkApiClient(getState).mercureInfo(); + return apiClientFactory().mercureInfo(); }, ); diff --git a/src/mercure/services/provideServices.ts b/shlink-web-component/src/mercure/services/provideServices.ts similarity index 92% rename from src/mercure/services/provideServices.ts rename to shlink-web-component/src/mercure/services/provideServices.ts index 0b6b4121..2eaabf4a 100644 --- a/src/mercure/services/provideServices.ts +++ b/shlink-web-component/src/mercure/services/provideServices.ts @@ -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 diff --git a/src/servers/Overview.tsx b/shlink-web-component/src/overview/Overview.tsx similarity index 74% rename from src/servers/Overview.tsx rename to shlink-web-component/src/overview/Overview.tsx index 83b7d7c3..c51d48fb 100644 --- a/src/servers/Overview.tsx +++ b/shlink-web-component/src/overview/Overview.tsx @@ -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 = (
- + {loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
- + {loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
@@ -93,7 +87,7 @@ export const Overview = ( Create a short URL
Create a short URL
- Advanced options » + Advanced options »
@@ -103,14 +97,13 @@ export const Overview = ( Recently created URLs
Recently created URLs
- See all » + See all »
navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)} + onTagClick={(tag) => navigate(`${routesPrefix}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)} /> diff --git a/src/servers/helpers/HighlightCard.scss b/shlink-web-component/src/overview/helpers/HighlightCard.scss similarity index 84% rename from src/servers/helpers/HighlightCard.scss rename to shlink-web-component/src/overview/helpers/HighlightCard.scss index ecfda6ed..8e23089c 100644 --- a/src/servers/helpers/HighlightCard.scss +++ b/shlink-web-component/src/overview/helpers/HighlightCard.scss @@ -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); } diff --git a/src/servers/helpers/HighlightCard.tsx b/shlink-web-component/src/overview/helpers/HighlightCard.tsx similarity index 78% rename from src/servers/helpers/HighlightCard.tsx rename to shlink-web-component/src/overview/helpers/HighlightCard.tsx index 99e35ecc..72b99865 100644 --- a/src/servers/helpers/HighlightCard.tsx +++ b/shlink-web-component/src/overview/helpers/HighlightCard.tsx @@ -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 = ({ children, title, link, tooltip }) => { const ref = useElementRef(); @@ -20,7 +20,7 @@ export const HighlightCard: FC = ({ children, title, link, t return ( <> - {link && } + {title} {children} diff --git a/src/servers/helpers/VisitsHighlightCard.tsx b/shlink-web-component/src/overview/helpers/VisitsHighlightCard.tsx similarity index 87% rename from src/servers/helpers/VisitsHighlightCard.tsx rename to shlink-web-component/src/overview/helpers/VisitsHighlightCard.tsx index 1617eb2e..6da51057 100644 --- a/src/servers/helpers/VisitsHighlightCard.tsx +++ b/shlink-web-component/src/overview/helpers/VisitsHighlightCard.tsx @@ -14,7 +14,7 @@ export const VisitsHighlightCard: FC = ({ loading, exc {excludeBots ? 'Plus' : 'Including'} {prettify(visitsSummary.bots)} potential bot visits + ? <>{excludeBots ? 'Plus' : 'Including'} {prettify(visitsSummary.bots)} potential bot visits : undefined } {...rest} diff --git a/shlink-web-component/src/overview/services/provideServices.ts b/shlink-web-component/src/overview/services/provideServices.ts new file mode 100644 index 00000000..1dce68ba --- /dev/null +++ b/shlink-web-component/src/overview/services/provideServices.ts @@ -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'], + )); +} diff --git a/src/short-urls/CreateShortUrl.tsx b/shlink-web-component/src/short-urls/CreateShortUrl.tsx similarity index 75% rename from src/short-urls/CreateShortUrl.tsx rename to shlink-web-component/src/short-urls/CreateShortUrl.tsx index 66ed0754..80428ded 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/shlink-web-component/src/short-urls/CreateShortUrl.tsx @@ -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; + createShortUrl: (data: ShlinkCreateShortUrlData) => Promise; 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, + ShortUrlForm: FC>, CreateShortUrlResult: FC, ) => ({ 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 = ( { + onSave={async (data) => { resetCreateShortUrl(); return createShortUrl(data); }} diff --git a/src/short-urls/EditShortUrl.tsx b/shlink-web-component/src/short-urls/EditShortUrl.tsx similarity index 84% rename from src/short-urls/EditShortUrl.tsx rename to shlink-web-component/src/short-urls/EditShortUrl.tsx index 0af479cc..229f224d 100644 --- a/src/short-urls/EditShortUrl.tsx +++ b/shlink-web-component/src/short-urls/EditShortUrl.tsx @@ -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) => ({ - settings: { shortUrlCreation: shortUrlCreationSettings }, - selectedServer, +export const EditShortUrl = (ShortUrlForm: FC>) => ({ shortUrlDetail, getShortUrlDetail, shortUrlEdition, @@ -41,6 +35,7 @@ export const EditShortUrl = (ShortUrlForm: FC) => ({ 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) => ({ { if (!shortUrl) { diff --git a/src/short-urls/Paginator.tsx b/shlink-web-component/src/short-urls/Paginator.tsx similarity index 82% rename from src/short-urls/Paginator.tsx rename to shlink-web-component/src/short-urls/Paginator.tsx index 6fb098b9..f2488585 100644 --- a/src/short-urls/Paginator.tsx +++ b/shlink-web-component/src/short-urls/Paginator.tsx @@ -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
; // Return some space diff --git a/src/short-urls/ShortUrlForm.scss b/shlink-web-component/src/short-urls/ShortUrlForm.scss similarity index 67% rename from src/short-urls/ShortUrlForm.scss rename to shlink-web-component/src/short-urls/ShortUrlForm.scss index 67b12ada..39d137c0 100644 --- a/src/short-urls/ShortUrlForm.scss +++ b/shlink-web-component/src/short-urls/ShortUrlForm.scss @@ -1,4 +1,4 @@ -@import '../utils/base'; +@import '@shlinkio/shlink-frontend-kit/base'; .short-url-form p:last-child { margin-bottom: 0; diff --git a/src/short-urls/ShortUrlForm.tsx b/shlink-web-component/src/short-urls/ShortUrlForm.tsx similarity index 84% rename from src/short-urls/ShortUrlForm.tsx rename to shlink-web-component/src/short-urls/ShortUrlForm.tsx index 68788064..16e2fead 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/shlink-web-component/src/short-urls/ShortUrlForm.tsx @@ -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 { + // 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; - selectedServer: SelectedServer; + initialState: T; + onSave: (shortUrlData: T) => Promise; } 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, DomainSelector: FC, -): FC => ({ mode, saving, onSave, initialState, selectedServer }) => { +) => function ShortUrlFormComp( + { mode, saving, onSave, initialState }: ShortUrlFormProps, +) { 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} /> ); - const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => ( + const renderDeviceLongUrlInput = (id: keyof ShlinkDeviceLongUrls, placeholder: string, icon: IconProp) => ( ); - const showForwardQueryControl = useFeature('forwardQuery', selectedServer); - return (
{isBasicMode && basicComponents} @@ -175,7 +178,7 @@ export const ShortUrlForm = ( title: setResettableValue(target.value, initialState.title), }), })} - {!isEdit && ( + {!isEdit && isCreation && ( <>
@@ -220,7 +223,7 @@ export const ShortUrlForm = ( > Validate URL - {!isEdit && ( + {!isEdit && isCreation && (

Make it crawlable - {showForwardQueryControl && ( - setShortUrlData({ ...shortUrlData, forwardQuery })} - > - Forward query params on redirect - - )} + setShortUrlData({ ...shortUrlData, forwardQuery })} + > + Forward query params on redirect +

diff --git a/src/short-urls/ShortUrlsFilteringBar.scss b/shlink-web-component/src/short-urls/ShortUrlsFilteringBar.scss similarity index 100% rename from src/short-urls/ShortUrlsFilteringBar.scss rename to shlink-web-component/src/short-urls/ShortUrlsFilteringBar.scss diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/shlink-web-component/src/short-urls/ShortUrlsFilteringBar.tsx similarity index 81% rename from src/short-urls/ShortUrlsFilteringBar.tsx rename to shlink-web-component/src/short-urls/ShortUrlsFilteringBar.tsx index b0394d06..9236df86 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/shlink-web-component/src/short-urls/ShortUrlsFilteringBar.tsx @@ -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, TagsSelector: FC, -): FC => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => { +): FC => ({ 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 = ( - {canChangeTagsMode && tags.length > 1 && ( + {tags.length > 1 && ( <>
QR code - {displayDownloadBtn && ( -
- -
- )} +
+ +
diff --git a/shlink-web-component/src/short-urls/helpers/ShortUrlDetailLink.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlDetailLink.tsx new file mode 100644 index 00000000..0f45e8a5 --- /dev/null +++ b/shlink-web-component/src/short-urls/helpers/ShortUrlDetailLink.tsx @@ -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> = ( + { shortUrl, suffix, asLink, children, ...rest }, +) => { + const routePrefix = useRoutesPrefix(); + if (!asLink || !shortUrl) { + return {children}; + } + + return {children}; +}; diff --git a/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx similarity index 83% rename from src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx rename to shlink-web-component/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx index 67e17d79..af7c88d7 100644 --- a/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx +++ b/shlink-web-component/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx @@ -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; diff --git a/src/short-urls/helpers/ShortUrlStatus.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlStatus.tsx similarity index 90% rename from src/short-urls/helpers/ShortUrlStatus.tsx rename to shlink-web-component/src/short-urls/helpers/ShortUrlStatus.tsx index ec490b01..419706f0 100644 --- a/src/short-urls/helpers/ShortUrlStatus.tsx +++ b/shlink-web-component/src/short-urls/helpers/ShortUrlStatus.tsx @@ -1,15 +1,15 @@ import type { IconDefinition } from '@fortawesome/fontawesome-common-types'; import { faCalendarXmark, faCheck, faLinkSlash } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useElementRef } from '@shlinkio/shlink-frontend-kit'; import { isBefore } from 'date-fns'; import type { FC, ReactNode } from 'react'; import { UncontrolledTooltip } from 'reactstrap'; -import { formatHumanFriendly, now, parseISO } from '../../utils/helpers/date'; -import { useElementRef } from '../../utils/helpers/hooks'; -import type { ShortUrl } from '../data'; +import type { ShlinkShortUrl } from '../../api-contract'; +import { formatHumanFriendly, now, parseISO } from '../../utils/dates/helpers/date'; interface ShortUrlStatusProps { - shortUrl: ShortUrl; + shortUrl: ShlinkShortUrl; } interface StatusResult { @@ -18,7 +18,7 @@ interface StatusResult { description: ReactNode; } -const resolveShortUrlStatus = (shortUrl: ShortUrl): StatusResult => { +const resolveShortUrlStatus = (shortUrl: ShlinkShortUrl): StatusResult => { const { meta, visitsCount, visitsSummary } = shortUrl; const { maxVisits, validSince, validUntil } = meta; const totalVisits = visitsSummary?.total ?? visitsCount; diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.scss b/shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.scss similarity index 100% rename from src/short-urls/helpers/ShortUrlVisitsCount.scss rename to shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.scss diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.tsx similarity index 83% rename from src/short-urls/helpers/ShortUrlVisitsCount.tsx rename to shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.tsx index 0dd53396..a66e16f9 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.tsx +++ b/shlink-web-component/src/short-urls/helpers/ShortUrlVisitsCount.tsx @@ -1,29 +1,28 @@ import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useElementRef } from '@shlinkio/shlink-frontend-kit'; import classNames from 'classnames'; import { UncontrolledTooltip } from 'reactstrap'; -import type { SelectedServer } from '../../servers/data'; -import { formatHumanFriendly, parseISO } from '../../utils/helpers/date'; -import { useElementRef } from '../../utils/helpers/hooks'; +import type { ShlinkShortUrl } from '../../api-contract'; +import { formatHumanFriendly, parseISO } from '../../utils/dates/helpers/date'; import { prettify } from '../../utils/helpers/numbers'; -import type { ShortUrl } from '../data'; import { ShortUrlDetailLink } from './ShortUrlDetailLink'; import './ShortUrlVisitsCount.scss'; interface ShortUrlVisitsCountProps { - shortUrl?: ShortUrl | null; - selectedServer?: SelectedServer; + shortUrl?: ShlinkShortUrl | null; visitsCount: number; active?: boolean; + asLink?: boolean; } export const ShortUrlVisitsCount = ( - { visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps, + { visitsCount, shortUrl, active = false, asLink = false }: ShortUrlVisitsCountProps, ) => { const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {}; const hasLimit = !!maxVisits || !!validSince || !!validUntil; const visitsLink = ( - + diff --git a/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx similarity index 94% rename from src/short-urls/helpers/ShortUrlsFilterDropdown.tsx rename to shlink-web-component/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx index 16e47395..fd1bb21f 100644 --- a/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx +++ b/shlink-web-component/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx @@ -1,6 +1,6 @@ +import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; import { DropdownItem } from 'reactstrap'; -import { DropdownBtn } from '../../utils/DropdownBtn'; -import { hasValue } from '../../utils/utils'; +import { hasValue } from '../../utils/helpers'; import type { ShortUrlsFilter } from '../data'; interface ShortUrlsFilterDropdownProps { diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.scss similarity index 79% rename from src/short-urls/helpers/ShortUrlsRow.scss rename to shlink-web-component/src/short-urls/helpers/ShortUrlsRow.scss index 64af7818..5ecd9c9d 100644 --- a/src/short-urls/helpers/ShortUrlsRow.scss +++ b/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.scss @@ -1,7 +1,12 @@ -@import '../../utils/base'; -@import '../../utils/mixins/text-ellipsis'; +@import '@shlinkio/shlink-frontend-kit/base'; @import '../../utils/mixins/vertical-align'; +@mixin text-ellipsis() { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + .short-urls-row__cell.short-urls-row__cell { vertical-align: middle !important; } @@ -33,7 +38,7 @@ .short-urls-row__copy-hint { @include vertical-align(translateX(10px)); - box-shadow: 0 3px 15px rgba(0, 0, 0, .25); + box-shadow: 0 3px 15px rgb(0 0 0 / .25); @media (max-width: $responsiveTableBreakpoint) { @include vertical-align(translateX(calc(-100% - 20px))); diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.tsx similarity index 84% rename from src/short-urls/helpers/ShortUrlsRow.tsx rename to shlink-web-component/src/short-urls/helpers/ShortUrlsRow.tsx index ebc5a968..41f736a0 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/shlink-web-component/src/short-urls/helpers/ShortUrlsRow.tsx @@ -1,13 +1,12 @@ import type { FC } from 'react'; import { useEffect, useRef } from 'react'; import { ExternalLink } from 'react-external-link'; -import type { SelectedServer } from '../../servers/data'; -import type { Settings } from '../../settings/reducers/settings'; -import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; +import type { ShlinkShortUrl } from '../../api-contract'; +import { CopyToClipboardIcon } from '../../utils/components/CopyToClipboardIcon'; import { Time } from '../../utils/dates/Time'; import type { TimeoutToggle } from '../../utils/helpers/hooks'; import type { ColorGenerator } from '../../utils/services/ColorGenerator'; -import type { ShortUrl } from '../data'; +import { useSetting } from '../../utils/settings'; import { useShortUrlsQuery } from './hooks'; import type { ShortUrlsRowMenuType } from './ShortUrlsRowMenu'; import { ShortUrlStatus } from './ShortUrlStatus'; @@ -17,12 +16,7 @@ import './ShortUrlsRow.scss'; interface ShortUrlsRowProps { onTagClick?: (tag: string) => void; - selectedServer: SelectedServer; - shortUrl: ShortUrl; -} - -interface ShortUrlsRowConnectProps extends ShortUrlsRowProps { - settings: Settings; + shortUrl: ShlinkShortUrl; } export type ShortUrlsRowType = FC; @@ -31,12 +25,12 @@ export const ShortUrlsRow = ( ShortUrlsRowMenu: ShortUrlsRowMenuType, colorGenerator: ColorGenerator, useTimeoutToggle: TimeoutToggle, -) => ({ shortUrl, selectedServer, onTagClick, settings }: ShortUrlsRowConnectProps) => { +) => ({ shortUrl, onTagClick }: ShortUrlsRowProps) => { const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle(); const [active, setActive] = useTimeoutToggle(false, 500); const isFirstRun = useRef(true); const [{ excludeBots }] = useShortUrlsQuery(); - const { visits } = settings; + const visits = useSetting('visits'); const doExcludeBots = excludeBots ?? visits?.excludeBots; useEffect(() => { @@ -80,15 +74,15 @@ export const ShortUrlsRow = ( doExcludeBots ? shortUrl.visitsSummary?.nonBots : shortUrl.visitsSummary?.total ) ?? shortUrl.visitsCount} shortUrl={shortUrl} - selectedServer={selectedServer} active={active} + asLink /> - + ); diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.tsx b/shlink-web-component/src/short-urls/helpers/ShortUrlsRowMenu.tsx similarity index 72% rename from src/short-urls/helpers/ShortUrlsRowMenu.tsx rename to shlink-web-component/src/short-urls/helpers/ShortUrlsRowMenu.tsx index c7a20b35..7e4a4cc0 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.tsx +++ b/shlink-web-component/src/short-urls/helpers/ShortUrlsRowMenu.tsx @@ -5,34 +5,32 @@ import { faQrcode as qrIcon, } 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 { DropdownItem } from 'reactstrap'; -import type { SelectedServer } from '../../servers/data'; -import { useToggle } from '../../utils/helpers/hooks'; -import { RowDropdownBtn } from '../../utils/RowDropdownBtn'; -import type { ShortUrl, ShortUrlModalProps } from '../data'; +import type { ShlinkShortUrl } from '../../api-contract'; +import type { ShortUrlModalProps } from '../data'; import { ShortUrlDetailLink } from './ShortUrlDetailLink'; interface ShortUrlsRowMenuProps { - selectedServer: SelectedServer; - shortUrl: ShortUrl; + shortUrl: ShlinkShortUrl; } type ShortUrlModal = FC; export const ShortUrlsRowMenu = ( DeleteShortUrlModal: ShortUrlModal, QrCodeModal: ShortUrlModal, -) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => { +) => ({ shortUrl }: ShortUrlsRowMenuProps) => { const [isQrModalOpen,, openQrCodeModal, closeQrCodeModal] = useToggle(); const [isDeleteModalOpen,, openDeleteModal, closeDeleteModal] = useToggle(); return ( - + Visit stats - + Edit short URL diff --git a/src/short-urls/helpers/Tags.tsx b/shlink-web-component/src/short-urls/helpers/Tags.tsx similarity index 100% rename from src/short-urls/helpers/Tags.tsx rename to shlink-web-component/src/short-urls/helpers/Tags.tsx diff --git a/src/short-urls/helpers/hooks.ts b/shlink-web-component/src/short-urls/helpers/hooks.ts similarity index 77% rename from src/short-urls/helpers/hooks.ts rename to shlink-web-component/src/short-urls/helpers/hooks.ts index 054d5d49..fd5d550a 100644 --- a/src/short-urls/helpers/hooks.ts +++ b/shlink-web-component/src/short-urls/helpers/hooks.ts @@ -1,11 +1,11 @@ +import { orderToString, parseQuery, stringifyQuery, stringToOrder } from '@shlinkio/shlink-frontend-kit'; import { isEmpty, pipe } from 'ramda'; -import { useMemo } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import type { TagsFilteringMode } from '../../api/types'; -import { orderToString, stringToOrder } from '../../utils/helpers/ordering'; -import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; -import type { BooleanString } from '../../utils/utils'; -import { parseOptionalBooleanToString } from '../../utils/utils'; +import { useCallback, useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import type { TagsFilteringMode } from '../../api-contract'; +import type { BooleanString } from '../../utils/helpers'; +import { parseOptionalBooleanToString } from '../../utils/helpers'; +import { useRoutesPrefix } from '../../utils/routesPrefix'; import type { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data'; interface ShortUrlsQueryCommon { @@ -36,7 +36,7 @@ type ToFirstPage = (extra: Partial) => void; export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { const navigate = useNavigate(); const { search } = useLocation(); - const { serverId = '' } = useParams<{ serverId: string }>(); + const routesPrefix = useRoutesPrefix(); const filtering = useMemo( pipe( @@ -56,7 +56,7 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { ), [search], ); - const toFirstPageWithExtra = (extra: Partial) => { + const toFirstPageWithExtra = useCallback((extra: Partial) => { const merged = { ...filtering, ...extra }; const { orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...mergedFiltering } = merged; const query: ShortUrlsQuery = { @@ -70,8 +70,8 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { const stringifiedQuery = stringifyQuery(query); const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`; - navigate(`/server/${serverId}/list-short-urls/1${queryString}`); - }; + navigate(`${routesPrefix}/list-short-urls/1${queryString}`); + }, [filtering, navigate, routesPrefix]); return [filtering, toFirstPageWithExtra]; }; diff --git a/src/short-urls/helpers/index.ts b/shlink-web-component/src/short-urls/helpers/index.ts similarity index 58% rename from src/short-urls/helpers/index.ts rename to shlink-web-component/src/short-urls/helpers/index.ts index 771db963..f576bc5f 100644 --- a/src/short-urls/helpers/index.ts +++ b/shlink-web-component/src/short-urls/helpers/index.ts @@ -1,10 +1,10 @@ import { isNil } from 'ramda'; -import type { ShortUrlCreationSettings } from '../../settings/reducers/settings'; -import type { OptionalString } from '../../utils/utils'; +import type { ShlinkCreateShortUrlData, ShlinkShortUrl } from '../../api-contract'; +import type { OptionalString } from '../../utils/helpers'; +import type { ShortUrlCreationSettings } from '../../utils/settings'; import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; -import type { ShortUrl, ShortUrlData } from '../data'; -export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: OptionalString): boolean => { +export const shortUrlMatches = (shortUrl: ShlinkShortUrl, shortCode: string, domain: OptionalString): boolean => { if (isNil(domain)) { return shortUrl.shortCode === shortCode && !shortUrl.domain; } @@ -12,7 +12,7 @@ export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: O return shortUrl.shortCode === shortCode && shortUrl.domain === domain; }; -export const domainMatches = (shortUrl: ShortUrl, domain: string): boolean => { +export const domainMatches = (shortUrl: ShlinkShortUrl, domain: string): boolean => { if (!shortUrl.domain && domain === DEFAULT_DOMAIN) { return true; } @@ -20,7 +20,11 @@ export const domainMatches = (shortUrl: ShortUrl, domain: string): boolean => { return shortUrl.domain === domain; }; -export const shortUrlDataFromShortUrl = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => { +// FIXME This should return ShlinkEditShortUrlData +export const shortUrlDataFromShortUrl = ( + shortUrl?: ShlinkShortUrl, + settings?: ShortUrlCreationSettings, +): ShlinkCreateShortUrlData => { const validateUrl = settings?.validateUrls ?? false; if (!shortUrl) { @@ -37,7 +41,11 @@ export const shortUrlDataFromShortUrl = (shortUrl?: ShortUrl, settings?: ShortUr maxVisits: shortUrl.meta.maxVisits ?? undefined, crawlable: shortUrl.crawlable, forwardQuery: shortUrl.forwardQuery, - deviceLongUrls: shortUrl.deviceLongUrls, + deviceLongUrls: shortUrl.deviceLongUrls && { + android: shortUrl.deviceLongUrls.android ?? undefined, + ios: shortUrl.deviceLongUrls.ios ?? undefined, + desktop: shortUrl.deviceLongUrls.desktop ?? undefined, + }, validateUrl, }; }; diff --git a/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx b/shlink-web-component/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx similarity index 94% rename from src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx rename to shlink-web-component/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx index 81673261..4fbc7950 100644 --- a/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx +++ b/shlink-web-component/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx @@ -1,6 +1,6 @@ +import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; import type { FC } from 'react'; import { DropdownItem } from 'reactstrap'; -import { DropdownBtn } from '../../../utils/DropdownBtn'; import type { QrErrorCorrection } from '../../../utils/helpers/qrCodes'; interface QrErrorCorrectionDropdownProps { diff --git a/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx b/shlink-web-component/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx similarity index 90% rename from src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx rename to shlink-web-component/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx index c7170b9c..8ed9b08a 100644 --- a/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx +++ b/shlink-web-component/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx @@ -1,6 +1,6 @@ +import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; import type { FC } from 'react'; import { DropdownItem } from 'reactstrap'; -import { DropdownBtn } from '../../../utils/DropdownBtn'; import type { QrCodeFormat } from '../../../utils/helpers/qrCodes'; interface QrFormatDropdownProps { diff --git a/src/short-urls/reducers/shortUrlCreation.ts b/shlink-web-component/src/short-urls/reducers/shortUrlCreation.ts similarity index 68% rename from src/short-urls/reducers/shortUrlCreation.ts rename to shlink-web-component/src/short-urls/reducers/shortUrlCreation.ts index 2fa080ab..df3efd0c 100644 --- a/src/short-urls/reducers/shortUrlCreation.ts +++ b/shlink-web-component/src/short-urls/reducers/shortUrlCreation.ts @@ -1,10 +1,7 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import type { ProblemDetailsError } from '../../api/types/errors'; -import { parseApiError } from '../../api/utils'; -import { createAsyncThunk } from '../../utils/helpers/redux'; -import type { ShortUrl, ShortUrlData } from '../data'; +import type { ProblemDetailsError, ShlinkApiClient, ShlinkCreateShortUrlData, ShlinkShortUrl } from '../../api-contract'; +import { parseApiError } from '../../api-contract/utils'; +import { createAsyncThunk } from '../../utils/redux'; const REDUCER_PREFIX = 'shlink/shortUrlCreation'; @@ -22,23 +19,21 @@ export type ShortUrlCreation = { error: true; errorData?: ProblemDetailsError; } | { - result: ShortUrl; + result: ShlinkShortUrl; saving: false; saved: true; error: false; }; -export type CreateShortUrlAction = PayloadAction; - const initialState: ShortUrlCreation = { saving: false, saved: false, error: false, }; -export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( +export const createShortUrl = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk( `${REDUCER_PREFIX}/createShortUrl`, - (data: ShortUrlData, { getState }): Promise => buildShlinkApiClient(getState).createShortUrl(data), + (data: ShlinkCreateShortUrlData): Promise => apiClientFactory().createShortUrl(data), ); export const shortUrlCreationReducerCreator = (createShortUrlThunk: ReturnType) => { diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/shlink-web-component/src/short-urls/reducers/shortUrlDeletion.ts similarity index 64% rename from src/short-urls/reducers/shortUrlDeletion.ts rename to shlink-web-component/src/short-urls/reducers/shortUrlDeletion.ts index 7d4837f1..91ff93ef 100644 --- a/src/short-urls/reducers/shortUrlDeletion.ts +++ b/shlink-web-component/src/short-urls/reducers/shortUrlDeletion.ts @@ -1,9 +1,8 @@ import { createAction, createSlice } from '@reduxjs/toolkit'; -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import type { ProblemDetailsError } from '../../api/types/errors'; -import { parseApiError } from '../../api/utils'; -import { createAsyncThunk } from '../../utils/helpers/redux'; -import type { ShortUrl, ShortUrlIdentifier } from '../data'; +import type { ProblemDetailsError, ShlinkApiClient, ShlinkShortUrl } from '../../api-contract'; +import { parseApiError } from '../../api-contract/utils'; +import { createAsyncThunk } from '../../utils/redux'; +import type { ShortUrlIdentifier } from '../data'; const REDUCER_PREFIX = 'shlink/shortUrlDeletion'; @@ -22,16 +21,15 @@ const initialState: ShortUrlDeletion = { error: false, }; -export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( +export const deleteShortUrl = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk( `${REDUCER_PREFIX}/deleteShortUrl`, - async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { - const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState); - await shlinkDeleteShortUrl(shortCode, domain); + async ({ shortCode, domain }: ShortUrlIdentifier): Promise => { + await apiClientFactory().deleteShortUrl(shortCode, domain); return { shortCode, domain }; }, ); -export const shortUrlDeleted = createAction(`${REDUCER_PREFIX}/shortUrlDeleted`); +export const shortUrlDeleted = createAction(`${REDUCER_PREFIX}/shortUrlDeleted`); export const shortUrlDeletionReducerCreator = (deleteShortUrlThunk: ReturnType) => { const { actions, reducer } = createSlice({ diff --git a/src/short-urls/reducers/shortUrlDetail.ts b/shlink-web-component/src/short-urls/reducers/shortUrlDetail.ts similarity index 66% rename from src/short-urls/reducers/shortUrlDetail.ts rename to shlink-web-component/src/short-urls/reducers/shortUrlDetail.ts index 932d3dfc..f9426a81 100644 --- a/src/short-urls/reducers/shortUrlDetail.ts +++ b/shlink-web-component/src/short-urls/reducers/shortUrlDetail.ts @@ -1,36 +1,35 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import type { ProblemDetailsError } from '../../api/types/errors'; -import { parseApiError } from '../../api/utils'; -import { createAsyncThunk } from '../../utils/helpers/redux'; -import type { ShortUrl, ShortUrlIdentifier } from '../data'; +import type { ProblemDetailsError, ShlinkApiClient, ShlinkShortUrl } from '../../api-contract'; +import { parseApiError } from '../../api-contract/utils'; +import { createAsyncThunk } from '../../utils/redux'; +import type { ShortUrlIdentifier } from '../data'; import { shortUrlMatches } from '../helpers'; const REDUCER_PREFIX = 'shlink/shortUrlDetail'; export interface ShortUrlDetail { - shortUrl?: ShortUrl; + shortUrl?: ShlinkShortUrl; loading: boolean; error: boolean; errorData?: ProblemDetailsError; } -export type ShortUrlDetailAction = PayloadAction; +export type ShortUrlDetailAction = PayloadAction; const initialState: ShortUrlDetail = { loading: false, error: false, }; -export const shortUrlDetailReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { +export const shortUrlDetailReducerCreator = (apiClientFactory: () => ShlinkApiClient) => { const getShortUrlDetail = createAsyncThunk( `${REDUCER_PREFIX}/getShortUrlDetail`, - async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { + async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { const { shortUrlsList } = getState(); const alreadyLoaded = shortUrlsList?.shortUrls?.data.find((url) => shortUrlMatches(url, shortCode, domain)); - return alreadyLoaded ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain); + return alreadyLoaded ?? await apiClientFactory().getShortUrl(shortCode, domain); }, ); diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/shlink-web-component/src/short-urls/reducers/shortUrlEdition.ts similarity index 56% rename from src/short-urls/reducers/shortUrlEdition.ts rename to shlink-web-component/src/short-urls/reducers/shortUrlEdition.ts index 103e6449..87d4bbe9 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/shlink-web-component/src/short-urls/reducers/shortUrlEdition.ts @@ -1,15 +1,13 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import type { ProblemDetailsError } from '../../api/types/errors'; -import { parseApiError } from '../../api/utils'; -import { createAsyncThunk } from '../../utils/helpers/redux'; -import type { EditShortUrlData, ShortUrl, ShortUrlIdentifier } from '../data'; +import type { ProblemDetailsError, ShlinkApiClient, ShlinkEditShortUrlData, ShlinkShortUrl } from '../../api-contract'; +import { parseApiError } from '../../api-contract/utils'; +import { createAsyncThunk } from '../../utils/redux'; +import type { ShortUrlIdentifier } from '../data'; const REDUCER_PREFIX = 'shlink/shortUrlEdition'; export interface ShortUrlEdition { - shortUrl?: ShortUrl; + shortUrl?: ShlinkShortUrl; saving: boolean; saved: boolean; error: boolean; @@ -17,23 +15,20 @@ export interface ShortUrlEdition { } export interface EditShortUrl extends ShortUrlIdentifier { - data: EditShortUrlData; + data: ShlinkEditShortUrlData; } -export type ShortUrlEditedAction = PayloadAction; - const initialState: ShortUrlEdition = { saving: false, saved: false, error: false, }; -export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( +export const editShortUrl = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk( `${REDUCER_PREFIX}/editShortUrl`, - ({ shortCode, domain, data }: EditShortUrl, { getState }): Promise => { - const { updateShortUrl } = buildShlinkApiClient(getState); - return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates - }, + ({ shortCode, domain, data }: EditShortUrl): Promise => + apiClientFactory().updateShortUrl(shortCode, domain, data as any) // TODO parse dates + , ); export const shortUrlEditionReducerCreator = (editShortUrlThunk: ReturnType) => createSlice({ diff --git a/src/short-urls/reducers/shortUrlsList.ts b/shlink-web-component/src/short-urls/reducers/shortUrlsList.ts similarity index 84% rename from src/short-urls/reducers/shortUrlsList.ts rename to shlink-web-component/src/short-urls/reducers/shortUrlsList.ts index b2920ba4..c33a81af 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/shlink-web-component/src/short-urls/reducers/shortUrlsList.ts @@ -1,10 +1,8 @@ import { createSlice } from '@reduxjs/toolkit'; import { assocPath, last, pipe, reject } from 'ramda'; -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import type { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types'; -import { createAsyncThunk } from '../../utils/helpers/redux'; +import type { ShlinkApiClient, ShlinkShortUrl, ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api-contract'; +import { createAsyncThunk } from '../../utils/redux'; import { createNewVisits } from '../../visits/reducers/visitCreation'; -import type { ShortUrl } from '../data'; import { shortUrlMatches } from '../helpers'; import type { createShortUrl } from './shortUrlCreation'; import { shortUrlDeleted } from './shortUrlDeletion'; @@ -24,12 +22,11 @@ const initialState: ShortUrlsList = { error: false, }; -export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( +export const listShortUrls = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk( `${REDUCER_PREFIX}/listShortUrls`, - (params: ShlinkShortUrlsListParams | void, { getState }): Promise => { - const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState); - return shlinkListShortUrls(params ?? {}); - }, + (params: ShlinkShortUrlsListParams | void): Promise => apiClientFactory().listShortUrls( + params ?? {}, + ), ); export const shortUrlsListReducerCreator = ( @@ -84,7 +81,7 @@ export const shortUrlsListReducerCreator = ( pipe( (state, { payload }) => (!state.shortUrls ? state : assocPath( ['shortUrls', 'data'], - reject((shortUrl) => + reject((shortUrl) => shortUrlMatches(shortUrl, payload.shortCode, payload.domain), state.shortUrls.data), state, )), diff --git a/src/short-urls/services/provideServices.ts b/shlink-web-component/src/short-urls/services/provideServices.ts similarity index 82% rename from src/short-urls/services/provideServices.ts rename to shlink-web-component/src/short-urls/services/provideServices.ts index 1e5f79c8..9f90c675 100644 --- a/src/short-urls/services/provideServices.ts +++ b/shlink-web-component/src/short-urls/services/provideServices.ts @@ -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 { CreateShortUrl } from '../CreateShortUrl'; import { EditShortUrl } from '../EditShortUrl'; import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult'; @@ -23,15 +23,12 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar'); bottle.decorator('ShortUrlsList', connect( - ['selectedServer', 'mercureInfo', 'shortUrlsList', 'settings'], + ['mercureInfo', 'shortUrlsList'], ['listShortUrls', 'createNewVisits', 'loadMercureInfo'], )); bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow'); - bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle'); - bottle.decorator('ShortUrlsRow', connect(['settings'])); - bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal'); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle'); bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector'); @@ -39,12 +36,12 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult'); bottle.decorator( 'CreateShortUrl', - connect(['shortUrlCreation', 'selectedServer', 'settings'], ['createShortUrl', 'resetCreateShortUrl']), + connect(['shortUrlCreation'], ['createShortUrl', 'resetCreateShortUrl']), ); bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm'); bottle.decorator('EditShortUrl', connect( - ['shortUrlDetail', 'shortUrlEdition', 'selectedServer', 'settings'], + ['shortUrlDetail', 'shortUrlEdition'], ['getShortUrlDetail', 'editShortUrl'], )); @@ -55,12 +52,9 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { )); bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader'); - bottle.decorator('QrCodeModal', connect(['selectedServer'])); - bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector'); - bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter'); - bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer'])); + bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'apiClientFactory', 'ReportExporter'); // Reducers bottle.serviceFactory( @@ -81,20 +75,20 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'deleteShortUrl'); bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator'); - bottle.serviceFactory('shortUrlDetailReducerCreator', shortUrlDetailReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('shortUrlDetailReducerCreator', shortUrlDetailReducerCreator, 'apiClientFactory'); bottle.serviceFactory('shortUrlDetailReducer', prop('reducer'), 'shortUrlDetailReducerCreator'); // Actions - bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); + bottle.serviceFactory('listShortUrls', listShortUrls, 'apiClientFactory'); - bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient'); + bottle.serviceFactory('createShortUrl', createShortUrl, 'apiClientFactory'); bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator'); - bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient'); + bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'apiClientFactory'); bottle.serviceFactory('resetDeleteShortUrl', prop('resetDeleteShortUrl'), 'shortUrlDeletionReducerCreator'); bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted); bottle.serviceFactory('getShortUrlDetail', prop('getShortUrlDetail'), 'shortUrlDetailReducerCreator'); - bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient'); + bottle.serviceFactory('editShortUrl', editShortUrl, 'apiClientFactory'); }; diff --git a/src/tags/TagsList.tsx b/shlink-web-component/src/tags/TagsList.tsx similarity index 80% rename from src/tags/TagsList.tsx rename to shlink-web-component/src/tags/TagsList.tsx index 13aad995..5e6acee8 100644 --- a/src/tags/TagsList.tsx +++ b/shlink-web-component/src/tags/TagsList.tsx @@ -1,17 +1,12 @@ +import { determineOrderDir, Message, OrderingDropdown, Result, SearchField, sortList } from '@shlinkio/shlink-frontend-kit'; import { pipe } from 'ramda'; import type { FC } from 'react'; import { useEffect, useState } from 'react'; import { Row } from 'reactstrap'; -import { ShlinkApiError } from '../api/ShlinkApiError'; +import { ShlinkApiError } from '../common/ShlinkApiError'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; -import type { SelectedServer } from '../servers/data'; -import type { Settings } from '../settings/reducers/settings'; -import { determineOrderDir, sortList } from '../utils/helpers/ordering'; -import { Message } from '../utils/Message'; -import { OrderingDropdown } from '../utils/OrderingDropdown'; -import { Result } from '../utils/Result'; -import { SearchField } from '../utils/SearchField'; +import { useSettings } from '../utils/settings'; import type { SimplifiedTag } from './data'; import type { TagsOrder, TagsOrderableFields } from './data/TagsListChildrenProps'; import { TAGS_ORDERABLE_FIELDS } from './data/TagsListChildrenProps'; @@ -22,13 +17,12 @@ export interface TagsListProps { filterTags: (searchTerm: string) => void; forceListTags: Function; tagsList: TagsListState; - selectedServer: SelectedServer; - settings: Settings; } export const TagsList = (TagsTable: FC) => boundToMercureHub(( - { filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps, + { filterTags, forceListTags, tagsList }: TagsListProps, ) => { + const settings = useSettings(); const [order, setOrder] = useState(settings.tags?.defaultOrdering ?? {}); const resolveSortedTags = pipe( () => tagsList.filteredTags.map((tag): SimplifiedTag => { @@ -78,7 +72,6 @@ export const TagsList = (TagsTable: FC) => boundToMercureHub(( return ( diff --git a/src/tags/TagsTable.scss b/shlink-web-component/src/tags/TagsTable.scss similarity index 80% rename from src/tags/TagsTable.scss rename to shlink-web-component/src/tags/TagsTable.scss index a6e4dceb..2a8699e4 100644 --- a/src/tags/TagsTable.scss +++ b/shlink-web-component/src/tags/TagsTable.scss @@ -1,4 +1,4 @@ -@import '../utils/base'; +@import '@shlinkio/shlink-frontend-kit/base'; @import '../utils/mixins/sticky-cell'; .tags-table__header-cell.tags-table__header-cell { diff --git a/src/tags/TagsTable.tsx b/shlink-web-component/src/tags/TagsTable.tsx similarity index 90% rename from src/tags/TagsTable.tsx rename to shlink-web-component/src/tags/TagsTable.tsx index 3898c623..34474258 100644 --- a/src/tags/TagsTable.tsx +++ b/shlink-web-component/src/tags/TagsTable.tsx @@ -1,11 +1,10 @@ +import { parseQuery, SimpleCard } from '@shlinkio/shlink-frontend-kit'; import { splitEvery } from 'ramda'; import type { FC } from 'react'; import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; -import { SimplePaginator } from '../common/SimplePaginator'; +import { SimplePaginator } from '../utils/components/SimplePaginator'; import { useQueryState } from '../utils/helpers/hooks'; -import { parseQuery } from '../utils/helpers/query'; -import { SimpleCard } from '../utils/SimpleCard'; import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import type { TagsListChildrenProps, TagsOrder, TagsOrderableFields } from './data/TagsListChildrenProps'; import type { TagsTableRowProps } from './TagsTableRow'; @@ -19,7 +18,7 @@ export interface TagsTableProps extends TagsListChildrenProps { const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings export const TagsTable = (TagsTableRow: FC) => ( - { sortedTags, selectedServer, orderByColumn, currentOrder }: TagsTableProps, + { sortedTags, orderByColumn, currentOrder }: TagsTableProps, ) => { const isFirstLoad = useRef(true); const { search } = useLocation(); @@ -57,7 +56,7 @@ export const TagsTable = (TagsTableRow: FC) => ( {currentPage.length === 0 && No results found} - {currentPage.map((tag) => )} + {currentPage.map((tag) => )} diff --git a/src/tags/TagsTableRow.tsx b/shlink-web-component/src/tags/TagsTableRow.tsx similarity index 79% rename from src/tags/TagsTableRow.tsx rename to shlink-web-component/src/tags/TagsTableRow.tsx index b643a388..f71beaae 100644 --- a/src/tags/TagsTableRow.tsx +++ b/shlink-web-component/src/tags/TagsTableRow.tsx @@ -1,30 +1,27 @@ import { faPencilAlt as editIcon, faTrash as deleteIcon } 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 { useToggle } from '../utils/helpers/hooks'; import { prettify } from '../utils/helpers/numbers'; -import { RowDropdownBtn } from '../utils/RowDropdownBtn'; +import { useRoutesPrefix } from '../utils/routesPrefix'; import type { ColorGenerator } from '../utils/services/ColorGenerator'; import type { SimplifiedTag, TagModalProps } from './data'; import { TagBullet } from './helpers/TagBullet'; export interface TagsTableRowProps { tag: SimplifiedTag; - selectedServer: SelectedServer; } export const TagsTableRow = ( DeleteTagConfirmModal: FC, EditTagModal: FC, colorGenerator: ColorGenerator, -) => ({ tag, selectedServer }: TagsTableRowProps) => { +) => ({ tag }: TagsTableRowProps) => { const [isDeleteModalOpen, toggleDelete] = useToggle(); const [isEditModalOpen, toggleEdit] = useToggle(); - const serverId = getServerId(selectedServer); + const routesPrefix = useRoutesPrefix(); return ( @@ -32,12 +29,12 @@ export const TagsTableRow = ( {tag.tag} - + {prettify(tag.shortUrls)} - + {prettify(tag.visits)} diff --git a/src/tags/data/TagsListChildrenProps.ts b/shlink-web-component/src/tags/data/TagsListChildrenProps.ts similarity index 69% rename from src/tags/data/TagsListChildrenProps.ts rename to shlink-web-component/src/tags/data/TagsListChildrenProps.ts index 3e2843eb..4310431a 100644 --- a/src/tags/data/TagsListChildrenProps.ts +++ b/shlink-web-component/src/tags/data/TagsListChildrenProps.ts @@ -1,5 +1,4 @@ -import type { SelectedServer } from '../../servers/data'; -import type { Order } from '../../utils/helpers/ordering'; +import type { Order } from '@shlinkio/shlink-frontend-kit'; import type { SimplifiedTag } from './index'; export const TAGS_ORDERABLE_FIELDS = { @@ -14,5 +13,4 @@ export type TagsOrder = Order; export interface TagsListChildrenProps { sortedTags: SimplifiedTag[]; - selectedServer: SelectedServer; } diff --git a/src/tags/data/index.ts b/shlink-web-component/src/tags/data/index.ts similarity index 73% rename from src/tags/data/index.ts rename to shlink-web-component/src/tags/data/index.ts index 744fcd47..2c2eb507 100644 --- a/src/tags/data/index.ts +++ b/shlink-web-component/src/tags/data/index.ts @@ -1,4 +1,4 @@ -import type { ShlinkTagsStats } from '../../api/types'; +import type { ShlinkTagsStats } from '@shlinkio/shlink-web-component/api-contract'; export type TagStats = Omit; diff --git a/src/tags/helpers/DeleteTagConfirmModal.tsx b/shlink-web-component/src/tags/helpers/DeleteTagConfirmModal.tsx similarity index 92% rename from src/tags/helpers/DeleteTagConfirmModal.tsx rename to shlink-web-component/src/tags/helpers/DeleteTagConfirmModal.tsx index 2d0742b8..663af566 100644 --- a/src/tags/helpers/DeleteTagConfirmModal.tsx +++ b/shlink-web-component/src/tags/helpers/DeleteTagConfirmModal.tsx @@ -1,6 +1,6 @@ +import { Result } from '@shlinkio/shlink-frontend-kit'; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; -import { ShlinkApiError } from '../../api/ShlinkApiError'; -import { Result } from '../../utils/Result'; +import { ShlinkApiError } from '../../common/ShlinkApiError'; import type { TagModalProps } from '../data'; import type { TagDeletion } from '../reducers/tagDelete'; diff --git a/src/tags/helpers/EditTagModal.scss b/shlink-web-component/src/tags/helpers/EditTagModal.scss similarity index 100% rename from src/tags/helpers/EditTagModal.scss rename to shlink-web-component/src/tags/helpers/EditTagModal.scss diff --git a/src/tags/helpers/EditTagModal.tsx b/shlink-web-component/src/tags/helpers/EditTagModal.tsx similarity index 93% rename from src/tags/helpers/EditTagModal.tsx rename to shlink-web-component/src/tags/helpers/EditTagModal.tsx index 8c5da75a..cb0aa7d3 100644 --- a/src/tags/helpers/EditTagModal.tsx +++ b/shlink-web-component/src/tags/helpers/EditTagModal.tsx @@ -1,14 +1,13 @@ import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Result, useToggle } from '@shlinkio/shlink-frontend-kit'; import { pipe } from 'ramda'; import { useState } from 'react'; import { HexColorPicker } from 'react-colorful'; import { Button, Input, InputGroup, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap'; -import { ShlinkApiError } from '../../api/ShlinkApiError'; -import { useToggle } from '../../utils/helpers/hooks'; -import { Result } from '../../utils/Result'; +import { ShlinkApiError } from '../../common/ShlinkApiError'; +import { handleEventPreventingDefault } from '../../utils/helpers'; import type { ColorGenerator } from '../../utils/services/ColorGenerator'; -import { handleEventPreventingDefault } from '../../utils/utils'; import type { TagModalProps } from '../data'; import type { EditTag, TagEdition } from '../reducers/tagEdit'; import './EditTagModal.scss'; diff --git a/src/tags/helpers/Tag.scss b/shlink-web-component/src/tags/helpers/Tag.scss similarity index 92% rename from src/tags/helpers/Tag.scss rename to shlink-web-component/src/tags/helpers/Tag.scss index 113f7f42..7db232f4 100644 --- a/src/tags/helpers/Tag.scss +++ b/shlink-web-component/src/tags/helpers/Tag.scss @@ -3,7 +3,7 @@ } .tag--light-bg { - color: #222 !important; + color: #222 !important; } .tag:not(:last-child) { diff --git a/src/tags/helpers/Tag.tsx b/shlink-web-component/src/tags/helpers/Tag.tsx similarity index 100% rename from src/tags/helpers/Tag.tsx rename to shlink-web-component/src/tags/helpers/Tag.tsx diff --git a/src/tags/helpers/TagBullet.scss b/shlink-web-component/src/tags/helpers/TagBullet.scss similarity index 100% rename from src/tags/helpers/TagBullet.scss rename to shlink-web-component/src/tags/helpers/TagBullet.scss diff --git a/src/tags/helpers/TagBullet.tsx b/shlink-web-component/src/tags/helpers/TagBullet.tsx similarity index 100% rename from src/tags/helpers/TagBullet.tsx rename to shlink-web-component/src/tags/helpers/TagBullet.tsx diff --git a/src/tags/helpers/TagsSelector.tsx b/shlink-web-component/src/tags/helpers/TagsSelector.tsx similarity index 88% rename from src/tags/helpers/TagsSelector.tsx rename to shlink-web-component/src/tags/helpers/TagsSelector.tsx index 15eb7926..72d74fcc 100644 --- a/src/tags/helpers/TagsSelector.tsx +++ b/shlink-web-component/src/tags/helpers/TagsSelector.tsx @@ -1,8 +1,8 @@ import { useEffect } from 'react'; import type { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete'; import ReactTags from 'react-tag-autocomplete'; -import type { Settings } from '../../settings/reducers/settings'; import type { ColorGenerator } from '../../utils/services/ColorGenerator'; +import { useSetting } from '../../utils/settings'; import type { TagsList } from '../reducers/tagsList'; import { Tag } from './Tag'; import { TagBullet } from './TagBullet'; @@ -17,19 +17,19 @@ export interface TagsSelectorProps { interface TagsSelectorConnectProps extends TagsSelectorProps { listTags: () => void; tagsList: TagsList; - settings: Settings; } const toComponentTag = (tag: string) => ({ id: tag, name: tag }); export const TagsSelector = (colorGenerator: ColorGenerator) => ( - { selectedTags, onChange, placeholder, listTags, tagsList, settings, allowNew = true }: TagsSelectorConnectProps, + { selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps, ) => { + const shortUrlCreation = useSetting('shortUrlCreation'); useEffect(() => { listTags(); }, []); - const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith'; + const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith'; const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) => ; const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => ( diff --git a/src/common/react-tag-autocomplete.scss b/shlink-web-component/src/tags/react-tag-autocomplete.scss similarity index 98% rename from src/common/react-tag-autocomplete.scss rename to shlink-web-component/src/tags/react-tag-autocomplete.scss index fdba7669..880d957b 100644 --- a/src/common/react-tag-autocomplete.scss +++ b/shlink-web-component/src/tags/react-tag-autocomplete.scss @@ -1,4 +1,4 @@ -@import '../utils/base'; +@import '@shlinkio/shlink-frontend-kit/base'; .react-tags { position: relative; diff --git a/src/tags/reducers/tagDelete.ts b/shlink-web-component/src/tags/reducers/tagDelete.ts similarity index 67% rename from src/tags/reducers/tagDelete.ts rename to shlink-web-component/src/tags/reducers/tagDelete.ts index 68be3b53..861f177e 100644 --- a/src/tags/reducers/tagDelete.ts +++ b/shlink-web-component/src/tags/reducers/tagDelete.ts @@ -1,8 +1,7 @@ import { createAction, createSlice } from '@reduxjs/toolkit'; -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import type { ProblemDetailsError } from '../../api/types/errors'; -import { parseApiError } from '../../api/utils'; -import { createAsyncThunk } from '../../utils/helpers/redux'; +import type { ProblemDetailsError, ShlinkApiClient } from '../../api-contract'; +import { parseApiError } from '../../api-contract/utils'; +import { createAsyncThunk } from '../../utils/redux'; const REDUCER_PREFIX = 'shlink/tagDelete'; @@ -21,10 +20,9 @@ const initialState: TagDeletion = { export const tagDeleted = createAction(`${REDUCER_PREFIX}/tagDeleted`); -export const tagDeleteReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const deleteTag = createAsyncThunk(`${REDUCER_PREFIX}/deleteTag`, async (tag: string, { getState }): Promise => { - const { deleteTags } = buildShlinkApiClient(getState); - await deleteTags([tag]); +export const tagDeleteReducerCreator = (apiClientFactory: () => ShlinkApiClient) => { + const deleteTag = createAsyncThunk(`${REDUCER_PREFIX}/deleteTag`, async (tag: string): Promise => { + await apiClientFactory().deleteTags([tag]); }); const { reducer } = createSlice({ diff --git a/src/tags/reducers/tagEdit.ts b/shlink-web-component/src/tags/reducers/tagEdit.ts similarity index 77% rename from src/tags/reducers/tagEdit.ts rename to shlink-web-component/src/tags/reducers/tagEdit.ts index 20d8e74b..4bc2f8ec 100644 --- a/src/tags/reducers/tagEdit.ts +++ b/shlink-web-component/src/tags/reducers/tagEdit.ts @@ -1,10 +1,9 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createAction, createSlice } from '@reduxjs/toolkit'; import { pick } from 'ramda'; -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import type { ProblemDetailsError } from '../../api/types/errors'; -import { parseApiError } from '../../api/utils'; -import { createAsyncThunk } from '../../utils/helpers/redux'; +import type { ProblemDetailsError, ShlinkApiClient } from '../../api-contract'; +import { parseApiError } from '../../api-contract/utils'; +import { createAsyncThunk } from '../../utils/redux'; import type { ColorGenerator } from '../../utils/services/ColorGenerator'; const REDUCER_PREFIX = 'shlink/tagEdit'; @@ -35,12 +34,12 @@ const initialState: TagEdition = { export const tagEdited = createAction(`${REDUCER_PREFIX}/tagEdited`); export const editTag = ( - buildShlinkApiClient: ShlinkApiClientBuilder, + apiClientFactory: () => ShlinkApiClient, colorGenerator: ColorGenerator, ) => createAsyncThunk( `${REDUCER_PREFIX}/editTag`, - async ({ oldName, newName, color }: EditTag, { getState }): Promise => { - await buildShlinkApiClient(getState).editTag(oldName, newName); + async ({ oldName, newName, color }: EditTag): Promise => { + await apiClientFactory().editTag(oldName, newName); colorGenerator.setColorForKey(newName, color); return { oldName, newName, color }; diff --git a/src/tags/reducers/tagsList.ts b/shlink-web-component/src/tags/reducers/tagsList.ts similarity index 85% rename from src/tags/reducers/tagsList.ts rename to shlink-web-component/src/tags/reducers/tagsList.ts index e92cb589..a7c46cbf 100644 --- a/src/tags/reducers/tagsList.ts +++ b/shlink-web-component/src/tags/reducers/tagsList.ts @@ -1,12 +1,9 @@ import { createAction, createSlice } from '@reduxjs/toolkit'; import { isEmpty, reject } from 'ramda'; -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import type { ShlinkTags } from '../../api/types'; -import type { ProblemDetailsError } from '../../api/types/errors'; -import { parseApiError } from '../../api/utils'; +import type { ProblemDetailsError, ShlinkApiClient, ShlinkTags } from '../../api-contract'; +import { parseApiError } from '../../api-contract/utils'; import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation'; -import { supportedFeatures } from '../../utils/helpers/features'; -import { createAsyncThunk } from '../../utils/helpers/redux'; +import { createAsyncThunk } from '../../utils/redux'; import { createNewVisits } from '../../visits/reducers/visitCreation'; import type { CreateVisit } from '../../visits/types'; import type { TagStats } from '../data'; @@ -83,19 +80,16 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O }, {}), ); -export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => createAsyncThunk( +export const listTags = (apiClientFactory: () => ShlinkApiClient, force = true) => createAsyncThunk( `${REDUCER_PREFIX}/listTags`, async (_: void, { getState }): Promise => { - const { tagsList, selectedServer } = getState(); + const { tagsList } = getState(); if (!force && !isEmpty(tagsList.tags)) { return tagsList; } - const { listTags: shlinkListTags, tagsStats } = buildShlinkApiClient(getState); - const { tags, stats }: ShlinkTags = await ( - supportedFeatures.tagsStats(selectedServer) ? tagsStats() : shlinkListTags() - ); + const { tags, stats }: ShlinkTags = await apiClientFactory().tagsStats(); const processedStats = stats.reduce((acc, { tag, ...rest }) => { acc[tag] = rest; return acc; diff --git a/src/tags/services/provideServices.ts b/shlink-web-component/src/tags/services/provideServices.ts similarity index 82% rename from src/tags/services/provideServices.ts rename to shlink-web-component/src/tags/services/provideServices.ts index 50d32e49..01279e88 100644 --- a/src/tags/services/provideServices.ts +++ b/shlink-web-component/src/tags/services/provideServices.ts @@ -1,7 +1,7 @@ import type { IContainer } from 'bottlejs'; import type Bottle from 'bottlejs'; import { prop } from 'ramda'; -import type { ConnectDecorator } from '../../container/types'; +import type { ConnectDecorator } from '../../container'; import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal'; import { EditTagModal } from '../helpers/EditTagModal'; import { TagsSelector } from '../helpers/TagsSelector'; @@ -15,7 +15,7 @@ import { TagsTableRow } from '../TagsTableRow'; export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator'); - bottle.decorator('TagsSelector', connect(['tagsList', 'settings'], ['listTags'])); + bottle.decorator('TagsSelector', connect(['tagsList'], ['listTags'])); bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal); bottle.decorator('DeleteTagConfirmModal', connect(['tagDelete'], ['deleteTag', 'tagDeleted'])); @@ -24,13 +24,11 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('EditTagModal', connect(['tagEdit'], ['editTag', 'tagEdited'])); bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator'); - bottle.decorator('TagsTableRow', connect(['settings'])); - bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow'); bottle.serviceFactory('TagsList', TagsList, 'TagsTable'); bottle.decorator('TagsList', connect( - ['tagsList', 'selectedServer', 'mercureInfo', 'settings'], + ['tagsList', 'mercureInfo'], ['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'], )); @@ -38,15 +36,17 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('tagEditReducerCreator', tagEditReducerCreator, 'editTag'); bottle.serviceFactory('tagEditReducer', prop('reducer'), 'tagEditReducerCreator'); - bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'apiClientFactory'); bottle.serviceFactory('tagDeleteReducer', prop('reducer'), 'tagDeleteReducerCreator'); bottle.serviceFactory('tagsListReducerCreator', tagsListReducerCreator, 'listTags', 'createShortUrl'); bottle.serviceFactory('tagsListReducer', prop('reducer'), 'tagsListReducerCreator'); // Actions - const listTagsActionFactory = (force: boolean) => - ({ buildShlinkApiClient }: IContainer) => listTags(buildShlinkApiClient, force); + const listTagsActionFactory = (force: boolean) => ({ apiClientFactory }: IContainer) => listTags( + apiClientFactory, + force, + ); bottle.factory('listTags', listTagsActionFactory(false)); bottle.factory('forceListTags', listTagsActionFactory(true)); @@ -55,6 +55,6 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('deleteTag', prop('deleteTag'), 'tagDeleteReducerCreator'); bottle.serviceFactory('tagDeleted', () => tagDeleted); - bottle.serviceFactory('editTag', editTag, 'buildShlinkApiClient', 'ColorGenerator'); + bottle.serviceFactory('editTag', editTag, 'apiClientFactory', 'ColorGenerator'); bottle.serviceFactory('tagEdited', () => tagEdited); }; diff --git a/src/utils/StickyCardPaginator.scss b/shlink-web-component/src/utils/StickyCardPaginator.scss similarity index 100% rename from src/utils/StickyCardPaginator.scss rename to shlink-web-component/src/utils/StickyCardPaginator.scss diff --git a/src/utils/CopyToClipboardIcon.scss b/shlink-web-component/src/utils/components/CopyToClipboardIcon.scss similarity index 100% rename from src/utils/CopyToClipboardIcon.scss rename to shlink-web-component/src/utils/components/CopyToClipboardIcon.scss diff --git a/src/utils/CopyToClipboardIcon.tsx b/shlink-web-component/src/utils/components/CopyToClipboardIcon.tsx similarity index 100% rename from src/utils/CopyToClipboardIcon.tsx rename to shlink-web-component/src/utils/components/CopyToClipboardIcon.tsx diff --git a/src/utils/ExportBtn.tsx b/shlink-web-component/src/utils/components/ExportBtn.tsx similarity index 93% rename from src/utils/ExportBtn.tsx rename to shlink-web-component/src/utils/components/ExportBtn.tsx index feee65d9..dd8b9d1d 100644 --- a/src/utils/ExportBtn.tsx +++ b/shlink-web-component/src/utils/components/ExportBtn.tsx @@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { FC } from 'react'; import type { ButtonProps } from 'reactstrap'; import { Button } from 'reactstrap'; -import { prettify } from './helpers/numbers'; +import { prettify } from '../helpers/numbers'; type ExportBtnProps = Omit & { amount?: number; diff --git a/src/utils/IconInput.scss b/shlink-web-component/src/utils/components/IconInput.scss similarity index 85% rename from src/utils/IconInput.scss rename to shlink-web-component/src/utils/components/IconInput.scss index 3e3e6d04..1fc0f083 100644 --- a/src/utils/IconInput.scss +++ b/shlink-web-component/src/utils/components/IconInput.scss @@ -1,5 +1,5 @@ -@import './mixins/vertical-align'; -@import './base'; +@import '../mixins/vertical-align'; +@import '@shlinkio/shlink-frontend-kit/base'; .icon-input-container { position: relative; diff --git a/src/utils/IconInput.tsx b/shlink-web-component/src/utils/components/IconInput.tsx similarity index 93% rename from src/utils/IconInput.tsx rename to shlink-web-component/src/utils/components/IconInput.tsx index 9b0e45d4..ffc4e0a3 100644 --- a/src/utils/IconInput.tsx +++ b/shlink-web-component/src/utils/components/IconInput.tsx @@ -1,10 +1,10 @@ import type { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useElementRef } from '@shlinkio/shlink-frontend-kit'; import classNames from 'classnames'; import type { FC } from 'react'; import type { InputProps } from 'reactstrap'; import { Input } from 'reactstrap'; -import { useElementRef } from './helpers/hooks'; import './IconInput.scss'; type IconInputProps = InputProps & { diff --git a/src/utils/InfoTooltip.tsx b/shlink-web-component/src/utils/components/InfoTooltip.tsx similarity index 92% rename from src/utils/InfoTooltip.tsx rename to shlink-web-component/src/utils/components/InfoTooltip.tsx index 4fb281d5..1b5a9e8a 100644 --- a/src/utils/InfoTooltip.tsx +++ b/shlink-web-component/src/utils/components/InfoTooltip.tsx @@ -1,9 +1,9 @@ import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { Placement } from '@popperjs/core'; +import { useElementRef } from '@shlinkio/shlink-frontend-kit'; import type { FC, PropsWithChildren } from 'react'; import { UncontrolledTooltip } from 'reactstrap'; -import { useElementRef } from './helpers/hooks'; export type InfoTooltipProps = PropsWithChildren<{ className?: string; diff --git a/src/utils/PaginationDropdown.tsx b/shlink-web-component/src/utils/components/PaginationDropdown.tsx similarity index 100% rename from src/utils/PaginationDropdown.tsx rename to shlink-web-component/src/utils/components/PaginationDropdown.tsx diff --git a/src/common/SimplePaginator.scss b/shlink-web-component/src/utils/components/SimplePaginator.scss similarity index 100% rename from src/common/SimplePaginator.scss rename to shlink-web-component/src/utils/components/SimplePaginator.scss diff --git a/src/common/SimplePaginator.tsx b/shlink-web-component/src/utils/components/SimplePaginator.tsx similarity index 93% rename from src/common/SimplePaginator.tsx rename to shlink-web-component/src/utils/components/SimplePaginator.tsx index 9271bdaa..1a2ae15c 100644 --- a/src/common/SimplePaginator.tsx +++ b/shlink-web-component/src/utils/components/SimplePaginator.tsx @@ -1,14 +1,13 @@ import classNames from 'classnames'; import type { FC } from 'react'; import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; -import type { - NumberOrEllipsis } from '../utils/helpers/pagination'; +import type { NumberOrEllipsis } from '../helpers/pagination'; import { keyForPage, pageIsEllipsis, prettifyPageNumber, progressivePagination, -} from '../utils/helpers/pagination'; +} from '../helpers/pagination'; import './SimplePaginator.scss'; interface SimplePaginatorProps { diff --git a/src/utils/dates/DateInput.scss b/shlink-web-component/src/utils/dates/DateInput.scss similarity index 94% rename from src/utils/dates/DateInput.scss rename to shlink-web-component/src/utils/dates/DateInput.scss index d61fd0d2..e096e75b 100644 --- a/src/utils/dates/DateInput.scss +++ b/shlink-web-component/src/utils/dates/DateInput.scss @@ -1,5 +1,5 @@ @import '../mixins/vertical-align'; -@import '../base'; +@import '@shlinkio/shlink-frontend-kit/base'; .react-datepicker__close-icon.react-datepicker__close-icon { @include vertical-align(); @@ -71,7 +71,7 @@ .react-datepicker__time-list.react-datepicker__time-list { /* Forefox scrollbar */ - scrollbar-color: rgba(0, 0, 0, 0.5) var(--secondary-color); + scrollbar-color: rgb(0 0 0 / .5) var(--secondary-color); scrollbar-width: thin; /* Chrome webkit scrollbar */ @@ -81,8 +81,8 @@ } &::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.5); - border-radius: 0.5rem; + background-color: rgb(0 0 0 / .5); + border-radius: .5rem; } } diff --git a/src/utils/dates/DateInput.tsx b/shlink-web-component/src/utils/dates/DateInput.tsx similarity index 96% rename from src/utils/dates/DateInput.tsx rename to shlink-web-component/src/utils/dates/DateInput.tsx index 9f32aaa9..60bd22cb 100644 --- a/src/utils/dates/DateInput.tsx +++ b/shlink-web-component/src/utils/dates/DateInput.tsx @@ -5,7 +5,7 @@ import { isNil } from 'ramda'; import { useRef } from 'react'; import type { ReactDatePickerProps } from 'react-datepicker'; import DatePicker from 'react-datepicker'; -import { STANDARD_DATE_FORMAT } from '../helpers/date'; +import { STANDARD_DATE_FORMAT } from './helpers/date'; import './DateInput.scss'; export type DateInputProps = ReactDatePickerProps; diff --git a/src/utils/dates/DateIntervalDropdownItems.tsx b/shlink-web-component/src/utils/dates/DateIntervalDropdownItems.tsx similarity index 83% rename from src/utils/dates/DateIntervalDropdownItems.tsx rename to shlink-web-component/src/utils/dates/DateIntervalDropdownItems.tsx index 3b8e585b..8a6a1a29 100644 --- a/src/utils/dates/DateIntervalDropdownItems.tsx +++ b/shlink-web-component/src/utils/dates/DateIntervalDropdownItems.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react'; import { DropdownItem } from 'reactstrap'; -import type { DateInterval } from '../helpers/dateIntervals'; -import { DATE_INTERVALS, rangeOrIntervalToString } from '../helpers/dateIntervals'; +import type { DateInterval } from './helpers/dateIntervals'; +import { DATE_INTERVALS, rangeOrIntervalToString } from './helpers/dateIntervals'; export interface DateIntervalDropdownProps { active?: DateInterval; diff --git a/shlink-web-component/src/utils/dates/DateIntervalSelector.tsx b/shlink-web-component/src/utils/dates/DateIntervalSelector.tsx new file mode 100644 index 00000000..fd9a6249 --- /dev/null +++ b/shlink-web-component/src/utils/dates/DateIntervalSelector.tsx @@ -0,0 +1,11 @@ +import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; +import type { FC } from 'react'; +import type { DateIntervalDropdownProps } from './DateIntervalDropdownItems'; +import { DateIntervalDropdownItems } from './DateIntervalDropdownItems'; +import { rangeOrIntervalToString } from './helpers/dateIntervals'; + +export const DateIntervalSelector: FC = ({ onChange, active, allText }) => ( + + + +); diff --git a/src/utils/dates/DateRangeRow.tsx b/shlink-web-component/src/utils/dates/DateRangeRow.tsx similarity index 94% rename from src/utils/dates/DateRangeRow.tsx rename to shlink-web-component/src/utils/dates/DateRangeRow.tsx index f5fe1d7f..ba47389f 100644 --- a/src/utils/dates/DateRangeRow.tsx +++ b/shlink-web-component/src/utils/dates/DateRangeRow.tsx @@ -1,6 +1,6 @@ import { endOfDay } from 'date-fns'; -import type { DateRange } from '../helpers/dateIntervals'; import { DateInput } from './DateInput'; +import type { DateRange } from './helpers/dateIntervals'; interface DateRangeRowProps extends DateRange { onStartDateChange: (date: Date | null) => void; diff --git a/src/utils/dates/DateRangeSelector.tsx b/shlink-web-component/src/utils/dates/DateRangeSelector.tsx similarity index 94% rename from src/utils/dates/DateRangeSelector.tsx rename to shlink-web-component/src/utils/dates/DateRangeSelector.tsx index dd254a6a..d79d656a 100644 --- a/src/utils/dates/DateRangeSelector.tsx +++ b/shlink-web-component/src/utils/dates/DateRangeSelector.tsx @@ -1,19 +1,19 @@ +import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; import { useState } from 'react'; import { DropdownItem } from 'reactstrap'; -import { DropdownBtn } from '../DropdownBtn'; +import { useEffectExceptFirstTime } from '../helpers/hooks'; +import { DateIntervalDropdownItems } from './DateIntervalDropdownItems'; +import { DateRangeRow } from './DateRangeRow'; import type { DateInterval, - DateRange } from '../helpers/dateIntervals'; + DateRange } from './helpers/dateIntervals'; import { ALL, dateRangeIsEmpty, intervalToDateRange, rangeIsInterval, rangeOrIntervalToString, -} from '../helpers/dateIntervals'; -import { useEffectExceptFirstTime } from '../helpers/hooks'; -import { DateIntervalDropdownItems } from './DateIntervalDropdownItems'; -import { DateRangeRow } from './DateRangeRow'; +} from './helpers/dateIntervals'; export interface DateRangeSelectorProps { initialDateRange?: DateInterval | DateRange; diff --git a/src/utils/dates/DateTimeInput.tsx b/shlink-web-component/src/utils/dates/DateTimeInput.tsx similarity index 87% rename from src/utils/dates/DateTimeInput.tsx rename to shlink-web-component/src/utils/dates/DateTimeInput.tsx index 236f1f0a..402423c3 100644 --- a/src/utils/dates/DateTimeInput.tsx +++ b/shlink-web-component/src/utils/dates/DateTimeInput.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react'; import type { ReactDatePickerProps } from 'react-datepicker'; -import { STANDARD_DATE_AND_TIME_FORMAT } from '../helpers/date'; import { DateInput } from './DateInput'; +import { STANDARD_DATE_AND_TIME_FORMAT } from './helpers/date'; export type DateTimeInputProps = Omit; diff --git a/src/utils/dates/Time.tsx b/shlink-web-component/src/utils/dates/Time.tsx similarity index 96% rename from src/utils/dates/Time.tsx rename to shlink-web-component/src/utils/dates/Time.tsx index 8245d031..22cb432c 100644 --- a/src/utils/dates/Time.tsx +++ b/shlink-web-component/src/utils/dates/Time.tsx @@ -1,5 +1,5 @@ import { format as formatDate, formatDistance, getUnixTime, parseISO } from 'date-fns'; -import { isDateObject, now, STANDARD_DATE_AND_TIME_FORMAT } from '../helpers/date'; +import { isDateObject, now, STANDARD_DATE_AND_TIME_FORMAT } from './helpers/date'; export interface TimeProps { date: Date | string; diff --git a/src/utils/helpers/date.ts b/shlink-web-component/src/utils/dates/helpers/date.ts similarity index 75% rename from src/utils/helpers/date.ts rename to shlink-web-component/src/utils/dates/helpers/date.ts index ebb33605..6f6793cb 100644 --- a/src/utils/helpers/date.ts +++ b/shlink-web-component/src/utils/dates/helpers/date.ts @@ -1,5 +1,4 @@ import { format, formatISO, isBefore, isEqual, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns'; -import type { OptionalString } from '../utils'; export const STANDARD_DATE_FORMAT = 'yyyy-MM-dd'; @@ -13,7 +12,7 @@ export const now = () => new Date(); export const isDateObject = (date: DateOrString): date is Date => typeof date !== 'string'; -const formatDateFromFormat = (date?: NullableDate, theFormat?: string): OptionalString => { +const formatDateFromFormat = (date?: NullableDate, theFormat?: string): string | null | undefined => { if (!date || !isDateObject(date)) { return date; } @@ -21,23 +20,16 @@ const formatDateFromFormat = (date?: NullableDate, theFormat?: string): Optional return theFormat ? format(date, theFormat) : formatISO(date); }; -export const formatDate = (theFormat = STANDARD_DATE_FORMAT) => (date?: NullableDate) => formatDateFromFormat( - date, - theFormat, -); - export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined); -export const formatInternational = formatDate(); +export const formatInternational = (date?: NullableDate) => formatDateFromFormat(date, STANDARD_DATE_FORMAT); -export const formatHumanFriendly = formatDate(STANDARD_DATE_AND_TIME_FORMAT); +export const formatHumanFriendly = (date?: NullableDate) => formatDateFromFormat(date, STANDARD_DATE_AND_TIME_FORMAT); export const parseDate = (date: string, theFormat: string) => parse(date, theFormat, now()); export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date)); -export const dateOrNull = (date?: string): Date | null => (date ? parseISO(date) : null); - export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => { try { return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) }); diff --git a/src/utils/helpers/dateIntervals.ts b/shlink-web-component/src/utils/dates/helpers/dateIntervals.ts similarity index 92% rename from src/utils/helpers/dateIntervals.ts rename to shlink-web-component/src/utils/dates/helpers/dateIntervals.ts index be5d7dd5..58512f17 100644 --- a/src/utils/helpers/dateIntervals.ts +++ b/shlink-web-component/src/utils/dates/helpers/dateIntervals.ts @@ -1,8 +1,7 @@ import { endOfDay, startOfDay, subDays } from 'date-fns'; import { cond, filter, isEmpty, T } from 'ramda'; -import { equals } from '../utils'; import type { DateOrString } from './date'; -import { dateOrNull, formatInternational, isBeforeOrEqual, now, parseISO } from './date'; +import { formatInternational, isBeforeOrEqual, now, parseISO } from './date'; export interface DateRange { startDate?: Date | null; @@ -31,7 +30,9 @@ export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange == export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval => typeof range === 'string' && INTERVALS.includes(range); -export const DATE_INTERVALS = INTERVALS.filter((value) => value !== ALL) as DateInterval[]; +export const DATE_INTERVALS = INTERVALS.filter((value) => value !== ALL) as Exclude[]; + +const dateOrNull = (date?: string): Date | null => (date ? parseISO(date) : null); export const datesToDateRange = (startDate?: string, endDate?: string): DateRange => ({ startDate: dateOrNull(startDate), @@ -68,6 +69,7 @@ export const rangeOrIntervalToString = (range?: DateRange | DateInterval): strin const startOfDaysAgo = (daysAgo: number) => startOfDay(subDays(now(), daysAgo)); const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(now()) }); +const equals = (value: any) => (otherValue: any) => value === otherValue; export const intervalToDateRange = cond<[DateInterval | undefined], DateRange>([ [equals('today'), () => endingToday(startOfDay(now()))], diff --git a/shlink-web-component/src/utils/features.ts b/shlink-web-component/src/utils/features.ts new file mode 100644 index 00000000..baf83d10 --- /dev/null +++ b/shlink-web-component/src/utils/features.ts @@ -0,0 +1,38 @@ +import { createContext, useContext, useMemo } from 'react'; +import type { SemVer } from './helpers/version'; +import { versionMatch } from './helpers/version'; + +const supportedFeatures = { + domainVisits: '3.1.0', + excludeBotsOnShortUrls: '3.4.0', + filterDisabledUrls: '3.4.0', + deviceLongUrls: '3.5.0', +} as const satisfies Record; + +Object.freeze(supportedFeatures); + +export type Feature = keyof typeof supportedFeatures; + +export const isFeatureEnabledForVersion = (feature: Feature, serverVersion: SemVer): boolean => + versionMatch(serverVersion, { minVersion: supportedFeatures[feature] }); + +const getFeaturesForVersion = (serverVersion: SemVer): Record => ({ + domainVisits: isFeatureEnabledForVersion('domainVisits', serverVersion), + excludeBotsOnShortUrls: isFeatureEnabledForVersion('excludeBotsOnShortUrls', serverVersion), + filterDisabledUrls: isFeatureEnabledForVersion('filterDisabledUrls', serverVersion), + deviceLongUrls: isFeatureEnabledForVersion('deviceLongUrls', serverVersion), +}); + +export const useFeatures = (serverVersion: SemVer) => useMemo( + () => getFeaturesForVersion(serverVersion), + [serverVersion], +); + +const FeaturesContext = createContext(getFeaturesForVersion('0.0.0')); + +export const FeaturesProvider = FeaturesContext.Provider; + +export const useFeature = (feature: Feature) => { + const features = useContext(FeaturesContext); + return features[feature]; +}; diff --git a/src/utils/helpers/charts.ts b/shlink-web-component/src/utils/helpers/charts.ts similarity index 100% rename from src/utils/helpers/charts.ts rename to shlink-web-component/src/utils/helpers/charts.ts diff --git a/shlink-web-component/src/utils/helpers/files.ts b/shlink-web-component/src/utils/helpers/files.ts new file mode 100644 index 00000000..dde8bfea --- /dev/null +++ b/shlink-web-component/src/utils/helpers/files.ts @@ -0,0 +1,17 @@ +export const saveUrl = ({ document }: Window, url: string, filename: string) => { + const link = document.createElement('a'); + + link.setAttribute('href', url); + link.setAttribute('download', filename); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; + +export const saveCsv = (window: Window, csv: string, filename: string) => { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + + saveUrl(window, url, filename); +}; diff --git a/shlink-web-component/src/utils/helpers/hooks.ts b/shlink-web-component/src/utils/helpers/hooks.ts new file mode 100644 index 00000000..9427833a --- /dev/null +++ b/shlink-web-component/src/utils/helpers/hooks.ts @@ -0,0 +1,76 @@ +import { parseQuery, stringifyQuery } from '@shlinkio/shlink-frontend-kit'; +import type { DependencyList, EffectCallback } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSwipeable as useReactSwipeable } from 'react-swipeable'; + +const DEFAULT_DELAY = 2000; + +export type TimeoutToggle = (initialValue?: boolean, delay?: number) => [boolean, () => void]; + +export const useTimeoutToggle = ( + setTimeout: (callback: Function, timeout: number) => number, + clearTimeout: (timer: number) => void, +): TimeoutToggle => (initialValue = false, delay = DEFAULT_DELAY) => { + const [flag, setFlag] = useState(initialValue); + const timeout = useRef(undefined); + const callback = () => { + setFlag(!initialValue); + + if (timeout.current) { + clearTimeout(timeout.current); + } + + timeout.current = setTimeout(() => setFlag(initialValue), delay); + }; + + return [flag, callback]; +}; + +export const useSwipeable = (showSidebar: () => void, hideSidebar: () => void) => { + const swipeMenuIfNoModalExists = (callback: () => void) => (e: any) => { + const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some( + ({ classList }) => classList?.contains('visits-table'), + ); + + if (swippedOnVisitsTable || document.querySelector('.modal')) { + return; + } + + callback(); + }; + + return useReactSwipeable({ + delta: 40, + onSwipedLeft: swipeMenuIfNoModalExists(hideSidebar), + onSwipedRight: swipeMenuIfNoModalExists(showSidebar), + }); +}; + +export const useQueryState = (paramName: string, initialState: T): [ T, (newValue: T) => void ] => { + const [value, setValue] = useState(initialState); + const setValueWithLocation = (valueToSet: T) => { + const { location, history } = window; + const query = parseQuery(location.search); + + query[paramName] = valueToSet; + history.pushState(null, '', `${location.pathname}?${stringifyQuery(query)}`); + setValue(valueToSet); + }; + + return [value, setValueWithLocation]; +}; + +export const useEffectExceptFirstTime = (callback: EffectCallback, deps: DependencyList): void => { + const isFirstLoad = useRef(true); + + useEffect(() => { + !isFirstLoad.current && callback(); + isFirstLoad.current = false; + }, deps); +}; + +export const useGoBack = () => { + const navigate = useNavigate(); + return () => navigate(-1); +}; diff --git a/shlink-web-component/src/utils/helpers/index.ts b/shlink-web-component/src/utils/helpers/index.ts new file mode 100644 index 00000000..0e1f829b --- /dev/null +++ b/shlink-web-component/src/utils/helpers/index.ts @@ -0,0 +1,32 @@ +import { isEmpty, isNil, pipe, range } from 'ramda'; +import type { SyntheticEvent } from 'react'; + +type Optional = T | null | undefined; + +export type OptionalString = Optional; + +export const handleEventPreventingDefault = (handler: () => T) => pipe( + (e: SyntheticEvent) => e.preventDefault(), + handler, +); + +export const rangeOf = (size: number, mappingFn: (value: number) => T, startAt = 1): T[] => + range(startAt, size + 1).map(mappingFn); + +export type Empty = null | undefined | '' | never[]; + +export const hasValue = (value: T | Empty): value is T => !isNil(value) && !isEmpty(value); + +export type Nullable = { + [P in keyof T]: T[P] | null +}; + +export const nonEmptyValueOrNull = (value: T): T | null => (isEmpty(value) ? null : value); + +export type BooleanString = 'true' | 'false'; + +export const parseBooleanToString = (value: boolean): BooleanString => (value ? 'true' : 'false'); + +export const parseOptionalBooleanToString = (value?: boolean): BooleanString | undefined => ( + value === undefined ? undefined : parseBooleanToString(value) +); diff --git a/shlink-web-component/src/utils/helpers/json.ts b/shlink-web-component/src/utils/helpers/json.ts new file mode 100644 index 00000000..c4ceb988 --- /dev/null +++ b/shlink-web-component/src/utils/helpers/json.ts @@ -0,0 +1,7 @@ +import { Parser } from '@json2csv/plainjs'; + +const jsonParser = new Parser(); // This accepts options if needed + +export const jsonToCsv = (data: T[]): string => jsonParser.parse(data); + +export type JsonToCsv = typeof jsonToCsv; diff --git a/src/utils/helpers/numbers.ts b/shlink-web-component/src/utils/helpers/numbers.ts similarity index 100% rename from src/utils/helpers/numbers.ts rename to shlink-web-component/src/utils/helpers/numbers.ts diff --git a/src/utils/helpers/pagination.ts b/shlink-web-component/src/utils/helpers/pagination.ts similarity index 100% rename from src/utils/helpers/pagination.ts rename to shlink-web-component/src/utils/helpers/pagination.ts diff --git a/src/utils/helpers/qrCodes.ts b/shlink-web-component/src/utils/helpers/qrCodes.ts similarity index 89% rename from src/utils/helpers/qrCodes.ts rename to shlink-web-component/src/utils/helpers/qrCodes.ts index 2e66d676..0fcb16b7 100644 --- a/src/utils/helpers/qrCodes.ts +++ b/shlink-web-component/src/utils/helpers/qrCodes.ts @@ -1,5 +1,5 @@ +import { stringifyQuery } from '@shlinkio/shlink-frontend-kit'; import { isEmpty } from 'ramda'; -import { stringifyQuery } from './query'; export type QrCodeFormat = 'svg' | 'png'; diff --git a/shlink-web-component/src/utils/helpers/version.ts b/shlink-web-component/src/utils/helpers/version.ts new file mode 100644 index 00000000..3505cbc5 --- /dev/null +++ b/shlink-web-component/src/utils/helpers/version.ts @@ -0,0 +1,21 @@ +import { compare } from 'compare-versions'; + +type SemVerPatternFragment = `${bigint | '*'}`; + +type SemVerPattern = SemVerPatternFragment +| `${SemVerPatternFragment}.${SemVerPatternFragment}` +| `${SemVerPatternFragment}.${SemVerPatternFragment}.${SemVerPatternFragment}`; + +type Versions = { + maxVersion?: SemVerPattern; + minVersion?: SemVerPattern; +}; + +export type SemVer = `${bigint}.${bigint}.${bigint}` | 'latest'; + +export const versionMatch = (versionToMatch: SemVer, { maxVersion, minVersion }: Versions): boolean => { + const matchesMinVersion = !minVersion || compare(versionToMatch, minVersion, '>='); + const matchesMaxVersion = !maxVersion || compare(versionToMatch, maxVersion, '<='); + + return matchesMaxVersion && matchesMinVersion; +}; diff --git a/src/utils/mixins/fit-with-margin.scss b/shlink-web-component/src/utils/mixins/fit-with-margin.scss similarity index 100% rename from src/utils/mixins/fit-with-margin.scss rename to shlink-web-component/src/utils/mixins/fit-with-margin.scss diff --git a/src/utils/mixins/sticky-cell.scss b/shlink-web-component/src/utils/mixins/sticky-cell.scss similarity index 69% rename from src/utils/mixins/sticky-cell.scss rename to shlink-web-component/src/utils/mixins/sticky-cell.scss index 959d350b..b006b83c 100644 --- a/src/utils/mixins/sticky-cell.scss +++ b/shlink-web-component/src/utils/mixins/sticky-cell.scss @@ -1,4 +1,4 @@ -@import '../base'; +@import '@shlinkio/shlink-frontend-kit/base'; @mixin sticky-cell($with-separators: true) { z-index: 1; @@ -7,10 +7,7 @@ &:before { content: ''; position: absolute; - top: -1px; - left: 0; - bottom: -1px; - right: if($with-separators, -1px, 0); + inset: -1px if($with-separators, -1px, 0) -1px 0; background: var(--table-border-color); z-index: -2; } @@ -22,10 +19,7 @@ &:after { content: ''; position: absolute; - top: 0; - left: if($with-separators, 1px, 0); - bottom: 0; - right: 0; + inset: 0 0 0 if($with-separators, 1px, 0); background: var(--primary-color); z-index: -1; } diff --git a/shlink-web-component/src/utils/mixins/vertical-align.scss b/shlink-web-component/src/utils/mixins/vertical-align.scss new file mode 100644 index 00000000..95b5985c --- /dev/null +++ b/shlink-web-component/src/utils/mixins/vertical-align.scss @@ -0,0 +1,5 @@ +@mixin vertical-align($extraTransforms: null) { + position: absolute; + top: 50%; + transform: translateY(-50%) $extraTransforms; +} diff --git a/shlink-web-component/src/utils/redux.ts b/shlink-web-component/src/utils/redux.ts new file mode 100644 index 00000000..2c7f252a --- /dev/null +++ b/shlink-web-component/src/utils/redux.ts @@ -0,0 +1,13 @@ +import type { AsyncThunkPayloadCreator } from '@reduxjs/toolkit'; +import { createAsyncThunk as baseCreateAsyncThunk } from '@reduxjs/toolkit'; +import { identity } from 'ramda'; +import type { RootState } from '../container/store'; + +export const createAsyncThunk = ( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator, +) => baseCreateAsyncThunk( + typePrefix, + payloadCreator, + { serializeError: identity }, + ); diff --git a/shlink-web-component/src/utils/routesPrefix.ts b/shlink-web-component/src/utils/routesPrefix.ts new file mode 100644 index 00000000..90e943c7 --- /dev/null +++ b/shlink-web-component/src/utils/routesPrefix.ts @@ -0,0 +1,7 @@ +import { createContext, useContext } from 'react'; + +const RoutesPrefixContext = createContext(''); + +export const RoutesPrefixProvider = RoutesPrefixContext.Provider; + +export const useRoutesPrefix = (): string => useContext(RoutesPrefixContext); diff --git a/src/utils/services/ColorGenerator.ts b/shlink-web-component/src/utils/services/ColorGenerator.ts similarity index 86% rename from src/utils/services/ColorGenerator.ts rename to shlink-web-component/src/utils/services/ColorGenerator.ts index 789fe122..be13f53d 100644 --- a/src/utils/services/ColorGenerator.ts +++ b/shlink-web-component/src/utils/services/ColorGenerator.ts @@ -1,6 +1,6 @@ import { isNil } from 'ramda'; -import { rangeOf } from '../utils'; -import type { LocalStorage } from './LocalStorage'; +import { rangeOf } from '../helpers'; +import type { TagColorsStorage } from './TagColorsStorage'; const HEX_COLOR_LENGTH = 6; const HEX_DIGITS = '0123456789ABCDEF'; @@ -19,8 +19,8 @@ export class ColorGenerator { private readonly colors: Record; private readonly lights: Record; - public constructor(private readonly storage: LocalStorage) { - this.colors = this.storage.get>('colors') ?? {}; + public constructor(private readonly storage?: TagColorsStorage) { + this.colors = this.storage?.getTagColors() ?? {}; this.lights = {}; } @@ -40,7 +40,7 @@ export class ColorGenerator { const normalizedKey = normalizeKey(key); this.colors[normalizedKey] = color; - this.storage.set('colors', this.colors); + this.storage?.storeTagColors(this.colors); return color; }; diff --git a/shlink-web-component/src/utils/services/ImageDownloader.ts b/shlink-web-component/src/utils/services/ImageDownloader.ts new file mode 100644 index 00000000..a64d9837 --- /dev/null +++ b/shlink-web-component/src/utils/services/ImageDownloader.ts @@ -0,0 +1,13 @@ +import { saveUrl } from '../helpers/files'; +import type { Fetch } from '../types'; + +export class ImageDownloader { + public constructor(private readonly fetch: Fetch, private readonly window: Window) {} + + public async saveImage(imgUrl: string, filename: string): Promise { + const data = await this.fetch(imgUrl).then((resp) => resp.blob()); + const url = URL.createObjectURL(data); + + saveUrl(this.window, url, filename); + } +} diff --git a/shlink-web-component/src/utils/services/LocalStorage.ts b/shlink-web-component/src/utils/services/LocalStorage.ts new file mode 100644 index 00000000..9c404e07 --- /dev/null +++ b/shlink-web-component/src/utils/services/LocalStorage.ts @@ -0,0 +1,14 @@ +const PREFIX = 'shlink'; +const buildPath = (path: string) => `${PREFIX}.${path}`; + +export class LocalStorage { + public constructor(private readonly localStorage: Storage) {} + + public readonly get = (key: string): T | undefined => { + const item = this.localStorage.getItem(buildPath(key)); + + return item ? JSON.parse(item) as T : undefined; + }; + + public readonly set = (key: string, value: any) => this.localStorage.setItem(buildPath(key), JSON.stringify(value)); +} diff --git a/src/common/services/ReportExporter.ts b/shlink-web-component/src/utils/services/ReportExporter.ts similarity index 83% rename from src/common/services/ReportExporter.ts rename to shlink-web-component/src/utils/services/ReportExporter.ts index d23b3bfe..f6d96fb2 100644 --- a/src/common/services/ReportExporter.ts +++ b/shlink-web-component/src/utils/services/ReportExporter.ts @@ -1,10 +1,11 @@ import type { ExportableShortUrl } from '../../short-urls/data'; -import type { JsonToCsv } from '../../utils/helpers/csvjson'; -import { saveCsv } from '../../utils/helpers/files'; import type { NormalizedVisit } from '../../visits/types'; +import { saveCsv } from '../helpers/files'; +import type { JsonToCsv } from '../helpers/json'; export class ReportExporter { - public constructor(private readonly window: Window, private readonly jsonToCsv: JsonToCsv) {} + public constructor(private readonly window: Window, private readonly jsonToCsv: JsonToCsv) { + } public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => { if (!visits.length) { diff --git a/shlink-web-component/src/utils/services/TagColorsStorage.ts b/shlink-web-component/src/utils/services/TagColorsStorage.ts new file mode 100644 index 00000000..114093b1 --- /dev/null +++ b/shlink-web-component/src/utils/services/TagColorsStorage.ts @@ -0,0 +1,5 @@ +export type TagColorsStorage = { + getTagColors(): Record; + + storeTagColors(colors: Record): void; +}; diff --git a/shlink-web-component/src/utils/services/provideServices.ts b/shlink-web-component/src/utils/services/provideServices.ts new file mode 100644 index 00000000..cdf6f7db --- /dev/null +++ b/shlink-web-component/src/utils/services/provideServices.ts @@ -0,0 +1,21 @@ +import type Bottle from 'bottlejs'; +import { useTimeoutToggle } from '../helpers/hooks'; +import { jsonToCsv } from '../helpers/json'; +import { ColorGenerator } from './ColorGenerator'; +import { ImageDownloader } from './ImageDownloader'; +import { ReportExporter } from './ReportExporter'; + +export function provideServices(bottle: Bottle) { + bottle.constant('window', window); + bottle.constant('fetch', window.fetch.bind(window)); + bottle.service('ImageDownloader', ImageDownloader, 'fetch', 'window'); + + bottle.service('ColorGenerator', ColorGenerator, 'TagColorsStorage'); + + bottle.constant('jsonToCsv', jsonToCsv); + bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv'); + + bottle.constant('setTimeout', window.setTimeout); + bottle.constant('clearTimeout', window.clearTimeout); + bottle.serviceFactory('useTimeoutToggle', useTimeoutToggle, 'setTimeout', 'clearTimeout'); +} diff --git a/shlink-web-component/src/utils/settings.ts b/shlink-web-component/src/utils/settings.ts new file mode 100644 index 00000000..34d21806 --- /dev/null +++ b/shlink-web-component/src/utils/settings.ts @@ -0,0 +1,83 @@ +import type { Theme } from '@shlinkio/shlink-frontend-kit'; +import { createContext, useContext } from 'react'; +import type { ShortUrlsOrder } from '../short-urls/data'; +import type { TagsOrder } from '../tags/data/TagsListChildrenProps'; +import type { DateInterval } from './dates/helpers/dateIntervals'; + +export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = { + field: 'dateCreated', + dir: 'DESC', +}; + +/** + * Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as + * optional, as old instances of the app will load partial objects from local storage until it is saved again. + */ + +export interface RealTimeUpdatesSettings { + enabled: boolean; + interval?: number; +} + +export type TagFilteringMode = 'startsWith' | 'includes'; + +export interface ShortUrlCreationSettings { + validateUrls: boolean; + tagFilteringMode?: TagFilteringMode; + forwardQuery?: boolean; +} + +export interface UiSettings { + theme: Theme; +} + +export interface VisitsSettings { + defaultInterval: DateInterval; + excludeBots?: boolean; +} + +export interface TagsSettings { + defaultOrdering?: TagsOrder; +} + +export interface ShortUrlsListSettings { + defaultOrdering?: ShortUrlsOrder; +} + +export interface Settings { + realTimeUpdates?: RealTimeUpdatesSettings; + shortUrlCreation?: ShortUrlCreationSettings; + shortUrlsList?: ShortUrlsListSettings; + ui?: UiSettings; + visits?: VisitsSettings; + tags?: TagsSettings; +} + +const defaultSettings: Settings = { + realTimeUpdates: { + enabled: true, + }, + shortUrlCreation: { + validateUrls: false, + }, + ui: { + theme: 'light', + }, + visits: { + defaultInterval: 'last30Days', + }, + shortUrlsList: { + defaultOrdering: DEFAULT_SHORT_URLS_ORDERING, + }, +}; + +const SettingsContext = createContext(defaultSettings); + +export const SettingsProvider = SettingsContext.Provider; + +export const useSettings = (): Settings => useContext(SettingsContext) ?? defaultSettings; + +export const useSetting = (settingName: T): Settings[T] => { + const settings = useSettings(); + return settings[settingName]; +}; diff --git a/src/utils/table/TableOrderIcon.tsx b/shlink-web-component/src/utils/table/TableOrderIcon.tsx similarity index 90% rename from src/utils/table/TableOrderIcon.tsx rename to shlink-web-component/src/utils/table/TableOrderIcon.tsx index 4c8d8fae..d6044d81 100644 --- a/src/utils/table/TableOrderIcon.tsx +++ b/shlink-web-component/src/utils/table/TableOrderIcon.tsx @@ -1,6 +1,6 @@ import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import type { Order } from '../helpers/ordering'; +import type { Order } from '@shlinkio/shlink-frontend-kit'; interface TableOrderIconProps { currentOrder: Order; diff --git a/shlink-web-component/src/utils/types/index.ts b/shlink-web-component/src/utils/types/index.ts new file mode 100644 index 00000000..953c1981 --- /dev/null +++ b/shlink-web-component/src/utils/types/index.ts @@ -0,0 +1,3 @@ +export type MediaMatcher = (query: string) => MediaQueryList; + +export type Fetch = typeof window.fetch; diff --git a/src/visits/DomainVisits.tsx b/shlink-web-component/src/visits/DomainVisits.tsx similarity index 83% rename from src/visits/DomainVisits.tsx rename to shlink-web-component/src/visits/DomainVisits.tsx index 474da438..17c1c35d 100644 --- a/src/visits/DomainVisits.tsx +++ b/shlink-web-component/src/visits/DomainVisits.tsx @@ -1,17 +1,16 @@ import { useParams } from 'react-router-dom'; -import type { ShlinkVisitsParams } from '../api/types'; -import type { ReportExporter } from '../common/services/ReportExporter'; +import type { ShlinkVisitsParams } from '../api-contract'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; +import type { ReportExporter } from '../utils/services/ReportExporter'; import type { DomainVisits as DomainVisitsState, LoadDomainVisits } from './reducers/domainVisits'; import type { NormalizedVisit } from './types'; -import type { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsHeader } from './VisitsHeader'; import { VisitsStats } from './VisitsStats'; -export interface DomainVisitsProps extends CommonVisitsProps { +export interface DomainVisitsProps { getDomainVisits: (params: LoadDomainVisits) => void; domainVisits: DomainVisitsState; cancelGetDomainVisits: () => void; @@ -21,7 +20,6 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure getDomainVisits, domainVisits, cancelGetDomainVisits, - settings, }: DomainVisitsProps) => { const goBack = useGoBack(); const { domain = '' } = useParams(); @@ -35,7 +33,6 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure getVisits={loadVisits} cancelGetVisits={cancelGetDomainVisits} visitsInfo={domainVisits} - settings={settings} exportCsv={exportCsv} > diff --git a/src/visits/NonOrphanVisits.tsx b/shlink-web-component/src/visits/NonOrphanVisits.tsx similarity index 84% rename from src/visits/NonOrphanVisits.tsx rename to shlink-web-component/src/visits/NonOrphanVisits.tsx index 79fd5fca..20c38134 100644 --- a/src/visits/NonOrphanVisits.tsx +++ b/shlink-web-component/src/visits/NonOrphanVisits.tsx @@ -1,15 +1,14 @@ -import type { ReportExporter } from '../common/services/ReportExporter'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; +import type { ReportExporter } from '../utils/services/ReportExporter'; import type { LoadVisits, VisitsInfo } from './reducers/types'; import type { NormalizedVisit, VisitsParams } from './types'; -import type { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsHeader } from './VisitsHeader'; import { VisitsStats } from './VisitsStats'; -export interface NonOrphanVisitsProps extends CommonVisitsProps { +export interface NonOrphanVisitsProps { getNonOrphanVisits: (params: LoadVisits) => void; nonOrphanVisits: VisitsInfo; cancelGetNonOrphanVisits: () => void; @@ -19,7 +18,6 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc getNonOrphanVisits, nonOrphanVisits, cancelGetNonOrphanVisits, - settings, }: NonOrphanVisitsProps) => { const goBack = useGoBack(); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits); @@ -31,7 +29,6 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc getVisits={loadVisits} cancelGetVisits={cancelGetNonOrphanVisits} visitsInfo={nonOrphanVisits} - settings={settings} exportCsv={exportCsv} > diff --git a/src/visits/OrphanVisits.tsx b/shlink-web-component/src/visits/OrphanVisits.tsx similarity index 85% rename from src/visits/OrphanVisits.tsx rename to shlink-web-component/src/visits/OrphanVisits.tsx index 379b0ef8..4d3f2111 100644 --- a/src/visits/OrphanVisits.tsx +++ b/shlink-web-component/src/visits/OrphanVisits.tsx @@ -1,16 +1,15 @@ -import type { ReportExporter } from '../common/services/ReportExporter'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; +import type { ReportExporter } from '../utils/services/ReportExporter'; import type { LoadOrphanVisits } from './reducers/orphanVisits'; import type { VisitsInfo } from './reducers/types'; import type { NormalizedVisit, VisitsParams } from './types'; -import type { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsHeader } from './VisitsHeader'; import { VisitsStats } from './VisitsStats'; -export interface OrphanVisitsProps extends CommonVisitsProps { +export interface OrphanVisitsProps { getOrphanVisits: (params: LoadOrphanVisits) => void; orphanVisits: VisitsInfo; cancelGetOrphanVisits: () => void; @@ -20,7 +19,6 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure getOrphanVisits, orphanVisits, cancelGetOrphanVisits, - settings, }: OrphanVisitsProps) => { const goBack = useGoBack(); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); @@ -33,7 +31,6 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure getVisits={loadVisits} cancelGetVisits={cancelGetOrphanVisits} visitsInfo={orphanVisits} - settings={settings} exportCsv={exportCsv} isOrphanVisits > diff --git a/src/visits/ShortUrlVisits.tsx b/shlink-web-component/src/visits/ShortUrlVisits.tsx similarity index 88% rename from src/visits/ShortUrlVisits.tsx rename to shlink-web-component/src/visits/ShortUrlVisits.tsx index d3a28e55..f19923a5 100644 --- a/src/visits/ShortUrlVisits.tsx +++ b/shlink-web-component/src/visits/ShortUrlVisits.tsx @@ -1,21 +1,20 @@ +import { parseQuery } from '@shlinkio/shlink-frontend-kit'; import { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; -import type { ReportExporter } from '../common/services/ReportExporter'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; import type { ShortUrlIdentifier } from '../short-urls/data'; import { urlDecodeShortCode } from '../short-urls/helpers'; import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; import { useGoBack } from '../utils/helpers/hooks'; -import { parseQuery } from '../utils/helpers/query'; +import type { ReportExporter } from '../utils/services/ReportExporter'; import type { LoadShortUrlVisits, ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader'; import type { NormalizedVisit, VisitsParams } from './types'; -import type { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsStats } from './VisitsStats'; -export interface ShortUrlVisitsProps extends CommonVisitsProps { +export interface ShortUrlVisitsProps { getShortUrlVisits: (params: LoadShortUrlVisits) => void; shortUrlVisits: ShortUrlVisitsState; getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void; @@ -29,7 +28,6 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu getShortUrlVisits, getShortUrlDetail, cancelGetShortUrlVisits, - settings, }: ShortUrlVisitsProps) => { const { shortCode = '' } = useParams<{ shortCode: string }>(); const { search } = useLocation(); @@ -54,7 +52,6 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu getVisits={loadVisits} cancelGetVisits={cancelGetShortUrlVisits} visitsInfo={shortUrlVisits} - settings={settings} exportCsv={exportCsv} > diff --git a/src/visits/ShortUrlVisitsHeader.scss b/shlink-web-component/src/visits/ShortUrlVisitsHeader.scss similarity index 100% rename from src/visits/ShortUrlVisitsHeader.scss rename to shlink-web-component/src/visits/ShortUrlVisitsHeader.scss diff --git a/src/visits/ShortUrlVisitsHeader.tsx b/shlink-web-component/src/visits/ShortUrlVisitsHeader.tsx similarity index 100% rename from src/visits/ShortUrlVisitsHeader.tsx rename to shlink-web-component/src/visits/ShortUrlVisitsHeader.tsx diff --git a/src/visits/TagVisits.tsx b/shlink-web-component/src/visits/TagVisits.tsx similarity index 83% rename from src/visits/TagVisits.tsx rename to shlink-web-component/src/visits/TagVisits.tsx index dcbcdbb1..ca77c13b 100644 --- a/src/visits/TagVisits.tsx +++ b/shlink-web-component/src/visits/TagVisits.tsx @@ -1,18 +1,17 @@ import { useParams } from 'react-router-dom'; -import type { ShlinkVisitsParams } from '../api/types'; -import type { ReportExporter } from '../common/services/ReportExporter'; +import type { ShlinkVisitsParams } from '../api-contract'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; import type { ColorGenerator } from '../utils/services/ColorGenerator'; +import type { ReportExporter } from '../utils/services/ReportExporter'; import type { LoadTagVisits, TagVisits as TagVisitsState } from './reducers/tagVisits'; import { TagVisitsHeader } from './TagVisitsHeader'; import type { NormalizedVisit } from './types'; -import type { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsStats } from './VisitsStats'; -export interface TagVisitsProps extends CommonVisitsProps { +export interface TagVisitsProps { getTagVisits: (params: LoadTagVisits) => void; tagVisits: TagVisitsState; cancelGetTagVisits: () => void; @@ -22,7 +21,6 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo getTagVisits, tagVisits, cancelGetTagVisits, - settings, }: TagVisitsProps) => { const goBack = useGoBack(); const { tag = '' } = useParams(); @@ -35,7 +33,6 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo getVisits={loadVisits} cancelGetVisits={cancelGetTagVisits} visitsInfo={tagVisits} - settings={settings} exportCsv={exportCsv} > diff --git a/src/visits/TagVisitsHeader.tsx b/shlink-web-component/src/visits/TagVisitsHeader.tsx similarity index 100% rename from src/visits/TagVisitsHeader.tsx rename to shlink-web-component/src/visits/TagVisitsHeader.tsx diff --git a/src/visits/VisitsHeader.tsx b/shlink-web-component/src/visits/VisitsHeader.tsx similarity index 93% rename from src/visits/VisitsHeader.tsx rename to shlink-web-component/src/visits/VisitsHeader.tsx index 56584e83..f9471bcd 100644 --- a/src/visits/VisitsHeader.tsx +++ b/shlink-web-component/src/visits/VisitsHeader.tsx @@ -2,7 +2,7 @@ import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { FC, PropsWithChildren, ReactNode } from 'react'; import { Button, Card } from 'reactstrap'; -import type { ShortUrl } from '../short-urls/data'; +import type { ShlinkShortUrl } from '../api-contract'; import { ShortUrlVisitsCount } from '../short-urls/helpers/ShortUrlVisitsCount'; import type { Visit } from './types'; @@ -10,7 +10,7 @@ type VisitsHeaderProps = PropsWithChildren<{ visits: Visit[]; goBack: () => void; title: ReactNode; - shortUrl?: ShortUrl; + shortUrl?: ShlinkShortUrl; }>; export const VisitsHeader: FC = ({ visits, goBack, shortUrl, children, title }) => ( diff --git a/src/visits/VisitsStats.tsx b/shlink-web-component/src/visits/VisitsStats.tsx similarity index 94% rename from src/visits/VisitsStats.tsx rename to shlink-web-component/src/visits/VisitsStats.tsx index 638d0cff..0f08628d 100644 --- a/src/visits/VisitsStats.tsx +++ b/shlink-web-component/src/visits/VisitsStats.tsx @@ -1,22 +1,20 @@ import type { IconDefinition } from '@fortawesome/fontawesome-common-types'; import { faCalendarAlt, faChartPie, faList, faMapMarkedAlt } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Message, NavPillItem, NavPills, Result } from '@shlinkio/shlink-frontend-kit'; import classNames from 'classnames'; import { isEmpty, pipe, propEq, values } from 'ramda'; import type { FC, PropsWithChildren } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import { Button, Progress, Row } from 'reactstrap'; -import { ShlinkApiError } from '../api/ShlinkApiError'; -import type { Settings } from '../settings/reducers/settings'; +import { ShlinkApiError } from '../common/ShlinkApiError'; +import { ExportBtn } from '../utils/components/ExportBtn'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; -import { ExportBtn } from '../utils/ExportBtn'; -import type { DateInterval, DateRange } from '../utils/helpers/dateIntervals'; -import { toDateRange } from '../utils/helpers/dateIntervals'; +import type { DateInterval, DateRange } from '../utils/dates/helpers/dateIntervals'; +import { toDateRange } from '../utils/dates/helpers/dateIntervals'; import { prettify } from '../utils/helpers/numbers'; -import { Message } from '../utils/Message'; -import { NavPillItem, NavPills } from '../utils/NavPills'; -import { Result } from '../utils/Result'; +import { useSetting } from '../utils/settings'; import { DoughnutChartCard } from './charts/DoughnutChartCard'; import { LineChartCard } from './charts/LineChartCard'; import { SortableBarChartCard } from './charts/SortableBarChartCard'; @@ -33,7 +31,6 @@ import { VisitsTable } from './VisitsTable'; export type VisitsStatsProps = PropsWithChildren<{ getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void; visitsInfo: VisitsInfo; - settings: Settings; cancelGetVisits: () => void; exportCsv: (visits: NormalizedVisit[]) => void; isOrphanVisits?: boolean; @@ -61,12 +58,12 @@ export const VisitsStats: FC = ({ visitsInfo, getVisits, cancelGetVisits, - settings, exportCsv, isOrphanVisits = false, }) => { const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo; const [{ dateRange, visitsFilter }, updateFiltering] = useVisitsQuery(); + const visitsSettings = useSetting('visits'); const setDates = pipe( ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({ dateRange: { @@ -77,7 +74,7 @@ export const VisitsStats: FC = ({ updateFiltering, ); const initialInterval = useRef( - dateRange ?? fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days', + dateRange ?? fallbackInterval ?? visitsSettings?.defaultInterval ?? 'last30Days', ); const [highlightedVisits, setHighlightedVisits] = useState([]); const [highlightedLabel, setHighlightedLabel] = useState(); @@ -92,7 +89,7 @@ export const VisitsStats: FC = ({ ); const resolvedFilter = useMemo(() => ({ ...visitsFilter, - excludeBots: visitsFilter.excludeBots ?? settings.visits?.excludeBots, + excludeBots: visitsFilter.excludeBots ?? visitsSettings?.excludeBots, }), [visitsFilter]); const mapLocations = values(citiesForMap); @@ -122,7 +119,7 @@ export const VisitsStats: FC = ({ }, [dateRange, visitsFilter]); useEffect(() => { // As soon as the fallback is loaded, if the initial interval used the settings one, we do fall back - if (fallbackInterval && initialInterval.current === (settings.visits?.defaultInterval ?? 'last30Days')) { + if (fallbackInterval && initialInterval.current === (visitsSettings?.defaultInterval ?? 'last30Days')) { initialInterval.current = fallbackInterval; } }, [fallbackInterval]); diff --git a/src/visits/VisitsTable.scss b/shlink-web-component/src/visits/VisitsTable.scss similarity index 93% rename from src/visits/VisitsTable.scss rename to shlink-web-component/src/visits/VisitsTable.scss index 2b20f59c..6c4f4e00 100644 --- a/src/visits/VisitsTable.scss +++ b/shlink-web-component/src/visits/VisitsTable.scss @@ -1,4 +1,4 @@ -@import '../utils/base'; +@import '@shlinkio/shlink-frontend-kit/base'; @import '../utils/mixins/sticky-cell'; .visits-table { diff --git a/src/visits/VisitsTable.tsx b/shlink-web-component/src/visits/VisitsTable.tsx similarity index 97% rename from src/visits/VisitsTable.tsx rename to shlink-web-component/src/visits/VisitsTable.tsx index 44c395f4..39a18c7a 100644 --- a/src/visits/VisitsTable.tsx +++ b/shlink-web-component/src/visits/VisitsTable.tsx @@ -1,15 +1,14 @@ import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import type { Order } from '@shlinkio/shlink-frontend-kit'; +import { determineOrderDir, SearchField, sortList } from '@shlinkio/shlink-frontend-kit'; import classNames from 'classnames'; import { min, splitEvery } from 'ramda'; import { useEffect, useMemo, useRef, useState } from 'react'; import { UncontrolledTooltip } from 'reactstrap'; -import { SimplePaginator } from '../common/SimplePaginator'; +import { SimplePaginator } from '../utils/components/SimplePaginator'; import { Time } from '../utils/dates/Time'; import { prettify } from '../utils/helpers/numbers'; -import type { Order } from '../utils/helpers/ordering'; -import { determineOrderDir, sortList } from '../utils/helpers/ordering'; -import { SearchField } from '../utils/SearchField'; import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import type { MediaMatcher } from '../utils/types'; import type { NormalizedOrphanVisit, NormalizedVisit } from './types'; diff --git a/src/visits/charts/ChartCard.scss b/shlink-web-component/src/visits/charts/ChartCard.scss similarity index 100% rename from src/visits/charts/ChartCard.scss rename to shlink-web-component/src/visits/charts/ChartCard.scss diff --git a/src/visits/charts/ChartCard.tsx b/shlink-web-component/src/visits/charts/ChartCard.tsx similarity index 100% rename from src/visits/charts/ChartCard.tsx rename to shlink-web-component/src/visits/charts/ChartCard.tsx diff --git a/src/visits/charts/DoughnutChart.tsx b/shlink-web-component/src/visits/charts/DoughnutChart.tsx similarity index 97% rename from src/visits/charts/DoughnutChart.tsx rename to shlink-web-component/src/visits/charts/DoughnutChart.tsx index ff721c14..e90cee87 100644 --- a/src/visits/charts/DoughnutChart.tsx +++ b/shlink-web-component/src/visits/charts/DoughnutChart.tsx @@ -1,10 +1,10 @@ +import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '@shlinkio/shlink-frontend-kit'; import type { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js'; import { keys, values } from 'ramda'; import type { FC } from 'react'; import { memo, useState } from 'react'; import { Doughnut } from 'react-chartjs-2'; import { renderPieChartLabel } from '../../utils/helpers/charts'; -import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '../../utils/theme'; import type { Stats } from '../types'; import { DoughnutChartLegend } from './DoughnutChartLegend'; diff --git a/src/visits/charts/DoughnutChartCard.tsx b/shlink-web-component/src/visits/charts/DoughnutChartCard.tsx similarity index 100% rename from src/visits/charts/DoughnutChartCard.tsx rename to shlink-web-component/src/visits/charts/DoughnutChartCard.tsx diff --git a/src/visits/charts/DoughnutChartLegend.scss b/shlink-web-component/src/visits/charts/DoughnutChartLegend.scss similarity index 90% rename from src/visits/charts/DoughnutChartLegend.scss rename to shlink-web-component/src/visits/charts/DoughnutChartLegend.scss index 3dabac25..0f8d4249 100644 --- a/src/visits/charts/DoughnutChartLegend.scss +++ b/shlink-web-component/src/visits/charts/DoughnutChartLegend.scss @@ -1,4 +1,4 @@ -@import '../../utils/base'; +@import '@shlinkio/shlink-frontend-kit/base'; .doughnut-chart-legend { list-style-type: none; diff --git a/src/visits/charts/DoughnutChartLegend.tsx b/shlink-web-component/src/visits/charts/DoughnutChartLegend.tsx similarity index 100% rename from src/visits/charts/DoughnutChartLegend.tsx rename to shlink-web-component/src/visits/charts/DoughnutChartLegend.tsx diff --git a/src/visits/charts/HorizontalBarChart.tsx b/shlink-web-component/src/visits/charts/HorizontalBarChart.tsx similarity index 97% rename from src/visits/charts/HorizontalBarChart.tsx rename to shlink-web-component/src/visits/charts/HorizontalBarChart.tsx index 1aac6903..600ea5ea 100644 --- a/src/visits/charts/HorizontalBarChart.tsx +++ b/shlink-web-component/src/visits/charts/HorizontalBarChart.tsx @@ -1,3 +1,4 @@ +import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '@shlinkio/shlink-frontend-kit'; import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js'; import { keys, values } from 'ramda'; import type { FC, MutableRefObject } from 'react'; @@ -5,9 +6,8 @@ import { useRef } from 'react'; import { Bar, getElementAtEvent } from 'react-chartjs-2'; import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts'; import { prettify } from '../../utils/helpers/numbers'; -import { fillTheGaps } from '../../utils/helpers/visits'; -import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../utils/theme'; import type { Stats } from '../types'; +import { fillTheGaps } from '../utils'; export interface HorizontalBarChartProps { stats: Stats; diff --git a/src/visits/charts/LineChartCard.scss b/shlink-web-component/src/visits/charts/LineChartCard.scss similarity index 73% rename from src/visits/charts/LineChartCard.scss rename to shlink-web-component/src/visits/charts/LineChartCard.scss index e834aece..a74f33ef 100644 --- a/src/visits/charts/LineChartCard.scss +++ b/shlink-web-component/src/visits/charts/LineChartCard.scss @@ -1,4 +1,4 @@ -@import '../../utils/base'; +@import '@shlinkio/shlink-frontend-kit/base'; .line-chart-card__body canvas { height: 300px !important; diff --git a/src/visits/charts/LineChartCard.tsx b/shlink-web-component/src/visits/charts/LineChartCard.tsx similarity index 91% rename from src/visits/charts/LineChartCard.tsx rename to shlink-web-component/src/visits/charts/LineChartCard.tsx index 59cb9bba..f2cb2a95 100644 --- a/src/visits/charts/LineChartCard.tsx +++ b/shlink-web-component/src/visits/charts/LineChartCard.tsx @@ -1,3 +1,4 @@ +import { HIGHLIGHTED_COLOR, MAIN_COLOR, ToggleSwitch, useToggle } from '@shlinkio/shlink-frontend-kit'; import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js'; import { add, @@ -23,15 +24,12 @@ import { DropdownToggle, UncontrolledDropdown, } from 'reactstrap'; +import { formatInternational } from '../../utils/dates/helpers/date'; +import { rangeOf } from '../../utils/helpers'; import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts'; -import { STANDARD_DATE_FORMAT } from '../../utils/helpers/date'; -import { useToggle } from '../../utils/helpers/hooks'; import { prettify } from '../../utils/helpers/numbers'; -import { fillTheGaps } from '../../utils/helpers/visits'; -import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme'; -import { ToggleSwitch } from '../../utils/ToggleSwitch'; -import { rangeOf } from '../../utils/utils'; import type { NormalizedVisit, Stats } from '../types'; +import { fillTheGaps } from '../utils'; import './LineChartCard.scss'; interface LineChartCardProps { @@ -67,10 +65,16 @@ const STEP_TO_DIFF_FUNC_MAP: Record n const STEP_TO_DATE_FORMAT: Record string> = { hourly: (date) => format(date, 'yyyy-MM-dd HH:00'), - daily: (date) => format(date, STANDARD_DATE_FORMAT), + // TODO Fix formatInternational return type + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + daily: (date) => formatInternational(date)!, weekly(date) { - const firstWeekDay = format(startOfISOWeek(date), STANDARD_DATE_FORMAT); - const lastWeekDay = format(endOfISOWeek(date), STANDARD_DATE_FORMAT); + // TODO Fix formatInternational return type + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const firstWeekDay = formatInternational(startOfISOWeek(date))!; + // TODO Fix formatInternational return type + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const lastWeekDay = formatInternational(endOfISOWeek(date))!; return `${firstWeekDay} - ${lastWeekDay}`; }, diff --git a/src/visits/charts/SortableBarChartCard.tsx b/shlink-web-component/src/visits/charts/SortableBarChartCard.tsx similarity index 93% rename from src/visits/charts/SortableBarChartCard.tsx rename to shlink-web-component/src/visits/charts/SortableBarChartCard.tsx index 7ee08900..8cedb724 100644 --- a/src/visits/charts/SortableBarChartCard.tsx +++ b/shlink-web-component/src/visits/charts/SortableBarChartCard.tsx @@ -1,12 +1,12 @@ +import type { Order } from '@shlinkio/shlink-frontend-kit'; +import { OrderingDropdown } from '@shlinkio/shlink-frontend-kit'; import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda'; import type { FC, ReactNode } from 'react'; import { useState } from 'react'; -import { SimplePaginator } from '../../common/SimplePaginator'; +import { PaginationDropdown } from '../../utils/components/PaginationDropdown'; +import { SimplePaginator } from '../../utils/components/SimplePaginator'; +import { rangeOf } from '../../utils/helpers'; import { roundTen } from '../../utils/helpers/numbers'; -import type { Order } from '../../utils/helpers/ordering'; -import { OrderingDropdown } from '../../utils/OrderingDropdown'; -import { PaginationDropdown } from '../../utils/PaginationDropdown'; -import { rangeOf } from '../../utils/utils'; import type { Stats, StatsRow } from '../types'; import { ChartCard } from './ChartCard'; import type { HorizontalBarChartProps } from './HorizontalBarChart'; diff --git a/src/visits/helpers/MapModal.scss b/shlink-web-component/src/visits/helpers/MapModal.scss similarity index 88% rename from src/visits/helpers/MapModal.scss rename to shlink-web-component/src/visits/helpers/MapModal.scss index bcf0d938..4a88ca51 100644 --- a/src/visits/helpers/MapModal.scss +++ b/shlink-web-component/src/visits/helpers/MapModal.scss @@ -1,4 +1,4 @@ -@import '../../utils/base'; +@import '@shlinkio/shlink-frontend-kit/base'; @import '../../utils/mixins/fit-with-margin'; .map-modal__modal.map-modal__modal { @@ -26,7 +26,7 @@ padding: .5rem 1rem 1rem; margin: 0; color: #fff; - background: linear-gradient(rgba(0, 0, 0, .5), rgba(0, 0, 0, 0)); + background: linear-gradient(rgb(0 0 0 / .5), rgb(0 0 0 / 0)); } .map-modal__modal-body.map-modal__modal-body { diff --git a/src/visits/helpers/MapModal.tsx b/shlink-web-component/src/visits/helpers/MapModal.tsx similarity index 100% rename from src/visits/helpers/MapModal.tsx rename to shlink-web-component/src/visits/helpers/MapModal.tsx diff --git a/src/visits/helpers/OpenMapModalBtn.scss b/shlink-web-component/src/visits/helpers/OpenMapModalBtn.scss similarity index 100% rename from src/visits/helpers/OpenMapModalBtn.scss rename to shlink-web-component/src/visits/helpers/OpenMapModalBtn.scss diff --git a/src/visits/helpers/OpenMapModalBtn.tsx b/shlink-web-component/src/visits/helpers/OpenMapModalBtn.tsx similarity index 96% rename from src/visits/helpers/OpenMapModalBtn.tsx rename to shlink-web-component/src/visits/helpers/OpenMapModalBtn.tsx index b36a2ce9..f36fa147 100644 --- a/src/visits/helpers/OpenMapModalBtn.tsx +++ b/shlink-web-component/src/visits/helpers/OpenMapModalBtn.tsx @@ -1,8 +1,8 @@ import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useDomId, useToggle } from '@shlinkio/shlink-frontend-kit'; import { useState } from 'react'; import { Button, Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap'; -import { useDomId, useToggle } from '../../utils/helpers/hooks'; import type { CityStats } from '../types'; import { MapModal } from './MapModal'; import './OpenMapModalBtn.scss'; diff --git a/src/visits/helpers/VisitsFilterDropdown.tsx b/shlink-web-component/src/visits/helpers/VisitsFilterDropdown.tsx similarity index 94% rename from src/visits/helpers/VisitsFilterDropdown.tsx rename to shlink-web-component/src/visits/helpers/VisitsFilterDropdown.tsx index 7a901421..63882165 100644 --- a/src/visits/helpers/VisitsFilterDropdown.tsx +++ b/shlink-web-component/src/visits/helpers/VisitsFilterDropdown.tsx @@ -1,7 +1,7 @@ +import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; import type { DropdownItemProps } from 'reactstrap'; import { DropdownItem } from 'reactstrap'; -import { DropdownBtn } from '../../utils/DropdownBtn'; -import { hasValue } from '../../utils/utils'; +import { hasValue } from '../../utils/helpers'; import type { OrphanVisitType, VisitsFilter } from '../types'; interface VisitsFilterDropdownProps { diff --git a/src/visits/helpers/hooks.ts b/shlink-web-component/src/visits/helpers/hooks.ts similarity index 84% rename from src/visits/helpers/hooks.ts rename to shlink-web-component/src/visits/helpers/hooks.ts index 42798bc7..bab38a1b 100644 --- a/src/visits/helpers/hooks.ts +++ b/shlink-web-component/src/visits/helpers/hooks.ts @@ -1,13 +1,13 @@ import type { DeepPartial } from '@reduxjs/toolkit'; +import { parseQuery, stringifyQuery } from '@shlinkio/shlink-frontend-kit'; import { isEmpty, isNil, mergeDeepRight, pipe } from 'ramda'; import { useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { formatIsoDate } from '../../utils/helpers/date'; -import type { DateRange } from '../../utils/helpers/dateIntervals'; -import { datesToDateRange } from '../../utils/helpers/dateIntervals'; -import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; -import type { BooleanString } from '../../utils/utils'; -import { parseBooleanToString } from '../../utils/utils'; +import { formatIsoDate } from '../../utils/dates/helpers/date'; +import type { DateRange } from '../../utils/dates/helpers/dateIntervals'; +import { datesToDateRange } from '../../utils/dates/helpers/dateIntervals'; +import type { BooleanString } from '../../utils/helpers'; +import { parseBooleanToString } from '../../utils/helpers'; import type { OrphanVisitType, VisitsFilter } from '../types'; interface VisitsQuery { diff --git a/src/visits/reducers/common.ts b/shlink-web-component/src/visits/reducers/common.ts similarity index 91% rename from src/visits/reducers/common.ts rename to shlink-web-component/src/visits/reducers/common.ts index de5f33dc..d8d6f528 100644 --- a/src/visits/reducers/common.ts +++ b/shlink-web-component/src/visits/reducers/common.ts @@ -1,11 +1,11 @@ import { createAction, createSlice } from '@reduxjs/toolkit'; import { flatten, prop, range, splitEvery } from 'ramda'; -import type { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types'; -import { parseApiError } from '../../api/utils'; -import type { ShlinkState } from '../../container/types'; -import type { DateInterval } from '../../utils/helpers/dateIntervals'; -import { dateToMatchingInterval } from '../../utils/helpers/dateIntervals'; -import { createAsyncThunk } from '../../utils/helpers/redux'; +import type { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api-contract'; +import { parseApiError } from '../../api-contract/utils'; +import type { RootState } from '../../container/store'; +import type { DateInterval } from '../../utils/dates/helpers/dateIntervals'; +import { dateToMatchingInterval } from '../../utils/dates/helpers/dateIntervals'; +import { createAsyncThunk } from '../../utils/redux'; import type { CreateVisit, Visit } from '../types'; import type { LoadVisits, VisitsInfo, VisitsLoaded } from './types'; import { createNewVisits } from './visitCreation'; @@ -22,9 +22,9 @@ type LastVisitLoader = (excludeBots?: boolean) => Promise; interface VisitsAsyncThunkOptions { typePrefix: string; - createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader]; + createLoaders: (params: T) => [VisitsLoader, LastVisitLoader]; getExtraFulfilledPayload: (params: T) => Partial; - shouldCancel: (getState: () => ShlinkState) => boolean; + shouldCancel: (getState: () => RootState) => boolean; } export const createVisitsAsyncThunk = ( @@ -35,7 +35,7 @@ export const createVisitsAsyncThunk = (`${typePrefix}/fallbackToInterval`); const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise> => { - const [visitsLoader, lastVisitLoader] = createLoaders(params, getState); + const [visitsLoader, lastVisitLoader] = createLoaders(params); const loadVisitsInParallel = async (pages: number[]): Promise => Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); diff --git a/src/visits/reducers/domainVisits.ts b/shlink-web-component/src/visits/reducers/domainVisits.ts similarity index 82% rename from src/visits/reducers/domainVisits.ts rename to shlink-web-component/src/visits/reducers/domainVisits.ts index cfb08322..9055347d 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/shlink-web-component/src/visits/reducers/domainVisits.ts @@ -1,6 +1,6 @@ -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; +import type { ShlinkApiClient } from '../../api-contract'; import { domainMatches } from '../../short-urls/helpers'; -import { isBetween } from '../../utils/helpers/date'; +import { isBetween } from '../../utils/dates/helpers/date'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import type { LoadVisits, VisitsInfo } from './types'; @@ -26,10 +26,10 @@ const initialState: DomainVisits = { progress: 0, }; -export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ +export const getDomainVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ typePrefix: `${REDUCER_PREFIX}/getDomainVisits`, - createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, getState) => { - const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); + createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits) => { + const { getDomainVisits: getVisits } = apiClientFactory(); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( domain, { ...query, page, itemsPerPage }, diff --git a/src/visits/reducers/nonOrphanVisits.ts b/shlink-web-component/src/visits/reducers/nonOrphanVisits.ts similarity index 74% rename from src/visits/reducers/nonOrphanVisits.ts rename to shlink-web-component/src/visits/reducers/nonOrphanVisits.ts index 6156cb74..59a48143 100644 --- a/src/visits/reducers/nonOrphanVisits.ts +++ b/shlink-web-component/src/visits/reducers/nonOrphanVisits.ts @@ -1,5 +1,5 @@ -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { isBetween } from '../../utils/helpers/date'; +import type { ShlinkApiClient } from '../../api-contract'; +import { isBetween } from '../../utils/dates/helpers/date'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import type { VisitsInfo } from './types'; @@ -14,10 +14,10 @@ const initialState: VisitsInfo = { progress: 0, }; -export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ +export const getNonOrphanVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ typePrefix: `${REDUCER_PREFIX}/getNonOrphanVisits`, - createLoaders: ({ query = {}, doIntervalFallback = false }, getState) => { - const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState); + createLoaders: ({ query = {}, doIntervalFallback = false }) => { + const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = apiClientFactory(); const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage }); const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits); diff --git a/src/visits/reducers/orphanVisits.ts b/shlink-web-component/src/visits/reducers/orphanVisits.ts similarity index 82% rename from src/visits/reducers/orphanVisits.ts rename to shlink-web-component/src/visits/reducers/orphanVisits.ts index 1bb5c55d..0abf619f 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/shlink-web-component/src/visits/reducers/orphanVisits.ts @@ -1,5 +1,5 @@ -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { isBetween } from '../../utils/helpers/date'; +import type { ShlinkApiClient } from '../../api-contract'; +import { isBetween } from '../../utils/dates/helpers/date'; import type { OrphanVisit, OrphanVisitType } from '../types'; import { isOrphanVisit } from '../types/helpers'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; @@ -23,10 +23,10 @@ const initialState: VisitsInfo = { const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) => !orphanVisitsType || orphanVisitsType === visit.type; -export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ +export const getOrphanVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ typePrefix: `${REDUCER_PREFIX}/getOrphanVisits`, - createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits, getState) => { - const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState); + createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits) => { + const { getOrphanVisits: getVisits } = apiClientFactory(); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage }) .then((result) => { const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType)); diff --git a/src/visits/reducers/shortUrlVisits.ts b/shlink-web-component/src/visits/reducers/shortUrlVisits.ts similarity index 83% rename from src/visits/reducers/shortUrlVisits.ts rename to shlink-web-component/src/visits/reducers/shortUrlVisits.ts index 5b7d432e..b76ee21d 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/shlink-web-component/src/visits/reducers/shortUrlVisits.ts @@ -1,7 +1,7 @@ -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; +import type { ShlinkApiClient } from '../../api-contract'; import type { ShortUrlIdentifier } from '../../short-urls/data'; import { shortUrlMatches } from '../../short-urls/helpers'; -import { isBetween } from '../../utils/helpers/date'; +import { isBetween } from '../../utils/dates/helpers/date'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import type { LoadVisits, VisitsInfo } from './types'; @@ -24,10 +24,10 @@ const initialState: ShortUrlVisits = { progress: 0, }; -export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ +export const getShortUrlVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ typePrefix: `${REDUCER_PREFIX}/getShortUrlVisits`, - createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits, getState) => { - const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState); + createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits) => { + const { getShortUrlVisits: shlinkGetShortUrlVisits } = apiClientFactory(); const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits( shortCode, { ...query, page, itemsPerPage }, diff --git a/src/visits/reducers/tagVisits.ts b/shlink-web-component/src/visits/reducers/tagVisits.ts similarity index 81% rename from src/visits/reducers/tagVisits.ts rename to shlink-web-component/src/visits/reducers/tagVisits.ts index ff2b2374..6fdf5dae 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/shlink-web-component/src/visits/reducers/tagVisits.ts @@ -1,5 +1,5 @@ -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { isBetween } from '../../utils/helpers/date'; +import type { ShlinkApiClient } from '../../api-contract'; +import { isBetween } from '../../utils/dates/helpers/date'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import type { LoadVisits, VisitsInfo } from './types'; @@ -23,10 +23,10 @@ const initialState: TagVisits = { progress: 0, }; -export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ +export const getTagVisits = (apiClientFactory: () => ShlinkApiClient) => createVisitsAsyncThunk({ typePrefix: `${REDUCER_PREFIX}/getTagVisits`, - createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits, getState) => { - const { getTagVisits: getVisits } = buildShlinkApiClient(getState); + createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits) => { + const { getTagVisits: getVisits } = apiClientFactory(); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( tag, { ...query, page, itemsPerPage }, diff --git a/src/visits/reducers/types/index.ts b/shlink-web-component/src/visits/reducers/types/index.ts similarity index 70% rename from src/visits/reducers/types/index.ts rename to shlink-web-component/src/visits/reducers/types/index.ts index 1ab70627..a93fadee 100644 --- a/src/visits/reducers/types/index.ts +++ b/shlink-web-component/src/visits/reducers/types/index.ts @@ -1,6 +1,5 @@ -import type { ShlinkVisitsParams } from '../../../api/types'; -import type { ProblemDetailsError } from '../../../api/types/errors'; -import type { DateInterval } from '../../../utils/helpers/dateIntervals'; +import type { ProblemDetailsError, ShlinkVisitsParams } from '../../../api-contract'; +import type { DateInterval } from '../../../utils/dates/helpers/dateIntervals'; import type { Visit } from '../../types'; export interface VisitsInfo { diff --git a/src/visits/reducers/visitCreation.ts b/shlink-web-component/src/visits/reducers/visitCreation.ts similarity index 100% rename from src/visits/reducers/visitCreation.ts rename to shlink-web-component/src/visits/reducers/visitCreation.ts diff --git a/src/visits/reducers/visitsOverview.ts b/shlink-web-component/src/visits/reducers/visitsOverview.ts similarity index 79% rename from src/visits/reducers/visitsOverview.ts rename to shlink-web-component/src/visits/reducers/visitsOverview.ts index 073fa3d5..259a4492 100644 --- a/src/visits/reducers/visitsOverview.ts +++ b/shlink-web-component/src/visits/reducers/visitsOverview.ts @@ -1,8 +1,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; -import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import type { ShlinkVisitsOverview } from '../../api/types'; -import { createAsyncThunk } from '../../utils/helpers/redux'; +import type { ShlinkApiClient, ShlinkVisitsOverview } from '../../api-contract'; +import { createAsyncThunk } from '../../utils/redux'; import type { CreateVisit } from '../types'; import { groupNewVisitsByType } from '../types/helpers'; import { createNewVisits } from './visitCreation'; @@ -40,19 +39,19 @@ const initialState: VisitsOverview = { const countBots = (visits: CreateVisit[]) => visits.filter(({ visit }) => visit.potentialBot).length; -export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( +export const loadVisitsOverview = (apiClientFactory: () => ShlinkApiClient) => createAsyncThunk( `${REDUCER_PREFIX}/loadVisitsOverview`, - (_: void, { getState }): Promise => buildShlinkApiClient(getState).getVisitsOverview().then( - (resp) => ({ + (): Promise => apiClientFactory().getVisitsOverview().then( + ({ nonOrphanVisits, visitsCount, orphanVisits, orphanVisitsCount }) => ({ nonOrphanVisits: { - total: resp.nonOrphanVisits?.total ?? resp.visitsCount, - nonBots: resp.nonOrphanVisits?.nonBots, - bots: resp.nonOrphanVisits?.bots, + total: nonOrphanVisits?.total ?? visitsCount, + nonBots: nonOrphanVisits?.nonBots, + bots: nonOrphanVisits?.bots, }, orphanVisits: { - total: resp.orphanVisits?.total ?? resp.orphanVisitsCount, - nonBots: resp.orphanVisits?.nonBots, - bots: resp.orphanVisits?.bots, + total: orphanVisits?.total ?? orphanVisitsCount, + nonBots: orphanVisits?.nonBots, + bots: orphanVisits?.bots, }, }), ), diff --git a/src/visits/services/VisitsParser.ts b/shlink-web-component/src/visits/services/VisitsParser.ts similarity index 96% rename from src/visits/services/VisitsParser.ts rename to shlink-web-component/src/visits/services/VisitsParser.ts index 9d10e706..3a81dfa0 100644 --- a/src/visits/services/VisitsParser.ts +++ b/shlink-web-component/src/visits/services/VisitsParser.ts @@ -1,8 +1,8 @@ import { isNil, map } from 'ramda'; -import { extractDomain, parseUserAgent } from '../../utils/helpers/visits'; -import { hasValue } from '../../utils/utils'; +import { hasValue } from '../../utils/helpers'; import type { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types'; import { isNormalizedOrphanVisit, isOrphanVisit } from '../types/helpers'; +import { extractDomain, parseUserAgent } from '../utils'; /* eslint-disable no-param-reassign */ const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) => diff --git a/src/visits/services/provideServices.ts b/shlink-web-component/src/visits/services/provideServices.ts similarity index 86% rename from src/visits/services/provideServices.ts rename to shlink-web-component/src/visits/services/provideServices.ts index 0319bfc4..59cf5964 100644 --- a/src/visits/services/provideServices.ts +++ b/shlink-web-component/src/visits/services/provideServices.ts @@ -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 { DomainVisits } from '../DomainVisits'; import { MapModal } from '../helpers/MapModal'; import { NonOrphanVisits } from '../NonOrphanVisits'; @@ -22,31 +22,31 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter'); bottle.decorator('ShortUrlVisits', connect( - ['shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings'], + ['shortUrlVisits', 'shortUrlDetail', 'mercureInfo'], ['getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo'], )); bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter'); bottle.decorator('TagVisits', connect( - ['tagVisits', 'mercureInfo', 'settings'], + ['tagVisits', 'mercureInfo'], ['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'], )); bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter'); bottle.decorator('DomainVisits', connect( - ['domainVisits', 'mercureInfo', 'settings'], + ['domainVisits', 'mercureInfo'], ['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'], )); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter'); bottle.decorator('OrphanVisits', connect( - ['orphanVisits', 'mercureInfo', 'settings'], + ['orphanVisits', 'mercureInfo'], ['getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo'], )); bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter'); bottle.decorator('NonOrphanVisits', connect( - ['nonOrphanVisits', 'mercureInfo', 'settings'], + ['nonOrphanVisits', 'mercureInfo'], ['getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo'], )); @@ -54,23 +54,23 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('VisitsParser', () => visitsParser); // Actions - bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'apiClientFactory'); bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetVisits'), 'shortUrlVisitsReducerCreator'); - bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getTagVisits', getTagVisits, 'apiClientFactory'); bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetVisits'), 'tagVisitsReducerCreator'); - bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getDomainVisits', getDomainVisits, 'apiClientFactory'); bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetVisits'), 'domainVisitsReducerCreator'); - bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'apiClientFactory'); bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetVisits'), 'orphanVisitsReducerCreator'); - bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'apiClientFactory'); bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetVisits'), 'nonOrphanVisitsReducerCreator'); bottle.serviceFactory('createNewVisits', () => createNewVisits); - bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient'); + bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'apiClientFactory'); // Reducers bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview'); diff --git a/src/visits/types/helpers.ts b/shlink-web-component/src/visits/types/helpers.ts similarity index 91% rename from src/visits/types/helpers.ts rename to shlink-web-component/src/visits/types/helpers.ts index 4cda7222..15dfa3c8 100644 --- a/src/visits/types/helpers.ts +++ b/shlink-web-component/src/visits/types/helpers.ts @@ -1,6 +1,6 @@ +import type { ShlinkVisitsParams } from '@shlinkio/shlink-web-component/api-contract'; import { countBy, groupBy, pipe, prop } from 'ramda'; -import type { ShlinkVisitsParams } from '../../api/types'; -import { formatIsoDate } from '../../utils/helpers/date'; +import { formatIsoDate } from '../../utils/dates/helpers/date'; import type { CreateVisit, NormalizedOrphanVisit, NormalizedVisit, OrphanVisit, Stats, Visit, VisitsParams } from './index'; export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => (visit as OrphanVisit).visitedUrl !== undefined; diff --git a/src/visits/types/index.ts b/shlink-web-component/src/visits/types/index.ts similarity index 91% rename from src/visits/types/index.ts rename to shlink-web-component/src/visits/types/index.ts index 503f8170..d10829a8 100644 --- a/src/visits/types/index.ts +++ b/shlink-web-component/src/visits/types/index.ts @@ -1,5 +1,5 @@ -import type { ShortUrl } from '../../short-urls/data'; -import type { DateRange } from '../../utils/helpers/dateIntervals'; +import type { ShlinkShortUrl } from '../../api-contract'; +import type { DateRange } from '../../utils/dates/helpers/dateIntervals'; export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; @@ -52,7 +52,7 @@ export interface NormalizedOrphanVisit extends NormalizedRegularVisit { export type NormalizedVisit = NormalizedRegularVisit | NormalizedOrphanVisit; export interface CreateVisit { - shortUrl?: ShortUrl; + shortUrl?: ShlinkShortUrl; visit: Visit; } diff --git a/src/utils/helpers/visits.ts b/shlink-web-component/src/visits/utils/index.ts similarity index 87% rename from src/utils/helpers/visits.ts rename to shlink-web-component/src/visits/utils/index.ts index 468d310a..9f6f2975 100644 --- a/src/utils/helpers/visits.ts +++ b/shlink-web-component/src/visits/utils/index.ts @@ -1,8 +1,8 @@ import bowser from 'bowser'; import { zipObj } from 'ramda'; -import type { Stats, UserAgent } from '../../visits/types'; -import type { Empty } from '../utils'; -import { hasValue } from '../utils'; +import type { Empty } from '../../utils/helpers'; +import { hasValue } from '../../utils/helpers'; +import type { Stats, UserAgent } from '../types'; const DEFAULT = 'Others'; const BROWSERS_WHITELIST = [ diff --git a/shlink-web-component/test/Main.test.tsx b/shlink-web-component/test/Main.test.tsx new file mode 100644 index 00000000..33b702bf --- /dev/null +++ b/shlink-web-component/test/Main.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import type { MainProps } from '../src/Main'; +import { Main as createMain } from '../src/Main'; +import { FeaturesProvider } from '../src/utils/features'; + +type SetUpOptions = { + currentPath: string + createNotFound?: MainProps['createNotFound']; + domainVisitsSupported?: boolean; +}; + +describe('
', () => { + const Main = createMain( + () => <>TagsList, + () => <>ShortUrlsList, + () => <>CreateShortUrl, + () => <>ShortUrlVisits, + () => <>TagVisits, + () => <>DomainVisits, + () => <>OrphanVisits, + () => <>NonOrphanVisits, + () => <>OverviewRoute, + () => <>EditShortUrl, + () => <>ManageDomains, + ); + const setUp = ({ createNotFound, currentPath, domainVisitsSupported = true }: SetUpOptions) => { + const history = createMemoryHistory(); + history.push(currentPath); + + return render( + + +
+ + , + ); + }; + + it.each([ + ['/overview', 'OverviewRoute'], + ['/list-short-urls/1', 'ShortUrlsList'], + ['/create-short-url', 'CreateShortUrl'], + ['/short-code/abc123/visits/foo', 'ShortUrlVisits'], + ['/short-code/abc123/edit', 'EditShortUrl'], + ['/tag/foo/visits/foo', 'TagVisits'], + ['/orphan-visits/foo', 'OrphanVisits'], + ['/manage-tags', 'TagsList'], + ['/domain/domain.com/visits/foo', 'DomainVisits'], + ['/non-orphan-visits/foo', 'NonOrphanVisits'], + ['/manage-domains', 'ManageDomains'], + ])( + 'renders expected component based on location and server version', + (currentPath, expectedContent) => { + setUp({ currentPath }); + expect(screen.getByText(expectedContent)).toBeInTheDocument(); + }, + ); + + it.each([ + ['/domain/domain.com/visits/foo', false], + ['/foo/bar/baz', true], + ])('renders not-found when trying to navigate to invalid route', (currentPath, domainVisitsSupported) => { + const createNotFound = () => <>Oops! Route not found.; + + setUp({ currentPath, domainVisitsSupported, createNotFound }); + + expect(screen.getByText('Oops! Route not found.')).toBeInTheDocument(); + }); +}); diff --git a/shlink-web-component/test/ShlinkWebComponent.test.tsx b/shlink-web-component/test/ShlinkWebComponent.test.tsx new file mode 100644 index 00000000..1f7023f9 --- /dev/null +++ b/shlink-web-component/test/ShlinkWebComponent.test.tsx @@ -0,0 +1,54 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import Bottle from 'bottlejs'; +import type { TagColorsStorage } from '../src'; +import type { ShlinkApiClient } from '../src/api-contract'; +import { createShlinkWebComponent } from '../src/ShlinkWebComponent'; + +describe('', () => { + let bottle: Bottle; + const dispatch = vi.fn(); + const loadMercureInfo = vi.fn(); + const apiClient = fromPartial({}); + + const setUp = (tagColorsStorage?: TagColorsStorage) => { + const ShlinkWebComponent = createShlinkWebComponent(bottle); + return render( + , + ); + }; + + beforeEach(() => { + bottle = new Bottle(); + + bottle.value('Main', () => <>Main); + bottle.value('store', { + dispatch, + getState: vi.fn().mockReturnValue({}), + subscribe: vi.fn(), + }); + bottle.value('loadMercureInfo', loadMercureInfo); + }); + + it('registers services when mounted', async () => { + expect(bottle.container.TagColorsStorage).not.toBeDefined(); + expect(bottle.container.apiClientFactory).not.toBeDefined(); + + setUp(fromPartial({})); + + await waitFor(() => expect(bottle.container.TagColorsStorage).toBeDefined()); + expect(bottle.container.apiClientFactory).toBeDefined(); + }); + + it('renders main content', async () => { + setUp(); + await waitFor(() => expect(screen.getByText('Main')).toBeInTheDocument()); + }); + + it('loads mercure on mount', async () => { + setUp(); + + await waitFor(() => expect(dispatch).toHaveBeenCalledOnce()); + expect(loadMercureInfo).toHaveBeenCalledOnce(); + }); +}); diff --git a/shlink-web-component/test/__helpers__/TestModalWrapper.tsx b/shlink-web-component/test/__helpers__/TestModalWrapper.tsx new file mode 100644 index 00000000..18959df7 --- /dev/null +++ b/shlink-web-component/test/__helpers__/TestModalWrapper.tsx @@ -0,0 +1,14 @@ +import { useToggle } from '@shlinkio/shlink-frontend-kit'; +import type { FC, ReactElement } from 'react'; + +interface RenderModalArgs { + isOpen: boolean; + toggle: () => void; +} + +export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = ( + { renderModal }, +) => { + const [isOpen, toggle] = useToggle(true); + return renderModal({ isOpen, toggle }); +}; diff --git a/shlink-web-component/test/__helpers__/setUpTest.ts b/shlink-web-component/test/__helpers__/setUpTest.ts new file mode 100644 index 00000000..5d125c73 --- /dev/null +++ b/shlink-web-component/test/__helpers__/setUpTest.ts @@ -0,0 +1,20 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; + +export const setUpCanvas = (element: ReactElement) => { + const result = render(element); + const { container } = result; + const getEvents = () => { + const context = container.querySelector('canvas')?.getContext('2d'); + // @ts-expect-error __getEvents is set by vitest-canvas-mock + return context?.__getEvents(); // eslint-disable-line no-underscore-dangle + }; + + return { ...result, events: getEvents(), getEvents }; +}; + +export const renderWithEvents = (element: ReactElement) => ({ + user: userEvent.setup(), + ...render(element), +}); diff --git a/shlink-web-component/test/__mocks__/Window.mock.ts b/shlink-web-component/test/__mocks__/Window.mock.ts new file mode 100644 index 00000000..8b718753 --- /dev/null +++ b/shlink-web-component/test/__mocks__/Window.mock.ts @@ -0,0 +1,18 @@ +import { fromAny, fromPartial } from '@total-typescript/shoehorn'; + +const createLinkMock = () => ({ + setAttribute: vi.fn(), + click: vi.fn(), + style: {}, +}); + +export const appendChild = vi.fn(); + +export const removeChild = vi.fn(); + +export const windowMock = fromPartial({ + document: fromAny({ + createElement: vi.fn(createLinkMock), + body: { appendChild, removeChild }, + }), +}); diff --git a/shlink-web-component/test/common/AsideMenu.test.tsx b/shlink-web-component/test/common/AsideMenu.test.tsx new file mode 100644 index 00000000..0b0d7bbb --- /dev/null +++ b/shlink-web-component/test/common/AsideMenu.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { AsideMenu } from '../../src/common/AsideMenu'; + +describe('', () => { + const setUp = () => render( + + + , + ); + + it('contains links to different sections', () => { + setUp(); + + const links = screen.getAllByRole('link'); + + expect.assertions(links.length + 1); + expect(links).toHaveLength(5); + links.forEach((link) => expect(link.getAttribute('href')).toContain('abc123')); + }); +}); diff --git a/test/api/ShlinkApiError.test.tsx b/shlink-web-component/test/common/ShlinkApiError.test.tsx similarity index 85% rename from test/api/ShlinkApiError.test.tsx rename to shlink-web-component/test/common/ShlinkApiError.test.tsx index 1690b5ce..8d614e66 100644 --- a/test/api/ShlinkApiError.test.tsx +++ b/shlink-web-component/test/common/ShlinkApiError.test.tsx @@ -1,9 +1,9 @@ import { render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiErrorProps } from '../../src/api/ShlinkApiError'; -import { ShlinkApiError } from '../../src/api/ShlinkApiError'; -import type { InvalidArgumentError, ProblemDetailsError } from '../../src/api/types/errors'; -import { ErrorTypeV2, ErrorTypeV3 } from '../../src/api/types/errors'; +import type { InvalidArgumentError, ProblemDetailsError } from '../../src/api-contract'; +import { ErrorTypeV2, ErrorTypeV3 } from '../../src/api-contract'; +import type { ShlinkApiErrorProps } from '../../src/common/ShlinkApiError'; +import { ShlinkApiError } from '../../src/common/ShlinkApiError'; describe('', () => { const setUp = (props: ShlinkApiErrorProps) => render(); diff --git a/test/domains/DomainRow.test.tsx b/shlink-web-component/test/domains/DomainRow.test.tsx similarity index 96% rename from test/domains/DomainRow.test.tsx rename to shlink-web-component/test/domains/DomainRow.test.tsx index f3d6bc3e..967b77ec 100644 --- a/test/domains/DomainRow.test.tsx +++ b/shlink-web-component/test/domains/DomainRow.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkDomainRedirects } from '../../src/api/types'; +import type { ShlinkDomainRedirects } from '../../src/api-contract'; import type { Domain } from '../../src/domains/data'; import { DomainRow } from '../../src/domains/DomainRow'; @@ -21,7 +21,6 @@ describe('', () => { diff --git a/test/domains/DomainSelector.test.tsx b/shlink-web-component/test/domains/DomainSelector.test.tsx similarity index 100% rename from test/domains/DomainSelector.test.tsx rename to shlink-web-component/test/domains/DomainSelector.test.tsx diff --git a/test/domains/ManageDomains.test.tsx b/shlink-web-component/test/domains/ManageDomains.test.tsx similarity index 94% rename from test/domains/ManageDomains.test.tsx rename to shlink-web-component/test/domains/ManageDomains.test.tsx index 6eba6b79..4f6050b8 100644 --- a/test/domains/ManageDomains.test.tsx +++ b/shlink-web-component/test/domains/ManageDomains.test.tsx @@ -1,7 +1,6 @@ import { screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkDomain } from '../../src/api/types'; -import type { ProblemDetailsError } from '../../src/api/types/errors'; +import type { ProblemDetailsError, ShlinkDomain } from '../../src/api-contract'; import { ManageDomains } from '../../src/domains/ManageDomains'; import type { DomainsList } from '../../src/domains/reducers/domainsList'; import { renderWithEvents } from '../__helpers__/setUpTest'; @@ -16,7 +15,6 @@ describe('', () => { editDomainRedirects={vi.fn()} checkDomainHealth={vi.fn()} domainsList={domainsList} - selectedServer={fromPartial({})} />, ); diff --git a/test/domains/helpers/DomainDropdown.test.tsx b/shlink-web-component/test/domains/helpers/DomainDropdown.test.tsx similarity index 62% rename from test/domains/helpers/DomainDropdown.test.tsx rename to shlink-web-component/test/domains/helpers/DomainDropdown.test.tsx index 85c72326..4044f98b 100644 --- a/test/domains/helpers/DomainDropdown.test.tsx +++ b/shlink-web-component/test/domains/helpers/DomainDropdown.test.tsx @@ -3,24 +3,27 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router-dom'; import type { Domain } from '../../../src/domains/data'; import { DomainDropdown } from '../../../src/domains/helpers/DomainDropdown'; -import type { SelectedServer } from '../../../src/servers/data'; -import type { SemVer } from '../../../src/utils/helpers/version'; +import { FeaturesProvider } from '../../../src/utils/features'; +import { RoutesPrefixProvider } from '../../../src/utils/routesPrefix'; import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { const editDomainRedirects = vi.fn().mockResolvedValue(undefined); - const setUp = (domain?: Domain, selectedServer?: SelectedServer) => renderWithEvents( + const setUp = ({ domain, withVisits = true }: { domain?: Domain; withVisits?: boolean } = {}) => renderWithEvents( - + + + + + , ); it('renders expected menu items', () => { - setUp(); + setUp({ withVisits: false }); expect(screen.queryByText('Visit stats')).not.toBeInTheDocument(); expect(screen.getByText('Edit redirects')).toBeInTheDocument(); @@ -30,37 +33,17 @@ describe('', () => { [true, '_DEFAULT'], [false, ''], ])('points first link to the proper section', (isDefault, expectedLink) => { - setUp( - fromPartial({ domain: 'foo.com', isDefault }), - fromPartial({ version: '3.1.0', id: '123' }), - ); + setUp({ domain: fromPartial({ domain: 'foo.com', isDefault }) }); expect(screen.getByText('Visit stats')).toHaveAttribute('href', `/server/123/domain/foo.com${expectedLink}/visits`); }); - it.each([ - [true, '2.9.0' as SemVer, false], - [true, '2.10.0' as SemVer, true], - [false, '2.9.0' as SemVer, true], - ])('allows editing certain the domains', (isDefault, serverVersion, canBeEdited) => { - setUp( - fromPartial({ domain: 'foo.com', isDefault }), - fromPartial({ version: serverVersion, id: '123' }), - ); - - if (canBeEdited) { - expect(screen.getByText('Edit redirects')).not.toHaveAttribute('disabled'); - } else { - expect(screen.getByText('Edit redirects')).toHaveAttribute('disabled'); - } - }); - it.each([ ['foo.com'], ['bar.org'], ['baz.net'], ])('displays modal when editing redirects', async (domain) => { - const { user } = setUp(fromPartial({ domain, isDefault: false })); + const { user } = setUp({ domain: fromPartial({ domain, isDefault: false }) }); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('form')).not.toBeInTheDocument(); diff --git a/test/domains/helpers/DomainStatusIcon.test.tsx b/shlink-web-component/test/domains/helpers/DomainStatusIcon.test.tsx similarity index 100% rename from test/domains/helpers/DomainStatusIcon.test.tsx rename to shlink-web-component/test/domains/helpers/DomainStatusIcon.test.tsx diff --git a/test/domains/helpers/EditDomainRedirectsModal.test.tsx b/shlink-web-component/test/domains/helpers/EditDomainRedirectsModal.test.tsx similarity index 98% rename from test/domains/helpers/EditDomainRedirectsModal.test.tsx rename to shlink-web-component/test/domains/helpers/EditDomainRedirectsModal.test.tsx index 8173a8ce..2a505ad0 100644 --- a/test/domains/helpers/EditDomainRedirectsModal.test.tsx +++ b/shlink-web-component/test/domains/helpers/EditDomainRedirectsModal.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkDomain } from '../../../src/api/types'; +import type { ShlinkDomain } from '../../../src/api-contract'; import { EditDomainRedirectsModal } from '../../../src/domains/helpers/EditDomainRedirectsModal'; import { renderWithEvents } from '../../__helpers__/setUpTest'; diff --git a/test/domains/helpers/__snapshots__/DomainStatusIcon.test.tsx.snap b/shlink-web-component/test/domains/helpers/__snapshots__/DomainStatusIcon.test.tsx.snap similarity index 100% rename from test/domains/helpers/__snapshots__/DomainStatusIcon.test.tsx.snap rename to shlink-web-component/test/domains/helpers/__snapshots__/DomainStatusIcon.test.tsx.snap diff --git a/test/domains/reducers/domainRedirects.test.ts b/shlink-web-component/test/domains/reducers/domainRedirects.test.ts similarity index 87% rename from test/domains/reducers/domainRedirects.test.ts rename to shlink-web-component/test/domains/reducers/domainRedirects.test.ts index 996e8f4a..40004a5d 100644 --- a/test/domains/reducers/domainRedirects.test.ts +++ b/shlink-web-component/test/domains/reducers/domainRedirects.test.ts @@ -1,6 +1,5 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { ShlinkDomainRedirects } from '../../../src/api/types'; +import type { ShlinkApiClient, ShlinkDomainRedirects } from '../../../src/api-contract'; import { editDomainRedirects } from '../../../src/domains/reducers/domainRedirects'; describe('domainRedirectsReducer', () => { diff --git a/test/domains/reducers/domainsList.test.ts b/shlink-web-component/test/domains/reducers/domainsList.test.ts similarity index 75% rename from test/domains/reducers/domainsList.test.ts rename to shlink-web-component/test/domains/reducers/domainsList.test.ts index 95d3de2d..ed4afc89 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/shlink-web-component/test/domains/reducers/domainsList.test.ts @@ -1,8 +1,6 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { ShlinkDomainRedirects } from '../../../src/api/types'; -import { parseApiError } from '../../../src/api/utils'; -import type { ShlinkState } from '../../../src/container/types'; +import type { ShlinkApiClient, ShlinkDomainRedirects } from '../../../src/api-contract'; +import { parseApiError } from '../../../src/api-contract/utils'; import type { Domain } from '../../../src/domains/data'; import type { EditDomainRedirects } from '../../../src/domains/reducers/domainRedirects'; import { editDomainRedirects } from '../../../src/domains/reducers/domainRedirects'; @@ -17,35 +15,35 @@ describe('domainsListReducer', () => { const getState = vi.fn(); const listDomains = vi.fn(); const health = vi.fn(); - const buildShlinkApiClient = () => fromPartial({ listDomains, health }); + const apiClientFactory = () => fromPartial({ listDomains, health }); const filteredDomains: Domain[] = [ fromPartial({ domain: 'foo', status: 'validating' }), fromPartial({ domain: 'Boo', status: 'validating' }), ]; const domains: Domain[] = [...filteredDomains, fromPartial({ domain: 'bar', status: 'validating' })]; const error = { type: 'NOT_FOUND', status: 404 } as unknown as Error; - const editDomainRedirectsThunk = editDomainRedirects(buildShlinkApiClient); + const editDomainRedirectsThunk = editDomainRedirects(apiClientFactory); const { reducer, listDomains: listDomainsAction, checkDomainHealth, filterDomains } = domainsListReducerCreator( - buildShlinkApiClient, + apiClientFactory, editDomainRedirectsThunk, ); describe('reducer', () => { it('returns loading on LIST_DOMAINS_START', () => { - expect(reducer(undefined, listDomainsAction.pending(''))).toEqual( + expect(reducer(undefined, listDomainsAction.pending('', {}))).toEqual( { domains: [], filteredDomains: [], loading: true, error: false }, ); }); it('returns error on LIST_DOMAINS_ERROR', () => { - expect(reducer(undefined, listDomainsAction.rejected(error, ''))).toEqual( + expect(reducer(undefined, listDomainsAction.rejected(error, '', {}))).toEqual( { domains: [], filteredDomains: [], loading: false, error: true, errorData: parseApiError(error) }, ); }); it('returns domains on LIST_DOMAINS', () => { expect( - reducer(undefined, listDomainsAction.fulfilled({ domains }, '')), + reducer(undefined, listDomainsAction.fulfilled({ domains }, '', {})), ).toEqual({ domains, filteredDomains: domains, loading: false, error: false }); }); @@ -93,7 +91,7 @@ describe('domainsListReducer', () => { it('dispatches domains once loaded', async () => { listDomains.mockResolvedValue({ data: domains }); - await listDomainsAction()(dispatch, getState, {}); + await listDomainsAction({})(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -116,33 +114,13 @@ describe('domainsListReducer', () => { describe('checkDomainHealth', () => { const domain = 'example.com'; - it('dispatches invalid status when selected server does not have all required data', async () => { - getState.mockReturnValue(fromPartial({ - selectedServer: {}, - })); - - await checkDomainHealth(domain)(dispatch, getState, {}); - - expect(getState).toHaveBeenCalledTimes(1); - expect(health).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ - payload: { domain, status: 'invalid' }, - })); - }); - it('dispatches invalid status when health endpoint returns an error', async () => { - getState.mockReturnValue(fromPartial({ - selectedServer: { - url: 'https://myerver.com', - apiKey: '123', - }, - })); health.mockRejectedValue({}); await checkDomainHealth(domain)(dispatch, getState, {}); - expect(getState).toHaveBeenCalledTimes(1); expect(health).toHaveBeenCalledTimes(1); + expect(health).toHaveBeenCalledWith(domain); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: { domain, status: 'invalid' }, })); @@ -155,18 +133,12 @@ describe('domainsListReducer', () => { healthStatus, expectedStatus, ) => { - getState.mockReturnValue(fromPartial({ - selectedServer: { - url: 'https://myerver.com', - apiKey: '123', - }, - })); health.mockResolvedValue({ status: healthStatus }); await checkDomainHealth(domain)(dispatch, getState, {}); - expect(getState).toHaveBeenCalledTimes(1); expect(health).toHaveBeenCalledTimes(1); + expect(health).toHaveBeenCalledWith(domain); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: { domain, status: expectedStatus }, })); diff --git a/test/mercure/helpers/index.test.tsx b/shlink-web-component/test/mercure/helpers/index.test.tsx similarity index 100% rename from test/mercure/helpers/index.test.tsx rename to shlink-web-component/test/mercure/helpers/index.test.tsx diff --git a/test/mercure/reducers/mercureInfo.test.ts b/shlink-web-component/test/mercure/reducers/mercureInfo.test.ts similarity index 75% rename from test/mercure/reducers/mercureInfo.test.ts rename to shlink-web-component/test/mercure/reducers/mercureInfo.test.ts index 2e1f0b45..acf3441d 100644 --- a/test/mercure/reducers/mercureInfo.test.ts +++ b/shlink-web-component/test/mercure/reducers/mercureInfo.test.ts @@ -1,6 +1,6 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { GetState } from '../../../src/container/types'; +import type { Settings } from '../../../src'; +import type { ShlinkApiClient } from '../../../src/api-contract'; import { mercureInfoReducerCreator } from '../../../src/mercure/reducers/mercureInfo'; describe('mercureInfoReducer', () => { @@ -14,21 +14,21 @@ describe('mercureInfoReducer', () => { describe('reducer', () => { it('returns loading on GET_MERCURE_INFO_START', () => { - expect(reducer(undefined, loadMercureInfo.pending(''))).toEqual({ + expect(reducer(undefined, loadMercureInfo.pending('', {}))).toEqual({ loading: true, error: false, }); }); it('returns error on GET_MERCURE_INFO_ERROR', () => { - expect(reducer(undefined, loadMercureInfo.rejected(null, ''))).toEqual({ + expect(reducer(undefined, loadMercureInfo.rejected(null, '', {}))).toEqual({ loading: false, error: true, }); }); it('returns mercure info on GET_MERCURE_INFO', () => { - expect(reducer(undefined, loadMercureInfo.fulfilled(mercureInfo, ''))).toEqual( + expect(reducer(undefined, loadMercureInfo.fulfilled(mercureInfo, '', {}))).toEqual( expect.objectContaining({ ...mercureInfo, loading: false, error: false }), ); }); @@ -36,17 +36,15 @@ describe('mercureInfoReducer', () => { describe('loadMercureInfo', () => { const dispatch = vi.fn(); - const createGetStateMock = (enabled: boolean): GetState => vi.fn().mockReturnValue({ - settings: { - realTimeUpdates: { enabled }, - }, + const createSettings = (enabled: boolean): Settings => fromPartial({ + realTimeUpdates: { enabled }, }); it('dispatches error when real time updates are disabled', async () => { getMercureInfo.mockResolvedValue(mercureInfo); - const getState = createGetStateMock(false); + const settings = createSettings(false); - await loadMercureInfo()(dispatch, getState, {}); + await loadMercureInfo(settings)(dispatch, vi.fn(), {}); expect(getMercureInfo).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledTimes(2); @@ -57,9 +55,9 @@ describe('mercureInfoReducer', () => { it('calls API on success', async () => { getMercureInfo.mockResolvedValue(mercureInfo); - const getState = createGetStateMock(true); + const settings = createSettings(true); - await loadMercureInfo()(dispatch, getState, {}); + await loadMercureInfo(settings)(dispatch, vi.fn(), {}); expect(getMercureInfo).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); diff --git a/test/servers/Overview.test.tsx b/shlink-web-component/test/overview/Overview.test.tsx similarity index 64% rename from test/servers/Overview.test.tsx rename to shlink-web-component/test/overview/Overview.test.tsx index 9a8002eb..9b4cf5d2 100644 --- a/test/servers/Overview.test.tsx +++ b/shlink-web-component/test/overview/Overview.test.tsx @@ -2,8 +2,10 @@ import { screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router-dom'; import type { MercureInfo } from '../../src/mercure/reducers/mercureInfo'; -import { Overview as overviewCreator } from '../../src/servers/Overview'; +import { Overview as overviewCreator } from '../../src/overview/Overview'; import { prettify } from '../../src/utils/helpers/numbers'; +import { RoutesPrefixProvider } from '../../src/utils/routesPrefix'; +import { SettingsProvider } from '../../src/utils/settings'; import { renderWithEvents } from '../__helpers__/setUpTest'; describe('', () => { @@ -16,26 +18,28 @@ describe('', () => { const shortUrls = { pagination: { totalItems: 83710 }, }; - const serverId = '123'; + const routesPrefix = '/server/123'; const setUp = (loading = false, excludeBots = false) => renderWithEvents( - ({})} - settings={fromPartial({ visits: { excludeBots } })} - /> + + + ({})} + /> + + , ); @@ -75,12 +79,13 @@ describe('', () => { const links = screen.getAllByRole('link'); - expect(links).toHaveLength(5); - expect(links[0]).toHaveAttribute('href', `/server/${serverId}/orphan-visits`); - expect(links[1]).toHaveAttribute('href', `/server/${serverId}/list-short-urls/1`); - expect(links[2]).toHaveAttribute('href', `/server/${serverId}/manage-tags`); - expect(links[3]).toHaveAttribute('href', `/server/${serverId}/create-short-url`); - expect(links[4]).toHaveAttribute('href', `/server/${serverId}/list-short-urls/1`); + expect(links).toHaveLength(6); + expect(links[0]).toHaveAttribute('href', `${routesPrefix}/non-orphan-visits`); + expect(links[1]).toHaveAttribute('href', `${routesPrefix}/orphan-visits`); + expect(links[2]).toHaveAttribute('href', `${routesPrefix}/list-short-urls/1`); + expect(links[3]).toHaveAttribute('href', `${routesPrefix}/manage-tags`); + expect(links[4]).toHaveAttribute('href', `${routesPrefix}/create-short-url`); + expect(links[5]).toHaveAttribute('href', `${routesPrefix}/list-short-urls/1`); }); it.each([ diff --git a/test/servers/helpers/HighlightCard.test.tsx b/shlink-web-component/test/overview/helpers/HighlightCard.test.tsx similarity index 67% rename from test/servers/helpers/HighlightCard.test.tsx rename to shlink-web-component/test/overview/helpers/HighlightCard.test.tsx index ec478de1..3306b77e 100644 --- a/test/servers/helpers/HighlightCard.test.tsx +++ b/shlink-web-component/test/overview/helpers/HighlightCard.test.tsx @@ -1,27 +1,17 @@ import { screen, waitFor } from '@testing-library/react'; -import type { ReactNode } from 'react'; +import type { PropsWithChildren } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import type { HighlightCardProps } from '../../../src/servers/helpers/HighlightCard'; -import { HighlightCard } from '../../../src/servers/helpers/HighlightCard'; +import type { HighlightCardProps } from '../../../src/overview/helpers/HighlightCard'; +import { HighlightCard } from '../../../src/overview/helpers/HighlightCard'; import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { - const setUp = (props: HighlightCardProps & { children?: ReactNode }) => renderWithEvents( + const setUp = (props: PropsWithChildren>) => renderWithEvents( - + , ); - it.each([ - [undefined], - [''], - ])('does not render icon when there is no link', (link) => { - setUp({ title: 'foo', link }); - - expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument(); - expect(screen.queryByRole('link')).not.toBeInTheDocument(); - }); - it.each([ ['foo'], ['bar'], diff --git a/test/servers/helpers/VisitsHighlightCard.test.tsx b/shlink-web-component/test/overview/helpers/VisitsHighlightCard.test.tsx similarity index 81% rename from test/servers/helpers/VisitsHighlightCard.test.tsx rename to shlink-web-component/test/overview/helpers/VisitsHighlightCard.test.tsx index ede67601..1428de12 100644 --- a/test/servers/helpers/VisitsHighlightCard.test.tsx +++ b/shlink-web-component/test/overview/helpers/VisitsHighlightCard.test.tsx @@ -1,11 +1,21 @@ import { screen, waitFor } from '@testing-library/react'; -import type { VisitsHighlightCardProps } from '../../../src/servers/helpers/VisitsHighlightCard'; -import { VisitsHighlightCard } from '../../../src/servers/helpers/VisitsHighlightCard'; +import { MemoryRouter } from 'react-router'; +import type { VisitsHighlightCardProps } from '../../../src/overview/helpers/VisitsHighlightCard'; +import { VisitsHighlightCard } from '../../../src/overview/helpers/VisitsHighlightCard'; import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { const setUp = (props: Partial = {}) => renderWithEvents( - , + + + , ); it.each([ diff --git a/test/short-urls/CreateShortUrl.test.tsx b/shlink-web-component/test/short-urls/CreateShortUrl.test.tsx similarity index 75% rename from test/short-urls/CreateShortUrl.test.tsx rename to shlink-web-component/test/short-urls/CreateShortUrl.test.tsx index 8027f3b3..36eb835e 100644 --- a/test/short-urls/CreateShortUrl.test.tsx +++ b/shlink-web-component/test/short-urls/CreateShortUrl.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { CreateShortUrl as createShortUrlsCreator } from '../../src/short-urls/CreateShortUrl'; import type { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation'; +import { SettingsProvider } from '../../src/utils/settings'; describe('', () => { const ShortUrlForm = () => ShortUrlForm; @@ -11,13 +12,13 @@ describe('', () => { const createShortUrl = vi.fn(async () => Promise.resolve()); const CreateShortUrl = createShortUrlsCreator(ShortUrlForm, CreateShortUrlResult); const setUp = () => render( - {}} - settings={fromPartial({ shortUrlCreation })} - />, + + {}} + /> + , ); it('renders computed initial state', () => { diff --git a/test/short-urls/EditShortUrl.test.tsx b/shlink-web-component/test/short-urls/EditShortUrl.test.tsx similarity index 84% rename from test/short-urls/EditShortUrl.test.tsx rename to shlink-web-component/test/short-urls/EditShortUrl.test.tsx index 560f110e..05a0f619 100644 --- a/test/short-urls/EditShortUrl.test.tsx +++ b/shlink-web-component/test/short-urls/EditShortUrl.test.tsx @@ -4,20 +4,21 @@ import { MemoryRouter } from 'react-router-dom'; import { EditShortUrl as createEditShortUrl } from '../../src/short-urls/EditShortUrl'; import type { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail'; import type { ShortUrlEdition } from '../../src/short-urls/reducers/shortUrlEdition'; +import { SettingsProvider } from '../../src/utils/settings'; describe('', () => { const shortUrlCreation = { validateUrls: true }; const EditShortUrl = createEditShortUrl(() => ShortUrlForm); const setUp = (detail: Partial = {}, edition: Partial = {}) => render( - Promise.resolve())} - /> + + Promise.resolve())} + /> + , ); diff --git a/test/short-urls/Paginator.test.tsx b/shlink-web-component/test/short-urls/Paginator.test.tsx similarity index 92% rename from test/short-urls/Paginator.test.tsx rename to shlink-web-component/test/short-urls/Paginator.test.tsx index cde9462c..36921ede 100644 --- a/test/short-urls/Paginator.test.tsx +++ b/shlink-web-component/test/short-urls/Paginator.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router-dom'; -import type { ShlinkPaginator } from '../../src/api/types'; +import type { ShlinkPaginator } from '../../src/api-contract'; import { Paginator } from '../../src/short-urls/Paginator'; import { ELLIPSIS } from '../../src/utils/helpers/pagination'; @@ -9,7 +9,7 @@ describe('', () => { const buildPaginator = (pagesCount?: number) => fromPartial({ pagesCount, currentPage: 1 }); const setUp = (paginator?: ShlinkPaginator, currentQueryString?: string) => render( - + , ); diff --git a/test/short-urls/ShortUrlForm.test.tsx b/shlink-web-component/test/short-urls/ShortUrlForm.test.tsx similarity index 71% rename from test/short-urls/ShortUrlForm.test.tsx rename to shlink-web-component/test/short-urls/ShortUrlForm.test.tsx index 6fa942ef..41234ef3 100644 --- a/test/short-urls/ShortUrlForm.test.tsx +++ b/shlink-web-component/test/short-urls/ShortUrlForm.test.tsx @@ -2,25 +2,33 @@ import { screen } from '@testing-library/react'; import type { UserEvent } from '@testing-library/user-event/setup/setup'; import { fromPartial } from '@total-typescript/shoehorn'; import { formatISO } from 'date-fns'; -import type { ReachableServer, SelectedServer } from '../../src/servers/data'; import type { Mode } from '../../src/short-urls/ShortUrlForm'; import { ShortUrlForm as createShortUrlForm } from '../../src/short-urls/ShortUrlForm'; -import { parseDate } from '../../src/utils/helpers/date'; -import type { OptionalString } from '../../src/utils/utils'; +import { parseDate } from '../../src/utils/dates/helpers/date'; +import { FeaturesProvider } from '../../src/utils/features'; import { renderWithEvents } from '../__helpers__/setUpTest'; describe('', () => { const createShortUrl = vi.fn(async () => Promise.resolve()); const ShortUrlForm = createShortUrlForm(() => TagsSelector, () => DomainSelector); - const setUp = (selectedServer: SelectedServer = null, mode: Mode = 'create', title?: OptionalString) => + const setUp = (withDeviceLongUrls = false, mode: Mode = 'create', title?: string | null) => renderWithEvents( - , + + + , ); it.each([ @@ -29,14 +37,14 @@ describe('', () => { await user.type(screen.getByPlaceholderText('Custom slug'), 'my-slug'); }, { customSlug: 'my-slug' }, - null, + false, ], [ async (user: UserEvent) => { await user.type(screen.getByPlaceholderText('Short code length'), '15'); }, { shortCodeLength: '15' }, - null, + false, ], [ async (user: UserEvent) => { @@ -49,10 +57,10 @@ describe('', () => { ios: 'https://ios.com', }, }, - fromPartial({ version: '3.5.0' }), + true, ], - ])('saves short URL with data set in form controls', async (extraFields, extraExpectedValues, selectedServer) => { - const { user } = setUp(selectedServer); + ])('saves short URL with data set in form controls', async (extraFields, extraExpectedValues, withDeviceLongUrls) => { + const { user } = setUp(withDeviceLongUrls); const validSince = parseDate('2017-01-01', 'yyyy-MM-dd'); const validUntil = parseDate('2017-01-06', 'yyyy-MM-dd'); @@ -73,6 +81,9 @@ describe('', () => { maxVisits: 20, findIfExists: false, validateUrl: true, + domain: undefined, + shortCodeLength: undefined, + customSlug: undefined, ...extraExpectedValues, }); }); @@ -83,7 +94,7 @@ describe('', () => { ])( 'renders expected amount of cards based on server capabilities and mode', (mode, expectedAmountOfCards) => { - setUp(null, mode); + setUp(false, mode); const cards = screen.queryAllByRole('heading'); expect(cards).toHaveLength(expectedAmountOfCards); @@ -100,7 +111,7 @@ describe('', () => { [undefined, false, undefined], ['old title', false, null], ])('sends expected title based on original and new values', async (originalTitle, withNewTitle, expectedSentTitle) => { - const { user } = setUp(fromPartial({ version: '2.6.0' }), 'create', originalTitle); + const { user } = setUp(false, 'create', originalTitle); await user.type(screen.getByPlaceholderText('URL to be shortened'), 'https://long-domain.com/foo/bar'); await user.clear(screen.getByPlaceholderText('Title')); @@ -114,19 +125,10 @@ describe('', () => { })); }); - it.each([ - [fromPartial({ version: '3.0.0' }), false], - [fromPartial({ version: '3.4.0' }), false], - [fromPartial({ version: '3.5.0' }), true], - [fromPartial({ version: '3.6.0' }), true], - ])('shows device-specific long URLs only for servers supporting it', (selectedServer, fieldsExist) => { - setUp(selectedServer); - const placeholders = ['Android-specific redirection', 'iOS-specific redirection', 'Desktop-specific redirection']; + it('shows device-specific long URLs only when supported', () => { + setUp(true); - if (fieldsExist) { - placeholders.forEach((placeholder) => expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument()); - } else { - placeholders.forEach((placeholder) => expect(screen.queryByPlaceholderText(placeholder)).not.toBeInTheDocument()); - } + const placeholders = ['Android-specific redirection', 'iOS-specific redirection', 'Desktop-specific redirection']; + placeholders.forEach((placeholder) => expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument()); }); }); diff --git a/test/short-urls/ShortUrlsFilteringBar.test.tsx b/shlink-web-component/test/short-urls/ShortUrlsFilteringBar.test.tsx similarity index 78% rename from test/short-urls/ShortUrlsFilteringBar.test.tsx rename to shlink-web-component/test/short-urls/ShortUrlsFilteringBar.test.tsx index cec24dc7..a01cf169 100644 --- a/test/short-urls/ShortUrlsFilteringBar.test.tsx +++ b/shlink-web-component/test/short-urls/ShortUrlsFilteringBar.test.tsx @@ -2,10 +2,12 @@ import { screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { endOfDay, formatISO, startOfDay } from 'date-fns'; import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; -import type { ReachableServer, SelectedServer } from '../../src/servers/data'; import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar'; -import { formatDate } from '../../src/utils/helpers/date'; -import type { DateRange } from '../../src/utils/helpers/dateIntervals'; +import { formatIsoDate } from '../../src/utils/dates/helpers/date'; +import type { DateRange } from '../../src/utils/dates/helpers/dateIntervals'; +import { FeaturesProvider } from '../../src/utils/features'; +import { RoutesPrefixProvider } from '../../src/utils/routesPrefix'; +import { SettingsProvider } from '../../src/utils/settings'; import { renderWithEvents } from '../__helpers__/setUpTest'; vi.mock('react-router-dom', async () => ({ @@ -20,18 +22,19 @@ describe('', () => { const navigate = vi.fn(); const handleOrderBy = vi.fn(); const now = new Date(); - const setUp = (search = '', selectedServer?: SelectedServer) => { + const setUp = (search = '', filterDisabledUrls = true) => { (useLocation as any).mockReturnValue({ search }); (useNavigate as any).mockReturnValue(navigate); return renderWithEvents( - + + + + + + + , ); }; @@ -65,22 +68,20 @@ describe('', () => { expect(await screen.findByRole('menu')).toBeInTheDocument(); expect(navigate).not.toHaveBeenCalled(); - dates.startDate && await user.type(screen.getByPlaceholderText('Since...'), formatDate()(dates.startDate) ?? ''); - dates.endDate && await user.type(screen.getByPlaceholderText('Until...'), formatDate()(dates.endDate) ?? ''); + dates.startDate && await user.type(screen.getByPlaceholderText('Since...'), formatIsoDate(dates.startDate) ?? ''); + dates.endDate && await user.type(screen.getByPlaceholderText('Until...'), formatIsoDate(dates.endDate) ?? ''); expect(navigate).toHaveBeenLastCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`); }); it.each([ - ['tags=foo,bar,baz', fromPartial({ version: '3.0.0' }), true], - ['tags=foo,bar', fromPartial({ version: '3.1.0' }), true], - ['tags=foo', fromPartial({ version: '3.0.0' }), false], - ['', fromPartial({ version: '3.0.0' }), false], - ['tags=foo,bar,baz', fromPartial({ version: '2.10.0' }), false], - ['', fromPartial({ version: '2.10.0' }), false], + { search: 'tags=foo,bar,baz', shouldHaveComponent: true }, + { search: 'tags=foo,bar', shouldHaveComponent: true }, + { search: 'tags=foo', shouldHaveComponent: false }, + { search: '', shouldHaveComponent: false }, ])( - 'renders tags mode toggle if the server supports it and there is more than one tag selected', - (search, selectedServer, shouldHaveComponent) => { - setUp(search, selectedServer); + 'renders tags mode toggle if there is more than one tag selected', + ({ search, shouldHaveComponent }) => { + setUp(search); if (shouldHaveComponent) { expect(screen.getByLabelText('Change tags mode')).toBeInTheDocument(); @@ -95,7 +96,7 @@ describe('', () => { ['&tagsMode=all', 'With all the tags.'], ['&tagsMode=any', 'With any of the tags.'], ])('expected tags mode tooltip title', async (initialTagsMode, expectedToggleText) => { - const { user } = setUp(`tags=foo,bar${initialTagsMode}`, fromPartial({ version: '3.0.0' })); + const { user } = setUp(`tags=foo,bar${initialTagsMode}`, true); await user.hover(screen.getByLabelText('Change tags mode')); expect(await screen.findByRole('tooltip')).toHaveTextContent(expectedToggleText); @@ -106,7 +107,7 @@ describe('', () => { ['&tagsMode=all', 'tagsMode=any'], ['&tagsMode=any', 'tagsMode=all'], ])('redirects to first page when tags mode changes', async (initialTagsMode, expectedRedirectTagsMode) => { - const { user } = setUp(`tags=foo,bar${initialTagsMode}`, fromPartial({ version: '3.0.0' })); + const { user } = setUp(`tags=foo,bar${initialTagsMode}`, true); expect(navigate).not.toHaveBeenCalled(); await user.click(screen.getByLabelText('Change tags mode')); @@ -124,7 +125,7 @@ describe('', () => { ['excludePastValidUntil=false', /Exclude enabled in the past/, 'excludePastValidUntil=true'], ['excludePastValidUntil=true', /Exclude enabled in the past/, 'excludePastValidUntil=false'], ])('allows to toggle filters through filtering dropdown', async (search, menuItemName, expectedQuery) => { - const { user } = setUp(search, fromPartial({ version: '3.4.0' })); + const { user } = setUp(search, true); const toggleFilter = async (name: RegExp) => { await user.click(screen.getByRole('button', { name: 'Filters' })); await waitFor(() => screen.findByRole('menu')); diff --git a/test/short-urls/ShortUrlsList.test.tsx b/shlink-web-component/test/short-urls/ShortUrlsList.test.tsx similarity index 78% rename from test/short-urls/ShortUrlsList.test.tsx rename to shlink-web-component/test/short-urls/ShortUrlsList.test.tsx index dd543635..f299c6ec 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/shlink-web-component/test/short-urls/ShortUrlsList.test.tsx @@ -1,13 +1,14 @@ import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter, useNavigate } from 'react-router-dom'; +import type { Settings } from '../../src'; import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; -import type { Settings } from '../../src/settings/reducers/settings'; import type { ShortUrlsOrder } from '../../src/short-urls/data'; import type { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; import { ShortUrlsList as createShortUrlsList } from '../../src/short-urls/ShortUrlsList'; import type { ShortUrlsTableType } from '../../src/short-urls/ShortUrlsTable'; -import type { SemVer } from '../../src/utils/helpers/version'; +import { FeaturesProvider } from '../../src/utils/features'; +import { SettingsProvider } from '../../src/utils/settings'; import { renderWithEvents } from '../__helpers__/setUpTest'; vi.mock('react-router-dom', async () => ({ @@ -35,15 +36,17 @@ describe('', () => { }, }); const ShortUrlsList = createShortUrlsList(ShortUrlsTable, ShortUrlsFilteringBar); - const setUp = (settings: Partial = {}, version: SemVer = '3.0.0') => renderWithEvents( + const setUp = (settings: Partial = {}, excludeBotsOnShortUrls = true) => renderWithEvents( - ({ mercureInfo: { loading: true } })} - listShortUrls={listShortUrlsMock} - shortUrlsList={shortUrlsList} - selectedServer={fromPartial({ id: '1', version })} - settings={fromPartial(settings)} - /> + + + ({ mercureInfo: { loading: true } })} + listShortUrls={listShortUrlsMock} + shortUrlsList={shortUrlsList} + /> + + , ); @@ -93,26 +96,26 @@ describe('', () => { shortUrlsList: { defaultOrdering: { field: 'visits', dir: 'ASC' }, }, - }), '3.3.0' as SemVer, { field: 'visits', dir: 'ASC' }], + }), false, { field: 'visits', dir: 'ASC' }], [fromPartial({ shortUrlsList: { defaultOrdering: { field: 'visits', dir: 'ASC' }, }, visits: { excludeBots: true }, - }), '3.3.0' as SemVer, { field: 'visits', dir: 'ASC' }], + }), false, { field: 'visits', dir: 'ASC' }], [fromPartial({ shortUrlsList: { defaultOrdering: { field: 'visits', dir: 'ASC' }, }, - }), '3.4.0' as SemVer, { field: 'visits', dir: 'ASC' }], + }), true, { field: 'visits', dir: 'ASC' }], [fromPartial({ shortUrlsList: { defaultOrdering: { field: 'visits', dir: 'ASC' }, }, visits: { excludeBots: true }, - }), '3.4.0' as SemVer, { field: 'nonBotVisits', dir: 'ASC' }], - ])('parses order by based on server version and config', (settings, serverVersion, expectedOrderBy) => { - setUp(settings, serverVersion); + }), true, { field: 'nonBotVisits', dir: 'ASC' }], + ])('parses order by based on supported features version and config', (settings, excludeBotsOnShortUrls, expectedOrderBy) => { + setUp(settings, excludeBotsOnShortUrls); expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({ orderBy: expectedOrderBy })); }); }); diff --git a/test/short-urls/ShortUrlsTable.test.tsx b/shlink-web-component/test/short-urls/ShortUrlsTable.test.tsx similarity index 88% rename from test/short-urls/ShortUrlsTable.test.tsx rename to shlink-web-component/test/short-urls/ShortUrlsTable.test.tsx index 27a984c5..7fc32db0 100644 --- a/test/short-urls/ShortUrlsTable.test.tsx +++ b/shlink-web-component/test/short-urls/ShortUrlsTable.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { SelectedServer } from '../../src/servers/data'; import type { ShortUrlsOrderableFields } from '../../src/short-urls/data'; import { SHORT_URLS_ORDERABLE_FIELDS } from '../../src/short-urls/data'; import type { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList'; @@ -11,8 +10,8 @@ describe('', () => { const shortUrlsList = fromPartial({}); const orderByColumn = vi.fn(); const ShortUrlsTable = shortUrlsTableCreator(() => ShortUrlsRow); - const setUp = (server: SelectedServer = null) => renderWithEvents( - orderByColumn} />, + const setUp = () => renderWithEvents( + orderByColumn} />, ); it('should render inner table by default', () => { @@ -54,7 +53,7 @@ describe('', () => { }); it('should render composed title column', () => { - setUp(fromPartial({ version: '2.0.0' })); + setUp(); const { innerHTML } = screen.getAllByRole('columnheader')[2]; diff --git a/test/short-urls/UseExistingIfFoundInfoIcon.test.tsx b/shlink-web-component/test/short-urls/UseExistingIfFoundInfoIcon.test.tsx similarity index 100% rename from test/short-urls/UseExistingIfFoundInfoIcon.test.tsx rename to shlink-web-component/test/short-urls/UseExistingIfFoundInfoIcon.test.tsx diff --git a/test/short-urls/helpers/CreateShortUrlResult.test.tsx b/shlink-web-component/test/short-urls/helpers/CreateShortUrlResult.test.tsx similarity index 100% rename from test/short-urls/helpers/CreateShortUrlResult.test.tsx rename to shlink-web-component/test/short-urls/helpers/CreateShortUrlResult.test.tsx diff --git a/test/short-urls/helpers/DeleteShortUrlModal.test.tsx b/shlink-web-component/test/short-urls/helpers/DeleteShortUrlModal.test.tsx similarity index 92% rename from test/short-urls/helpers/DeleteShortUrlModal.test.tsx rename to shlink-web-component/test/short-urls/helpers/DeleteShortUrlModal.test.tsx index c93d094a..448d46bb 100644 --- a/test/short-urls/helpers/DeleteShortUrlModal.test.tsx +++ b/shlink-web-component/test/short-urls/helpers/DeleteShortUrlModal.test.tsx @@ -1,15 +1,14 @@ import { screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { InvalidShortUrlDeletion } from '../../../src/api/types/errors'; -import { ErrorTypeV2, ErrorTypeV3 } from '../../../src/api/types/errors'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { InvalidShortUrlDeletion, ShlinkShortUrl } from '../../../src/api-contract'; +import { ErrorTypeV2, ErrorTypeV3 } from '../../../src/api-contract'; import { DeleteShortUrlModal } from '../../../src/short-urls/helpers/DeleteShortUrlModal'; import type { ShortUrlDeletion } from '../../../src/short-urls/reducers/shortUrlDeletion'; import { renderWithEvents } from '../../__helpers__/setUpTest'; import { TestModalWrapper } from '../../__helpers__/TestModalWrapper'; describe('', () => { - const shortUrl = fromPartial({ + const shortUrl = fromPartial({ tags: [], shortCode: 'abc123', longUrl: 'https://long-domain.com/foo/bar', diff --git a/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx b/shlink-web-component/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx similarity index 66% rename from test/short-urls/helpers/ExportShortUrlsBtn.test.tsx rename to shlink-web-component/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx index 66d74e81..c4f21519 100644 --- a/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx +++ b/shlink-web-component/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx @@ -1,10 +1,9 @@ import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router-dom'; -import type { ReportExporter } from '../../../src/common/services/ReportExporter'; -import type { NotFoundServer, SelectedServer } from '../../../src/servers/data'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { ShlinkShortUrl } from '../../../src/api-contract'; import { ExportShortUrlsBtn as createExportShortUrlsBtn } from '../../../src/short-urls/helpers/ExportShortUrlsBtn'; +import type { ReportExporter } from '../../../src/utils/services/ReportExporter'; import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { @@ -13,9 +12,9 @@ describe('', () => { const exportShortUrls = vi.fn(); const reportExporter = fromPartial({ exportShortUrls }); const ExportShortUrlsBtn = createExportShortUrlsBtn(buildShlinkApiClient, reportExporter); - const setUp = (amount?: number, selectedServer?: SelectedServer) => renderWithEvents( + const setUp = (amount?: number) => renderWithEvents( - + , ); @@ -28,17 +27,6 @@ describe('', () => { expect(screen.getByText(/Export/)).toHaveTextContent(`Export (${expectedAmount})`); }); - it.each([ - [null], - [fromPartial({})], - ])('does nothing on click if selected server is not reachable', async (selectedServer) => { - const { user } = setUp(0, selectedServer); - - await user.click(screen.getByRole('button')); - expect(listShortUrls).not.toHaveBeenCalled(); - expect(exportShortUrls).not.toHaveBeenCalled(); - }); - it.each([ [10, 1], [30, 2], @@ -48,7 +36,7 @@ describe('', () => { [385, 20], ])('loads proper amount of pages based on the amount of results', async (amount, expectedPageLoads) => { listShortUrls.mockResolvedValue({ data: [] }); - const { user } = setUp(amount, fromPartial({ id: '123' })); + const { user } = setUp(amount); await user.click(screen.getByRole('button')); @@ -58,12 +46,12 @@ describe('', () => { it('maps short URLs for exporting', async () => { listShortUrls.mockResolvedValue({ - data: [fromPartial({ + data: [fromPartial({ shortUrl: 'https://s.test/short-code', tags: [], })], }); - const { user } = setUp(undefined, fromPartial({ id: '123' })); + const { user } = setUp(); await user.click(screen.getByRole('button')); diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/shlink-web-component/test/short-urls/helpers/QrCodeModal.test.tsx similarity index 86% rename from test/short-urls/helpers/QrCodeModal.test.tsx rename to shlink-web-component/test/short-urls/helpers/QrCodeModal.test.tsx index f80873a8..2e54ba47 100644 --- a/test/short-urls/helpers/QrCodeModal.test.tsx +++ b/shlink-web-component/test/short-urls/helpers/QrCodeModal.test.tsx @@ -1,18 +1,16 @@ import { fireEvent, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { QrCodeModal as createQrCodeModal } from '../../../src/short-urls/helpers/QrCodeModal'; -import type { SemVer } from '../../../src/utils/helpers/version'; import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { const saveImage = vi.fn().mockReturnValue(Promise.resolve()); const QrCodeModal = createQrCodeModal(fromPartial({ saveImage })); const shortUrl = 'https://s.test/abc123'; - const setUp = (version: SemVer = '2.8.0') => renderWithEvents( + const setUp = () => renderWithEvents( {}} />, ); @@ -63,16 +61,14 @@ describe('', () => { }); it('shows expected components based on server version', () => { - const { container } = setUp(); + setUp(); const dropdowns = screen.getAllByRole('button'); - const firstCol = container.parentNode?.querySelectorAll('.d-grid').item(0); - expect(dropdowns).toHaveLength(2 + 1); // Add one because of the close button - expect(firstCol).toHaveClass('col-md-4'); + expect(dropdowns).toHaveLength(2 + 2); // Add two because of the close and download buttons }); it('saves the QR code image when clicking the Download button', async () => { - const { user } = setUp('2.9.0'); + const { user } = setUp(); expect(saveImage).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: /^Download/ })); diff --git a/shlink-web-component/test/short-urls/helpers/ShortUrlDetailLink.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlDetailLink.test.tsx new file mode 100644 index 00000000..0ad7f412 --- /dev/null +++ b/shlink-web-component/test/short-urls/helpers/ShortUrlDetailLink.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { MemoryRouter } from 'react-router-dom'; +import type { ShlinkShortUrl } from '../../../src/api-contract'; +import type { LinkSuffix } from '../../../src/short-urls/helpers/ShortUrlDetailLink'; +import { ShortUrlDetailLink } from '../../../src/short-urls/helpers/ShortUrlDetailLink'; +import { RoutesPrefixProvider } from '../../../src/utils/routesPrefix'; + +describe('', () => { + it.each([ + [false, undefined], + [false, null], + [true, null], + [true, undefined], + [false, fromPartial({})], + [false, fromPartial({})], + ])('only renders a plain span when either server or short URL are not set', (asLink, shortUrl) => { + render( + + Something + , + ); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.getByText('Something')).toBeInTheDocument(); + }); + + it.each([ + [ + '/server/1', + fromPartial({ shortCode: 'abc123' }), + 'visits' as LinkSuffix, + '/server/1/short-code/abc123/visits', + ], + [ + '/foobar', + fromPartial({ shortCode: 'def456', domain: 'example.com' }), + 'visits' as LinkSuffix, + '/foobar/short-code/def456/visits?domain=example.com', + ], + [ + '/server/1', + fromPartial({ shortCode: 'abc123' }), + 'edit' as LinkSuffix, + '/server/1/short-code/abc123/edit', + ], + [ + '/server/3', + fromPartial({ shortCode: 'def456', domain: 'example.com' }), + 'edit' as LinkSuffix, + '/server/3/short-code/def456/edit?domain=example.com', + ], + ])('renders link with expected query when', (routesPrefix, shortUrl, suffix, expectedLink) => { + render( + + + + Something + + + , + ); + expect(screen.getByRole('link')).toHaveProperty('href', expect.stringContaining(expectedLink)); + }); +}); diff --git a/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx similarity index 100% rename from test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx rename to shlink-web-component/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx diff --git a/test/short-urls/helpers/ShortUrlStatus.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlStatus.test.tsx similarity index 65% rename from test/short-urls/helpers/ShortUrlStatus.test.tsx rename to shlink-web-component/test/short-urls/helpers/ShortUrlStatus.test.tsx index 7755bfc8..40ced0af 100644 --- a/test/short-urls/helpers/ShortUrlStatus.test.tsx +++ b/shlink-web-component/test/short-urls/helpers/ShortUrlStatus.test.tsx @@ -1,42 +1,41 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkVisitsSummary } from '../../../src/api/types'; -import type { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data'; +import type { ShlinkShortUrl, ShlinkShortUrlMeta, ShlinkVisitsSummary } from '../../../src/api-contract'; import { ShortUrlStatus } from '../../../src/short-urls/helpers/ShortUrlStatus'; describe('', () => { - const setUp = (shortUrl: ShortUrl) => ({ + const setUp = (shortUrl: ShlinkShortUrl) => ({ user: userEvent.setup(), ...render(), }); it.each([ [ - fromPartial({ validSince: '2099-01-01T10:30:15' }), + fromPartial({ validSince: '2099-01-01T10:30:15' }), {}, 'This short URL will start working on 2099-01-01 10:30.', ], [ - fromPartial({ validUntil: '2020-01-01T10:30:15' }), + fromPartial({ validUntil: '2020-01-01T10:30:15' }), {}, 'This short URL cannot be visited since 2020-01-01 10:30.', ], [ - fromPartial({ maxVisits: 10 }), + fromPartial({ maxVisits: 10 }), fromPartial({ total: 10 }), 'This short URL cannot be currently visited because it has reached the maximum amount of 10 visits.', ], [ - fromPartial({ maxVisits: 1 }), + fromPartial({ maxVisits: 1 }), fromPartial({ total: 1 }), 'This short URL cannot be currently visited because it has reached the maximum amount of 1 visit.', ], [{}, {}, 'This short URL can be visited normally.'], - [fromPartial({ validUntil: '2099-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'], - [fromPartial({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'], + [fromPartial({ validUntil: '2099-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'], + [fromPartial({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'], [ - fromPartial({ maxVisits: 10 }), + fromPartial({ maxVisits: 10 }), fromPartial({ total: 1 }), 'This short URL can be visited normally.', ], diff --git a/test/short-urls/helpers/ShortUrlVisitsCount.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlVisitsCount.test.tsx similarity index 94% rename from test/short-urls/helpers/ShortUrlVisitsCount.test.tsx rename to shlink-web-component/test/short-urls/helpers/ShortUrlVisitsCount.test.tsx index b810fa6e..2c0c5517 100644 --- a/test/short-urls/helpers/ShortUrlVisitsCount.test.tsx +++ b/shlink-web-component/test/short-urls/helpers/ShortUrlVisitsCount.test.tsx @@ -1,11 +1,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { ShlinkShortUrl } from '../../../src/api-contract'; import { ShortUrlVisitsCount } from '../../../src/short-urls/helpers/ShortUrlVisitsCount'; describe('', () => { - const setUp = (visitsCount: number, shortUrl: ShortUrl) => ({ + const setUp = (visitsCount: number, shortUrl: ShlinkShortUrl) => ({ user: userEvent.setup(), ...render( , diff --git a/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx similarity index 100% rename from test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx rename to shlink-web-component/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx diff --git a/test/short-urls/helpers/ShortUrlsRow.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlsRow.test.tsx similarity index 87% rename from test/short-urls/helpers/ShortUrlsRow.test.tsx rename to shlink-web-component/test/short-urls/helpers/ShortUrlsRow.test.tsx index 5c9297a5..0331e2db 100644 --- a/test/short-urls/helpers/ShortUrlsRow.test.tsx +++ b/shlink-web-component/test/short-urls/helpers/ShortUrlsRow.test.tsx @@ -3,20 +3,19 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { addDays, formatISO, subDays } from 'date-fns'; import { last } from 'ramda'; import { MemoryRouter, useLocation } from 'react-router-dom'; -import type { ReachableServer } from '../../../src/servers/data'; -import type { Settings } from '../../../src/settings/reducers/settings'; -import type { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data'; +import type { Settings } from '../../../src'; +import type { ShlinkShortUrl, ShlinkShortUrlMeta } from '../../../src/api-contract'; import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow'; -import { now, parseDate } from '../../../src/utils/helpers/date'; +import { now, parseDate } from '../../../src/utils/dates/helpers/date'; import type { TimeoutToggle } from '../../../src/utils/helpers/hooks'; -import type { OptionalString } from '../../../src/utils/utils'; +import { SettingsProvider } from '../../../src/utils/settings'; import { renderWithEvents } from '../../__helpers__/setUpTest'; import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock'; interface SetUpOptions { - title?: OptionalString; + title?: string | null; tags?: string[]; - meta?: ShortUrlMeta; + meta?: ShlinkShortUrlMeta; settings?: Partial; } @@ -28,8 +27,7 @@ vi.mock('react-router-dom', async () => ({ describe('', () => { const timeoutToggle = vi.fn(() => true); const useTimeoutToggle = vi.fn(() => [false, timeoutToggle]) as TimeoutToggle; - const server = fromPartial({ url: 'https://s.test' }); - const shortUrl: ShortUrl = { + const shortUrl: ShlinkShortUrl = { shortCode: 'abc123', shortUrl: 'https://s.test/abc123', longUrl: 'https://foo.com/bar', @@ -54,16 +52,16 @@ describe('', () => { (useLocation as any).mockReturnValue({ search }); return renderWithEvents( - - - null} - settings={fromPartial(settings)} - /> - -
+ + + + null} + /> + +
+
, ); }; diff --git a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx b/shlink-web-component/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx similarity index 76% rename from test/short-urls/helpers/ShortUrlsRowMenu.test.tsx rename to shlink-web-component/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx index c78ec61b..413369c7 100644 --- a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx +++ b/shlink-web-component/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx @@ -1,21 +1,19 @@ import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router-dom'; -import type { ReachableServer } from '../../../src/servers/data'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { ShlinkShortUrl } from '../../../src/api-contract'; import { ShortUrlsRowMenu as createShortUrlsRowMenu } from '../../../src/short-urls/helpers/ShortUrlsRowMenu'; import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { const ShortUrlsRowMenu = createShortUrlsRowMenu(() => DeleteShortUrlModal, () => QrCodeModal); - const selectedServer = fromPartial({ id: 'abc123' }); - const shortUrl = fromPartial({ + const shortUrl = fromPartial({ shortCode: 'abc123', shortUrl: 'https://s.test/abc123', }); const setUp = () => renderWithEvents( - + , ); diff --git a/test/short-urls/helpers/Tags.test.tsx b/shlink-web-component/test/short-urls/helpers/Tags.test.tsx similarity index 100% rename from test/short-urls/helpers/Tags.test.tsx rename to shlink-web-component/test/short-urls/helpers/Tags.test.tsx diff --git a/test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap b/shlink-web-component/test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap similarity index 100% rename from test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap rename to shlink-web-component/test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap diff --git a/test/short-urls/helpers/index.test.ts b/shlink-web-component/test/short-urls/helpers/index.test.ts similarity index 92% rename from test/short-urls/helpers/index.test.ts rename to shlink-web-component/test/short-urls/helpers/index.test.ts index 6f252950..ff3c1344 100644 --- a/test/short-urls/helpers/index.test.ts +++ b/shlink-web-component/test/short-urls/helpers/index.test.ts @@ -1,5 +1,5 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { ShlinkShortUrl } from '../../../src/api-contract'; import { shortUrlDataFromShortUrl, urlDecodeShortCode, urlEncodeShortCode } from '../../../src/short-urls/helpers'; describe('helpers', () => { @@ -8,7 +8,7 @@ describe('helpers', () => { [undefined, { validateUrls: true }, { longUrl: '', validateUrl: true }], [undefined, undefined, { longUrl: '', validateUrl: false }], [ - fromPartial({ meta: {} }), + fromPartial({ meta: {} }), { validateUrls: false }, { longUrl: undefined, diff --git a/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx b/shlink-web-component/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx similarity index 100% rename from test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx rename to shlink-web-component/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx diff --git a/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx b/shlink-web-component/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx similarity index 100% rename from test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx rename to shlink-web-component/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx diff --git a/test/short-urls/reducers/shortUrlCreation.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlCreation.test.ts similarity index 83% rename from test/short-urls/reducers/shortUrlCreation.test.ts rename to shlink-web-component/test/short-urls/reducers/shortUrlCreation.test.ts index da9c38bf..c547f623 100644 --- a/test/short-urls/reducers/shortUrlCreation.test.ts +++ b/shlink-web-component/test/short-urls/reducers/shortUrlCreation.test.ts @@ -1,14 +1,12 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { ShlinkState } from '../../../src/container/types'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { ShlinkApiClient, ShlinkShortUrl } from '../../../src/api-contract'; import { createShortUrl as createShortUrlCreator, shortUrlCreationReducerCreator, } from '../../../src/short-urls/reducers/shortUrlCreation'; describe('shortUrlCreationReducer', () => { - const shortUrl = fromPartial({}); + const shortUrl = fromPartial({}); const createShortUrlCall = vi.fn(); const buildShlinkApiClient = () => fromPartial({ createShortUrl: createShortUrlCall }); const createShortUrl = createShortUrlCreator(buildShlinkApiClient); @@ -51,11 +49,10 @@ describe('shortUrlCreationReducer', () => { describe('createShortUrl', () => { const dispatch = vi.fn(); - const getState = () => fromPartial({}); it('calls API on success', async () => { createShortUrlCall.mockResolvedValue(shortUrl); - await createShortUrl({ longUrl: 'foo' })(dispatch, getState, {}); + await createShortUrl({ longUrl: 'foo' })(dispatch, vi.fn(), {}); expect(createShortUrlCall).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); diff --git a/test/short-urls/reducers/shortUrlDeletion.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlDeletion.test.ts similarity index 94% rename from test/short-urls/reducers/shortUrlDeletion.test.ts rename to shlink-web-component/test/short-urls/reducers/shortUrlDeletion.test.ts index 3c957d25..f31b11b0 100644 --- a/test/short-urls/reducers/shortUrlDeletion.test.ts +++ b/shlink-web-component/test/short-urls/reducers/shortUrlDeletion.test.ts @@ -1,6 +1,5 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { ProblemDetailsError } from '../../../src/api/types/errors'; +import type { ProblemDetailsError, ShlinkApiClient } from '../../../src/api-contract'; import { deleteShortUrl as deleteShortUrlCreator, shortUrlDeletionReducerCreator, diff --git a/test/short-urls/reducers/shortUrlDetail.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlDetail.test.ts similarity index 83% rename from test/short-urls/reducers/shortUrlDetail.test.ts rename to shlink-web-component/test/short-urls/reducers/shortUrlDetail.test.ts index 34817e5d..ce7afc60 100644 --- a/test/short-urls/reducers/shortUrlDetail.test.ts +++ b/shlink-web-component/test/short-urls/reducers/shortUrlDetail.test.ts @@ -1,7 +1,6 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { ShlinkState } from '../../../src/container/types'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { ShlinkApiClient, ShlinkShortUrl } from '../../../src/api-contract'; +import type { RootState } from '../../../src/container/store'; import { shortUrlDetailReducerCreator } from '../../../src/short-urls/reducers/shortUrlDetail'; import type { ShortUrlsList } from '../../../src/short-urls/reducers/shortUrlsList'; @@ -25,7 +24,7 @@ describe('shortUrlDetailReducer', () => { }); it('return short URL on GET_SHORT_URL_DETAIL', () => { - const actionShortUrl = fromPartial({ longUrl: 'foo', shortCode: 'bar' }); + const actionShortUrl = fromPartial({ longUrl: 'foo', shortCode: 'bar' }); const state = reducer( { loading: true, error: false }, getShortUrlDetail.fulfilled(actionShortUrl, '', { shortCode: '' }), @@ -40,7 +39,7 @@ describe('shortUrlDetailReducer', () => { describe('getShortUrlDetail', () => { const dispatchMock = vi.fn(); - const buildGetState = (shortUrlsList?: ShortUrlsList) => () => fromPartial({ shortUrlsList }); + const buildGetState = (shortUrlsList?: ShortUrlsList) => () => fromPartial({ shortUrlsList }); it.each([ [undefined], @@ -58,7 +57,7 @@ describe('shortUrlDetailReducer', () => { }), ], ])('performs API call when short URL is not found in local state', async (shortUrlsList?: ShortUrlsList) => { - const resolvedShortUrl = fromPartial({ longUrl: 'foo', shortCode: 'abc123' }); + const resolvedShortUrl = fromPartial({ longUrl: 'foo', shortCode: 'abc123' }); getShortUrlCall.mockResolvedValue(resolvedShortUrl); await getShortUrlDetail({ shortCode: 'abc123', domain: '' })(dispatchMock, buildGetState(shortUrlsList), {}); @@ -69,8 +68,8 @@ describe('shortUrlDetailReducer', () => { }); it('avoids API calls when short URL is found in local state', async () => { - const foundShortUrl = fromPartial({ longUrl: 'foo', shortCode: 'abc123' }); - getShortUrlCall.mockResolvedValue(fromPartial({})); + const foundShortUrl = fromPartial({ longUrl: 'foo', shortCode: 'abc123' }); + getShortUrlCall.mockResolvedValue(fromPartial({})); await getShortUrlDetail(foundShortUrl)( dispatchMock, diff --git a/test/short-urls/reducers/shortUrlEdition.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlEdition.test.ts similarity index 82% rename from test/short-urls/reducers/shortUrlEdition.test.ts rename to shlink-web-component/test/short-urls/reducers/shortUrlEdition.test.ts index 7adcd813..96cfa681 100644 --- a/test/short-urls/reducers/shortUrlEdition.test.ts +++ b/shlink-web-component/test/short-urls/reducers/shortUrlEdition.test.ts @@ -1,7 +1,5 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkState } from '../../../src/container/types'; -import type { SelectedServer } from '../../../src/servers/data'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { ShlinkShortUrl } from '../../../src/api-contract'; import { editShortUrl as editShortUrlCreator, shortUrlEditionReducerCreator, @@ -10,7 +8,7 @@ import { describe('shortUrlEditionReducer', () => { const longUrl = 'https://shlink.io'; const shortCode = 'abc123'; - const shortUrl = fromPartial({ longUrl, shortCode }); + const shortUrl = fromPartial({ longUrl, shortCode }); const updateShortUrl = vi.fn().mockResolvedValue(shortUrl); const buildShlinkApiClient = vi.fn().mockReturnValue({ updateShortUrl }); const editShortUrl = editShortUrlCreator(buildShlinkApiClient); @@ -45,12 +43,9 @@ describe('shortUrlEditionReducer', () => { describe('editShortUrl', () => { const dispatch = vi.fn(); - const createGetState = (selectedServer: SelectedServer = null) => () => fromPartial({ - selectedServer, - }); it.each([[undefined], [null], ['example.com']])('dispatches short URL on success', async (domain) => { - await editShortUrl({ shortCode, domain, data: { longUrl } })(dispatch, createGetState(), {}); + await editShortUrl({ shortCode, domain, data: { longUrl } })(dispatch, vi.fn(), {}); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(updateShortUrl).toHaveBeenCalledTimes(1); diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/shlink-web-component/test/short-urls/reducers/shortUrlsList.test.ts similarity index 78% rename from test/short-urls/reducers/shortUrlsList.test.ts rename to shlink-web-component/test/short-urls/reducers/shortUrlsList.test.ts index abcac9ec..a758ab50 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/shlink-web-component/test/short-urls/reducers/shortUrlsList.test.ts @@ -1,7 +1,5 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { ShlinkShortUrlsResponse } from '../../../src/api/types'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { ShlinkApiClient, ShlinkShortUrl, ShlinkShortUrlsResponse } from '../../../src/api-contract'; import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation'; import { shortUrlDeleted } from '../../../src/short-urls/reducers/shortUrlDeletion'; import { editShortUrl as editShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlEdition'; @@ -103,36 +101,36 @@ describe('shortUrlsListReducer', () => { it.each([ [ [ - fromPartial({ shortCode }), - fromPartial({ shortCode, domain: 'example.com' }), - fromPartial({ shortCode: 'foo' }), + fromPartial({ shortCode }), + fromPartial({ shortCode, domain: 'example.com' }), + fromPartial({ shortCode: 'foo' }), ], [{ shortCode: 'newOne' }, { shortCode }, { shortCode, domain: 'example.com' }, { shortCode: 'foo' }], ], [ [ - fromPartial({ shortCode }), - fromPartial({ shortCode: 'code' }), - fromPartial({ shortCode: 'foo' }), - fromPartial({ shortCode: 'bar' }), - fromPartial({ shortCode: 'baz' }), + fromPartial({ shortCode }), + fromPartial({ shortCode: 'code' }), + fromPartial({ shortCode: 'foo' }), + fromPartial({ shortCode: 'bar' }), + fromPartial({ shortCode: 'baz' }), ], [{ shortCode: 'newOne' }, { shortCode }, { shortCode: 'code' }, { shortCode: 'foo' }, { shortCode: 'bar' }], ], [ [ - fromPartial({ shortCode }), - fromPartial({ shortCode: 'code' }), - fromPartial({ shortCode: 'foo' }), - fromPartial({ shortCode: 'bar' }), - fromPartial({ shortCode: 'baz1' }), - fromPartial({ shortCode: 'baz2' }), - fromPartial({ shortCode: 'baz3' }), + fromPartial({ shortCode }), + fromPartial({ shortCode: 'code' }), + fromPartial({ shortCode: 'foo' }), + fromPartial({ shortCode: 'bar' }), + fromPartial({ shortCode: 'baz1' }), + fromPartial({ shortCode: 'baz2' }), + fromPartial({ shortCode: 'baz3' }), ], [{ shortCode: 'newOne' }, { shortCode }, { shortCode: 'code' }, { shortCode: 'foo' }, { shortCode: 'bar' }], ], ])('prepends new short URL and increases total on CREATE_SHORT_URL', (data, expectedData) => { - const newShortUrl = fromPartial({ shortCode: 'newOne' }); + const newShortUrl = fromPartial({ shortCode: 'newOne' }); const state = { shortUrls: fromPartial({ data, @@ -153,15 +151,15 @@ describe('shortUrlsListReducer', () => { }); it.each([ - ((): [ShortUrl, ShortUrl[], ShortUrl[]] => { - const editedShortUrl = fromPartial({ shortCode: 'notMatching' }); - const list: ShortUrl[] = [fromPartial({ shortCode: 'foo' }), fromPartial({ shortCode: 'bar' })]; + ((): [ShlinkShortUrl, ShlinkShortUrl[], ShlinkShortUrl[]] => { + const editedShortUrl = fromPartial({ shortCode: 'notMatching' }); + const list: ShlinkShortUrl[] = [fromPartial({ shortCode: 'foo' }), fromPartial({ shortCode: 'bar' })]; return [editedShortUrl, list, list]; })(), - ((): [ShortUrl, ShortUrl[], ShortUrl[]] => { - const editedShortUrl = fromPartial({ shortCode: 'matching', longUrl: 'new_one' }); - const list: ShortUrl[] = [ + ((): [ShlinkShortUrl, ShlinkShortUrl[], ShlinkShortUrl[]] => { + const editedShortUrl = fromPartial({ shortCode: 'matching', longUrl: 'new_one' }); + const list: ShlinkShortUrl[] = [ fromPartial({ shortCode: 'matching', longUrl: 'old_one' }), fromPartial({ shortCode: 'bar' }), ]; @@ -187,7 +185,7 @@ describe('shortUrlsListReducer', () => { describe('listShortUrls', () => { const dispatch = vi.fn(); - const getState = vi.fn().mockReturnValue({ selectedServer: {} }); + const getState = vi.fn(); it('dispatches proper actions if API client request succeeds', async () => { listShortUrlsMock.mockResolvedValue({}); diff --git a/test/tags/TagsList.test.tsx b/shlink-web-component/test/tags/TagsList.test.tsx similarity index 87% rename from test/tags/TagsList.test.tsx rename to shlink-web-component/test/tags/TagsList.test.tsx index d53e52d4..0f7271d5 100644 --- a/test/tags/TagsList.test.tsx +++ b/shlink-web-component/test/tags/TagsList.test.tsx @@ -5,20 +5,22 @@ import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercure import type { TagsList } from '../../src/tags/reducers/tagsList'; import type { TagsListProps } from '../../src/tags/TagsList'; import { TagsList as createTagsList } from '../../src/tags/TagsList'; +import { SettingsProvider } from '../../src/utils/settings'; import { renderWithEvents } from '../__helpers__/setUpTest'; describe('', () => { const filterTags = vi.fn(); const TagsListComp = createTagsList(({ sortedTags }) => <>TagsTable ({sortedTags.map((t) => t.visits).join(',')})); const setUp = (tagsList: Partial, excludeBots = false) => renderWithEvents( - ({})} - {...fromPartial({ mercureInfo: {} })} - forceListTags={identity} - filterTags={filterTags} - tagsList={fromPartial(tagsList)} - settings={fromPartial({ visits: { excludeBots } })} - />, + + ({})} + {...fromPartial({ mercureInfo: {} })} + forceListTags={identity} + filterTags={filterTags} + tagsList={fromPartial(tagsList)} + /> + , ); it('shows a loading message when tags are being loaded', () => { diff --git a/test/tags/TagsTable.test.tsx b/shlink-web-component/test/tags/TagsTable.test.tsx similarity index 97% rename from test/tags/TagsTable.test.tsx rename to shlink-web-component/test/tags/TagsTable.test.tsx index ee146aeb..12894b98 100644 --- a/test/tags/TagsTable.test.tsx +++ b/shlink-web-component/test/tags/TagsTable.test.tsx @@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { useLocation } from 'react-router-dom'; import { TagsTable as createTagsTable } from '../../src/tags/TagsTable'; -import { rangeOf } from '../../src/utils/utils'; +import { rangeOf } from '../../src/utils/helpers'; import { renderWithEvents } from '../__helpers__/setUpTest'; vi.mock('react-router-dom', async () => ({ @@ -19,7 +19,6 @@ describe('', () => { return renderWithEvents( fromPartial({ tag }))} - selectedServer={fromPartial({})} currentOrder={{}} orderByColumn={() => orderByColumn} />, diff --git a/test/tags/TagsTableRow.test.tsx b/shlink-web-component/test/tags/TagsTableRow.test.tsx similarity index 87% rename from test/tags/TagsTableRow.test.tsx rename to shlink-web-component/test/tags/TagsTableRow.test.tsx index ec2c4e8a..f0fb3927 100644 --- a/test/tags/TagsTableRow.test.tsx +++ b/shlink-web-component/test/tags/TagsTableRow.test.tsx @@ -1,7 +1,7 @@ import { screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router-dom'; import { TagsTableRow as createTagsTableRow } from '../../src/tags/TagsTableRow'; +import { RoutesPrefixProvider } from '../../src/utils/routesPrefix'; import { renderWithEvents } from '../__helpers__/setUpTest'; import { colorGeneratorMock } from '../utils/services/__mocks__/ColorGenerator.mock'; @@ -13,14 +13,15 @@ describe('', () => { ); const setUp = (tagStats?: { visits?: number; shortUrls?: number }) => renderWithEvents( - - - - -
+ + + + + +
+
, ); diff --git a/test/tags/helpers/DeleteTagConfirmModal.test.tsx b/shlink-web-component/test/tags/helpers/DeleteTagConfirmModal.test.tsx similarity index 100% rename from test/tags/helpers/DeleteTagConfirmModal.test.tsx rename to shlink-web-component/test/tags/helpers/DeleteTagConfirmModal.test.tsx diff --git a/test/tags/helpers/EditTagModal.test.tsx b/shlink-web-component/test/tags/helpers/EditTagModal.test.tsx similarity index 100% rename from test/tags/helpers/EditTagModal.test.tsx rename to shlink-web-component/test/tags/helpers/EditTagModal.test.tsx diff --git a/test/tags/helpers/Tag.test.tsx b/shlink-web-component/test/tags/helpers/Tag.test.tsx similarity index 98% rename from test/tags/helpers/Tag.test.tsx rename to shlink-web-component/test/tags/helpers/Tag.test.tsx index a9088840..2700a51f 100644 --- a/test/tags/helpers/Tag.test.tsx +++ b/shlink-web-component/test/tags/helpers/Tag.test.tsx @@ -1,9 +1,9 @@ +import { MAIN_COLOR } from '@shlinkio/shlink-frontend-kit'; import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { ReactNode } from 'react'; import { Tag } from '../../../src/tags/helpers/Tag'; import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator'; -import { MAIN_COLOR } from '../../../src/utils/theme'; import { renderWithEvents } from '../../__helpers__/setUpTest'; const hexToRgb = (hex: string) => { diff --git a/test/tags/helpers/TagsSelector.test.tsx b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx similarity index 90% rename from test/tags/helpers/TagsSelector.test.tsx rename to shlink-web-component/test/tags/helpers/TagsSelector.test.tsx index 08bb3058..29de7d5c 100644 --- a/test/tags/helpers/TagsSelector.test.tsx +++ b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx @@ -2,6 +2,7 @@ import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { TagsSelector as createTagsSelector } from '../../../src/tags/helpers/TagsSelector'; import type { TagsList } from '../../../src/tags/reducers/tagsList'; +import { SettingsProvider } from '../../../src/utils/settings'; import { renderWithEvents } from '../../__helpers__/setUpTest'; import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock'; @@ -11,13 +12,14 @@ describe('', () => { const tags = ['foo', 'bar']; const tagsList = fromPartial({ tags: [...tags, 'baz'] }); const setUp = () => renderWithEvents( - , + + + , ); it('has an input for tags', () => { diff --git a/test/tags/reducers/tagDelete.test.ts b/shlink-web-component/test/tags/reducers/tagDelete.test.ts similarity index 87% rename from test/tags/reducers/tagDelete.test.ts rename to shlink-web-component/test/tags/reducers/tagDelete.test.ts index eda74d29..59a4fb14 100644 --- a/test/tags/reducers/tagDelete.test.ts +++ b/shlink-web-component/test/tags/reducers/tagDelete.test.ts @@ -1,6 +1,5 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { ShlinkState } from '../../../src/container/types'; +import type { ShlinkApiClient } from '../../../src/api-contract'; import { tagDeleted, tagDeleteReducerCreator } from '../../../src/tags/reducers/tagDelete'; describe('tagDeleteReducer', () => { @@ -42,13 +41,12 @@ describe('tagDeleteReducer', () => { describe('deleteTag', () => { const dispatch = vi.fn(); - const getState = () => fromPartial({}); it('calls API on success', async () => { const tag = 'foo'; deleteTagsCall.mockResolvedValue(undefined); - await deleteTag(tag)(dispatch, getState, {}); + await deleteTag(tag)(dispatch, vi.fn(), {}); expect(deleteTagsCall).toHaveBeenCalledTimes(1); expect(deleteTagsCall).toHaveBeenNthCalledWith(1, [tag]); diff --git a/test/tags/reducers/tagEdit.test.ts b/shlink-web-component/test/tags/reducers/tagEdit.test.ts similarity index 89% rename from test/tags/reducers/tagEdit.test.ts rename to shlink-web-component/test/tags/reducers/tagEdit.test.ts index 8e901c4c..cd61f9df 100644 --- a/test/tags/reducers/tagEdit.test.ts +++ b/shlink-web-component/test/tags/reducers/tagEdit.test.ts @@ -1,6 +1,5 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import type { ShlinkState } from '../../../src/container/types'; +import type { ShlinkApiClient } from '../../../src/api-contract'; import { editTag as editTagCreator, tagEdited, tagEditReducerCreator } from '../../../src/tags/reducers/tagEdit'; import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator'; @@ -51,12 +50,11 @@ describe('tagEditReducer', () => { describe('editTag', () => { const dispatch = vi.fn(); - const getState = () => fromPartial({}); it('calls API on success', async () => { editTagCall.mockResolvedValue(undefined); - await editTag({ oldName, newName, color })(dispatch, getState, {}); + await editTag({ oldName, newName, color })(dispatch, vi.fn(), {}); expect(editTagCall).toHaveBeenCalledTimes(1); expect(editTagCall).toHaveBeenCalledWith(oldName, newName); diff --git a/test/tags/reducers/tagsList.test.ts b/shlink-web-component/test/tags/reducers/tagsList.test.ts similarity index 94% rename from test/tags/reducers/tagsList.test.ts rename to shlink-web-component/test/tags/reducers/tagsList.test.ts index 7e081e48..9fedfd3b 100644 --- a/test/tags/reducers/tagsList.test.ts +++ b/shlink-web-component/test/tags/reducers/tagsList.test.ts @@ -1,6 +1,6 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkState } from '../../../src/container/types'; -import type { ShortUrl } from '../../../src/short-urls/data'; +import type { ShlinkShortUrl } from '../../../src/api-contract'; +import type { RootState } from '../../../src/container/store'; import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation'; import { tagDeleted } from '../../../src/tags/reducers/tagDelete'; import { tagEdited } from '../../../src/tags/reducers/tagEdit'; @@ -112,7 +112,7 @@ describe('tagsListReducer', () => { [['new', 'tag'], ['foo', 'bar', 'baz', 'foo2', 'fo', 'new', 'tag']], ])('appends new short URL\'s tags to the list of tags on CREATE_SHORT_URL', (shortUrlTags, expectedTags) => { const tags = ['foo', 'bar', 'baz', 'foo2', 'fo']; - const payload = fromPartial({ tags: shortUrlTags }); + const payload = fromPartial({ tags: shortUrlTags }); expect(reducer(state({ tags }), createShortUrl.fulfilled(payload, '', fromPartial({})))).toEqual({ tags: expectedTags, @@ -195,11 +195,11 @@ describe('tagsListReducer', () => { describe('listTags', () => { const dispatch = vi.fn(); - const getState = vi.fn(() => fromPartial({})); + const getState = vi.fn(() => fromPartial({})); const listTagsMock = vi.fn(); const assertNoAction = async (tagsList: TagsList) => { - getState.mockReturnValue(fromPartial({ tagsList })); + getState.mockReturnValue(fromPartial({ tagsList })); await listTagsCreator(buildShlinkApiClient, false)()(dispatch, getState, {}); @@ -218,7 +218,7 @@ describe('tagsListReducer', () => { const tags = ['foo', 'bar', 'baz']; listTagsMock.mockResolvedValue({ tags, stats: [] }); - buildShlinkApiClient.mockReturnValue({ listTags: listTagsMock }); + buildShlinkApiClient.mockReturnValue({ tagsStats: listTagsMock }); await listTags()(dispatch, getState, {}); diff --git a/test/utils/CopyToClipboardIcon.test.tsx b/shlink-web-component/test/utils/components/CopyToClipboardIcon.test.tsx similarity index 81% rename from test/utils/CopyToClipboardIcon.test.tsx rename to shlink-web-component/test/utils/components/CopyToClipboardIcon.test.tsx index b73fa786..01aee93b 100644 --- a/test/utils/CopyToClipboardIcon.test.tsx +++ b/shlink-web-component/test/utils/components/CopyToClipboardIcon.test.tsx @@ -1,5 +1,5 @@ -import { CopyToClipboardIcon } from '../../src/utils/CopyToClipboardIcon'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { CopyToClipboardIcon } from '../../../src/utils/components/CopyToClipboardIcon'; +import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { const onCopy = vi.fn(); diff --git a/test/utils/ExportBtn.test.tsx b/shlink-web-component/test/utils/components/ExportBtn.test.tsx similarity index 93% rename from test/utils/ExportBtn.test.tsx rename to shlink-web-component/test/utils/components/ExportBtn.test.tsx index 0ed10837..39d0f5cd 100644 --- a/test/utils/ExportBtn.test.tsx +++ b/shlink-web-component/test/utils/components/ExportBtn.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { ExportBtn } from '../../src/utils/ExportBtn'; +import { ExportBtn } from '../../../src/utils/components/ExportBtn'; describe('', () => { const setUp = (amount?: number, loading = false) => render(); diff --git a/test/utils/IconInput.test.tsx b/shlink-web-component/test/utils/components/IconInput.test.tsx similarity index 86% rename from test/utils/IconInput.test.tsx rename to shlink-web-component/test/utils/components/IconInput.test.tsx index a5d863d0..96bee16d 100644 --- a/test/utils/IconInput.test.tsx +++ b/shlink-web-component/test/utils/components/IconInput.test.tsx @@ -1,8 +1,8 @@ import type { IconProp } from '@fortawesome/fontawesome-svg-core'; import { faAppleAlt, faCalendar, faTable } from '@fortawesome/free-solid-svg-icons'; import { screen } from '@testing-library/react'; -import { IconInput } from '../../src/utils/IconInput'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { IconInput } from '../../../src/utils/components/IconInput'; +import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { const setUp = (icon: IconProp, placeholder?: string) => renderWithEvents( diff --git a/test/utils/InfoTooltip.test.tsx b/shlink-web-component/test/utils/components/InfoTooltip.test.tsx similarity index 88% rename from test/utils/InfoTooltip.test.tsx rename to shlink-web-component/test/utils/components/InfoTooltip.test.tsx index 388c279c..13c97c8e 100644 --- a/test/utils/InfoTooltip.test.tsx +++ b/shlink-web-component/test/utils/components/InfoTooltip.test.tsx @@ -1,8 +1,8 @@ import type { Placement } from '@popperjs/core'; import { screen, waitFor } from '@testing-library/react'; -import type { InfoTooltipProps } from '../../src/utils/InfoTooltip'; -import { InfoTooltip } from '../../src/utils/InfoTooltip'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import type { InfoTooltipProps } from '../../../src/utils/components/InfoTooltip'; +import { InfoTooltip } from '../../../src/utils/components/InfoTooltip'; +import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { const setUp = (props: Partial = {}) => renderWithEvents( diff --git a/test/utils/PaginationDropdown.test.tsx b/shlink-web-component/test/utils/components/PaginationDropdown.test.tsx similarity index 85% rename from test/utils/PaginationDropdown.test.tsx rename to shlink-web-component/test/utils/components/PaginationDropdown.test.tsx index a4188b56..57a8be6b 100644 --- a/test/utils/PaginationDropdown.test.tsx +++ b/shlink-web-component/test/utils/components/PaginationDropdown.test.tsx @@ -1,6 +1,6 @@ import { screen } from '@testing-library/react'; -import { PaginationDropdown } from '../../src/utils/PaginationDropdown'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { PaginationDropdown } from '../../../src/utils/components/PaginationDropdown'; +import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { const setValue = vi.fn(); diff --git a/test/common/SimplePaginator.test.tsx b/shlink-web-component/test/utils/components/SimplePaginator.test.tsx similarity index 91% rename from test/common/SimplePaginator.test.tsx rename to shlink-web-component/test/utils/components/SimplePaginator.test.tsx index f0b9e1ae..b3557603 100644 --- a/test/common/SimplePaginator.test.tsx +++ b/shlink-web-component/test/utils/components/SimplePaginator.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; -import { SimplePaginator } from '../../src/common/SimplePaginator'; -import { ELLIPSIS } from '../../src/utils/helpers/pagination'; +import { SimplePaginator } from '../../../src/utils/components/SimplePaginator'; +import { ELLIPSIS } from '../../../src/utils/helpers/pagination'; describe('', () => { const setUp = (pagesCount: number, currentPage = 1) => render( diff --git a/test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap b/shlink-web-component/test/utils/components/__snapshots__/CopyToClipboardIcon.test.tsx.snap similarity index 100% rename from test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap rename to shlink-web-component/test/utils/components/__snapshots__/CopyToClipboardIcon.test.tsx.snap diff --git a/test/utils/__snapshots__/ExportBtn.test.tsx.snap b/shlink-web-component/test/utils/components/__snapshots__/ExportBtn.test.tsx.snap similarity index 100% rename from test/utils/__snapshots__/ExportBtn.test.tsx.snap rename to shlink-web-component/test/utils/components/__snapshots__/ExportBtn.test.tsx.snap diff --git a/test/utils/__snapshots__/IconInput.test.tsx.snap b/shlink-web-component/test/utils/components/__snapshots__/IconInput.test.tsx.snap similarity index 100% rename from test/utils/__snapshots__/IconInput.test.tsx.snap rename to shlink-web-component/test/utils/components/__snapshots__/IconInput.test.tsx.snap diff --git a/test/utils/dates/DateInput.test.tsx b/shlink-web-component/test/utils/dates/DateInput.test.tsx similarity index 100% rename from test/utils/dates/DateInput.test.tsx rename to shlink-web-component/test/utils/dates/DateInput.test.tsx diff --git a/test/utils/dates/DateIntervalDropdownItems.test.tsx b/shlink-web-component/test/utils/dates/DateIntervalDropdownItems.test.tsx similarity index 90% rename from test/utils/dates/DateIntervalDropdownItems.test.tsx rename to shlink-web-component/test/utils/dates/DateIntervalDropdownItems.test.tsx index 0a90769c..79495f08 100644 --- a/test/utils/dates/DateIntervalDropdownItems.test.tsx +++ b/shlink-web-component/test/utils/dates/DateIntervalDropdownItems.test.tsx @@ -1,8 +1,8 @@ +import { DropdownBtn } from '@shlinkio/shlink-frontend-kit'; import { screen, waitFor } from '@testing-library/react'; import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems'; -import { DropdownBtn } from '../../../src/utils/DropdownBtn'; -import type { DateInterval } from '../../../src/utils/helpers/dateIntervals'; -import { DATE_INTERVALS, rangeOrIntervalToString } from '../../../src/utils/helpers/dateIntervals'; +import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals'; +import { DATE_INTERVALS, rangeOrIntervalToString } from '../../../src/utils/dates/helpers/dateIntervals'; import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { diff --git a/test/utils/dates/DateRangeRow.test.tsx b/shlink-web-component/test/utils/dates/DateRangeRow.test.tsx similarity index 100% rename from test/utils/dates/DateRangeRow.test.tsx rename to shlink-web-component/test/utils/dates/DateRangeRow.test.tsx diff --git a/test/utils/dates/DateRangeSelector.test.tsx b/shlink-web-component/test/utils/dates/DateRangeSelector.test.tsx similarity index 96% rename from test/utils/dates/DateRangeSelector.test.tsx rename to shlink-web-component/test/utils/dates/DateRangeSelector.test.tsx index be8be592..016a5496 100644 --- a/test/utils/dates/DateRangeSelector.test.tsx +++ b/shlink-web-component/test/utils/dates/DateRangeSelector.test.tsx @@ -2,7 +2,7 @@ import { screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../../../src/utils/dates/DateRangeSelector'; -import type { DateInterval } from '../../../src/utils/helpers/dateIntervals'; +import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals'; import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { diff --git a/test/utils/dates/Time.test.tsx b/shlink-web-component/test/utils/dates/Time.test.tsx similarity index 93% rename from test/utils/dates/Time.test.tsx rename to shlink-web-component/test/utils/dates/Time.test.tsx index aa57cdd8..e3f0388f 100644 --- a/test/utils/dates/Time.test.tsx +++ b/shlink-web-component/test/utils/dates/Time.test.tsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react'; +import { parseDate } from '../../../src/utils/dates/helpers/date'; import type { TimeProps } from '../../../src/utils/dates/Time'; import { Time } from '../../../src/utils/dates/Time'; -import { parseDate } from '../../../src/utils/helpers/date'; describe('