mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Install @shlinkio/shlink-frontend-kit as external lib and downgrade bootstrap
This commit is contained in:
parent
dfe2cdadbc
commit
036c2b28d0
69 changed files with 67 additions and 1514 deletions
46
package-lock.json
generated
46
package-lock.json
generated
|
@ -15,7 +15,8 @@
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@json2csv/plainjs": "^7.0.1",
|
"@json2csv/plainjs": "^7.0.1",
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
"bootstrap": "^5.3.1",
|
"@shlinkio/shlink-frontend-kit": "^0.1.2",
|
||||||
|
"bootstrap": "5.2.3",
|
||||||
"bottlejs": "^2.0.1",
|
"bottlejs": "^2.0.1",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"chart.js": "^4.3.3",
|
"chart.js": "^4.3.3",
|
||||||
|
@ -3108,6 +3109,25 @@
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@shlinkio/shlink-frontend-kit": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-7PppdcD7Urv5wJYxXR1X9KIJC9MbQXReG6qtusjSeUdyjeliux/6LclHlZteWwT5JuVvdV+FPVpbxl+D4wLXsw==",
|
||||||
|
"dependencies": {
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"qs": "^6.11.2",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||||
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.14.2",
|
||||||
|
"reactstrap": "^9.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@shlinkio/stylelint-config-css-coding-standard": {
|
"node_modules/@shlinkio/stylelint-config-css-coding-standard": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@shlinkio/stylelint-config-css-coding-standard/-/stylelint-config-css-coding-standard-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shlinkio/stylelint-config-css-coding-standard/-/stylelint-config-css-coding-standard-1.1.1.tgz",
|
||||||
|
@ -4436,9 +4456,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bootstrap": {
|
"node_modules/bootstrap": {
|
||||||
"version": "5.3.1",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz",
|
||||||
"integrity": "sha512-jzwza3Yagduci2x0rr9MeFSORjcHpt0lRZukZPZQJT1Dth5qzV7XcgGqYzi39KGAVYR8QEDVoO0ubFKOxzMG+g==",
|
"integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
@ -4450,7 +4470,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@popperjs/core": "^2.11.8"
|
"@popperjs/core": "^2.11.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bottlejs": {
|
"node_modules/bottlejs": {
|
||||||
|
@ -13306,6 +13326,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@shlinkio/shlink-frontend-kit": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-7PppdcD7Urv5wJYxXR1X9KIJC9MbQXReG6qtusjSeUdyjeliux/6LclHlZteWwT5JuVvdV+FPVpbxl+D4wLXsw==",
|
||||||
|
"requires": {
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"qs": "^6.11.2",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@shlinkio/stylelint-config-css-coding-standard": {
|
"@shlinkio/stylelint-config-css-coding-standard": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@shlinkio/stylelint-config-css-coding-standard/-/stylelint-config-css-coding-standard-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shlinkio/stylelint-config-css-coding-standard/-/stylelint-config-css-coding-standard-1.1.1.tgz",
|
||||||
|
@ -14249,9 +14279,9 @@
|
||||||
"version": "3.7.2"
|
"version": "3.7.2"
|
||||||
},
|
},
|
||||||
"bootstrap": {
|
"bootstrap": {
|
||||||
"version": "5.3.1",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz",
|
||||||
"integrity": "sha512-jzwza3Yagduci2x0rr9MeFSORjcHpt0lRZukZPZQJT1Dth5qzV7XcgGqYzi39KGAVYR8QEDVoO0ubFKOxzMG+g==",
|
"integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"bottlejs": {
|
"bottlejs": {
|
||||||
|
|
|
@ -7,8 +7,8 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npm run lint:css && npm run lint:js",
|
"lint": "npm run lint:css && npm run lint:js",
|
||||||
"lint:css": "stylelint src/*.scss src/**/*.scss shlink-web-component/*.scss shlink-web-component/**/*.scss shlink-frontend-kit/*.scss shlink-frontend-kit/**/*.scss",
|
"lint:css": "stylelint src/*.scss src/**/*.scss shlink-web-component/*.scss shlink-web-component/**/*.scss",
|
||||||
"lint:js": "eslint --ext .js,.ts,.tsx src shlink-web-component shlink-frontend-kit test",
|
"lint:js": "eslint --ext .js,.ts,.tsx src shlink-web-component test",
|
||||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||||
"lint:css:fix": "npm run lint:css -- --fix",
|
"lint:css:fix": "npm run lint:css -- --fix",
|
||||||
"lint:js:fix": "npm run lint:js -- --fix",
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
|
@ -31,7 +31,8 @@
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@json2csv/plainjs": "^7.0.1",
|
"@json2csv/plainjs": "^7.0.1",
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
"bootstrap": "^5.3.1",
|
"@shlinkio/shlink-frontend-kit": "^0.1.2",
|
||||||
|
"bootstrap": "5.2.3",
|
||||||
"bottlejs": "^2.0.1",
|
"bottlejs": "^2.0.1",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"chart.js": "^4.3.3",
|
"chart.js": "^4.3.3",
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
// Breakpoints
|
|
||||||
$xsMax: 575px;
|
|
||||||
$smMin: 576px;
|
|
||||||
$smMax: 767px;
|
|
||||||
$mdMin: 768px;
|
|
||||||
$mdMax: 991px;
|
|
||||||
$lgMin: 992px;
|
|
||||||
$lgMax: 1199px;
|
|
||||||
$xlgMin: 1200px;
|
|
||||||
$responsiveTableBreakpoint: $mdMax;
|
|
||||||
|
|
||||||
// Colors
|
|
||||||
$mainColor: #4696e5;
|
|
||||||
$lightColor: #f5f6fe;
|
|
||||||
$lightGrey: #eeeeee;
|
|
||||||
$dangerColor: #dc3545;
|
|
||||||
$mediumGrey: #dee2e6;
|
|
||||||
$textPlaceholder: #6c757d;
|
|
||||||
|
|
||||||
// Misc
|
|
||||||
$headerHeight: 56px;
|
|
||||||
$asideMenuWidth: 260px;
|
|
||||||
$footer-height: 2.3rem;
|
|
||||||
$footer-margin: .8rem;
|
|
||||||
|
|
||||||
// Bootstrap overwrites
|
|
||||||
$primary: $mainColor;
|
|
|
@ -1,54 +0,0 @@
|
||||||
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
|
||||||
import { Card, Row } from 'reactstrap';
|
|
||||||
|
|
||||||
type MessageType = 'default' | 'error';
|
|
||||||
|
|
||||||
const getClassForType = (type: MessageType) => {
|
|
||||||
const map: Record<MessageType, string> = {
|
|
||||||
error: 'border-danger',
|
|
||||||
default: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
return map[type];
|
|
||||||
};
|
|
||||||
const getTextClassForType = (type: MessageType) => {
|
|
||||||
const map: Record<MessageType, string> = {
|
|
||||||
error: 'text-danger',
|
|
||||||
default: 'text-muted',
|
|
||||||
};
|
|
||||||
|
|
||||||
return map[type];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageProps = PropsWithChildren<{
|
|
||||||
className?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
type?: MessageType;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const Message: FC<MessageProps> = (
|
|
||||||
{ className, children, loading = false, type = 'default', fullWidth = false },
|
|
||||||
) => {
|
|
||||||
const classes = classNames({
|
|
||||||
'col-md-12': fullWidth,
|
|
||||||
'col-md-10 offset-md-1': !fullWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row className={classNames('g-0', className)}>
|
|
||||||
<div className={classes}>
|
|
||||||
<Card className={getClassForType(type)} body>
|
|
||||||
<h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
|
|
||||||
{loading && <FontAwesomeIcon icon={preloader} spin />}
|
|
||||||
{loading && <span className="ms-2">{children ?? 'Loading...'}</span>}
|
|
||||||
{!loading && children}
|
|
||||||
</h3>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,31 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
|
||||||
import { Row } from 'reactstrap';
|
|
||||||
import { SimpleCard } from './SimpleCard';
|
|
||||||
|
|
||||||
export type ResultType = 'success' | 'error' | 'warning';
|
|
||||||
|
|
||||||
export type ResultProps = PropsWithChildren<{
|
|
||||||
type: ResultType;
|
|
||||||
className?: string;
|
|
||||||
small?: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const Result: FC<ResultProps> = ({ children, type, className, small = false }) => (
|
|
||||||
<Row className={className}>
|
|
||||||
<div className={classNames({ 'col-md-10 offset-md-1': !small, 'col-12': small })}>
|
|
||||||
<SimpleCard
|
|
||||||
role="document"
|
|
||||||
className={classNames('text-center', {
|
|
||||||
'bg-main': type === 'success',
|
|
||||||
'bg-danger': type === 'error',
|
|
||||||
'bg-warning': type === 'warning',
|
|
||||||
'text-white': type !== 'warning',
|
|
||||||
})}
|
|
||||||
bodyClassName={classNames({ 'p-2': small })}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SimpleCard>
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
);
|
|
|
@ -1,15 +0,0 @@
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import type { CardProps } from 'reactstrap';
|
|
||||||
import { Card, CardBody, CardHeader } from 'reactstrap';
|
|
||||||
|
|
||||||
export type SimpleCardProps = Omit<CardProps, 'title'> & {
|
|
||||||
title?: ReactNode;
|
|
||||||
bodyClassName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => (
|
|
||||||
<Card {...rest}>
|
|
||||||
{title && <CardHeader role="heading">{title}</CardHeader>}
|
|
||||||
<CardBody className={bodyClassName}>{children}</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './Message';
|
|
||||||
export * from './Result';
|
|
||||||
export * from './SimpleCard';
|
|
|
@ -1,34 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import { identity } from 'ramda';
|
|
||||||
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
|
||||||
import { useDomId } from '../hooks';
|
|
||||||
|
|
||||||
export type BooleanControlProps = PropsWithChildren<{
|
|
||||||
checked?: boolean;
|
|
||||||
onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
className?: string;
|
|
||||||
inline?: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
type BooleanControlWithTypeProps = BooleanControlProps & {
|
|
||||||
type: 'switch' | 'checkbox';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BooleanControl: FC<BooleanControlWithTypeProps> = (
|
|
||||||
{ checked = false, onChange = identity, className, children, type, inline = false },
|
|
||||||
) => {
|
|
||||||
const id = useDomId();
|
|
||||||
const onChecked = (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked, e);
|
|
||||||
const typeClasses = {
|
|
||||||
'form-switch': type === 'switch',
|
|
||||||
'form-checkbox': type === 'checkbox',
|
|
||||||
};
|
|
||||||
const style = inline ? { display: 'inline-block' } : {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={classNames('form-check', typeClasses, className)} style={style}>
|
|
||||||
<input type="checkbox" className="form-check-input" id={id} checked={checked} onChange={onChecked} />
|
|
||||||
<label className="form-check-label" htmlFor={id}>{children}</label>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,5 +0,0 @@
|
||||||
import type { FC } from 'react';
|
|
||||||
import type { BooleanControlProps } from './BooleanControl';
|
|
||||||
import { BooleanControl } from './BooleanControl';
|
|
||||||
|
|
||||||
export const Checkbox: FC<BooleanControlProps> = (props) => <BooleanControl type="checkbox" {...props} />;
|
|
|
@ -1,34 +0,0 @@
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
|
||||||
import type { InputType } from 'reactstrap/types/lib/Input';
|
|
||||||
import { useDomId } from '../hooks';
|
|
||||||
import { LabeledFormGroup } from './LabeledFormGroup';
|
|
||||||
|
|
||||||
export type InputFormGroupProps = PropsWithChildren<{
|
|
||||||
value: string;
|
|
||||||
onChange: (newValue: string) => void;
|
|
||||||
type?: InputType;
|
|
||||||
required?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
labelClassName?: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const InputFormGroup: FC<InputFormGroupProps> = (
|
|
||||||
{ children, value, onChange, type, required, placeholder, className, labelClassName },
|
|
||||||
) => {
|
|
||||||
const id = useDomId();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LabeledFormGroup label={<>{children}:</>} className={className ?? ''} labelClassName={labelClassName} id={id}>
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
className="form-control"
|
|
||||||
type={type ?? 'text'}
|
|
||||||
value={value}
|
|
||||||
required={required ?? true}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</LabeledFormGroup>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,19 +0,0 @@
|
||||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
|
||||||
|
|
||||||
type LabeledFormGroupProps = PropsWithChildren<{
|
|
||||||
label: ReactNode;
|
|
||||||
noMargin?: boolean;
|
|
||||||
className?: string;
|
|
||||||
labelClassName?: string;
|
|
||||||
id?: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
|
||||||
export const LabeledFormGroup: FC<LabeledFormGroupProps> = (
|
|
||||||
{ children, label, className = '', labelClassName = '', noMargin = false, id },
|
|
||||||
) => (
|
|
||||||
<div className={`${className} ${noMargin ? '' : 'mb-3'}`}>
|
|
||||||
<label className={`form-label ${labelClassName}`} htmlFor={id}>{label}</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
|
@ -1,33 +0,0 @@
|
||||||
@import '../../../shlink-web-component/src/utils/mixins/vertical-align';
|
|
||||||
|
|
||||||
.search-field {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:focus-within {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-field__input.search-field__input {
|
|
||||||
padding-left: 40px;
|
|
||||||
padding-right: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-field__input--no-border.search-field__input--no-border {
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-field__icon {
|
|
||||||
@include vertical-align();
|
|
||||||
|
|
||||||
left: 15px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-field__close {
|
|
||||||
@include vertical-align();
|
|
||||||
|
|
||||||
right: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
|
@ -1,57 +0,0 @@
|
||||||
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import './SearchField.scss';
|
|
||||||
|
|
||||||
const DEFAULT_SEARCH_INTERVAL = 500;
|
|
||||||
let timer: NodeJS.Timeout | null;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const resetTimer = () => {
|
|
||||||
timer && clearTimeout(timer);
|
|
||||||
timer = null;
|
|
||||||
};
|
|
||||||
const searchTermChanged = (newSearchTerm: string, timeout = DEFAULT_SEARCH_INTERVAL) => {
|
|
||||||
setSearchTerm(newSearchTerm);
|
|
||||||
|
|
||||||
resetTimer();
|
|
||||||
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
onChange(newSearchTerm);
|
|
||||||
resetTimer();
|
|
||||||
}, timeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames('search-field', className)}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={classNames('form-control search-field__input', {
|
|
||||||
'form-control-lg': large,
|
|
||||||
'search-field__input--no-border': noBorder,
|
|
||||||
})}
|
|
||||||
placeholder="Search..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => searchTermChanged(e.target.value)}
|
|
||||||
/>
|
|
||||||
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
|
|
||||||
<div
|
|
||||||
className="close search-field__close btn-close"
|
|
||||||
hidden={searchTerm === ''}
|
|
||||||
id="search-field__close"
|
|
||||||
onClick={() => searchTermChanged('', 0)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,5 +0,0 @@
|
||||||
import type { FC } from 'react';
|
|
||||||
import type { BooleanControlProps } from './BooleanControl';
|
|
||||||
import { BooleanControl } from './BooleanControl';
|
|
||||||
|
|
||||||
export const ToggleSwitch: FC<BooleanControlProps> = (props) => <BooleanControl type="switch" {...props} />;
|
|
|
@ -1,5 +0,0 @@
|
||||||
export * from './Checkbox';
|
|
||||||
export * from './ToggleSwitch';
|
|
||||||
export * from './InputFormGroup';
|
|
||||||
export * from './LabeledFormGroup';
|
|
||||||
export * from './SearchField';
|
|
|
@ -1,16 +0,0 @@
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
|
|
||||||
type ToggleResult = [boolean, () => void, () => void, () => void];
|
|
||||||
|
|
||||||
export const useToggle = (initialValue = false): ToggleResult => {
|
|
||||||
const [flag, setFlag] = useState<boolean>(initialValue);
|
|
||||||
return [flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false)];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDomId = (): string => {
|
|
||||||
const { current: id } = useRef(`dom-${uuid()}`);
|
|
||||||
return id;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useElementRef = <T>() => useRef<T | null>(null);
|
|
|
@ -1,219 +0,0 @@
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
export * from './block';
|
|
||||||
export * from './form';
|
|
||||||
export * from './hooks';
|
|
||||||
export * from './navigation';
|
|
||||||
export * from './ordering';
|
|
||||||
export * from './theme';
|
|
||||||
export * from './utils';
|
|
|
@ -1,42 +0,0 @@
|
||||||
/* stylelint-disable no-descending-specificity */
|
|
||||||
|
|
||||||
@import '../../../shlink-web-component/src/utils/mixins/vertical-align';
|
|
||||||
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle--with-caret {
|
|
||||||
padding-right: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle,
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled).active,
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):active,
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):focus,
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):hover,
|
|
||||||
.show > .dropdown-btn__toggle.dropdown-btn__toggle.dropdown-toggle {
|
|
||||||
color: var(--input-text-color);
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-color: var(--input-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card .dropdown-btn__toggle.dropdown-btn__toggle,
|
|
||||||
.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled).active,
|
|
||||||
.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):active,
|
|
||||||
.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):focus,
|
|
||||||
.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):hover,
|
|
||||||
.show > .card .dropdown-btn__toggle.dropdown-btn__toggle.dropdown-toggle {
|
|
||||||
background-color: var(--input-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle.disabled,
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle:disabled {
|
|
||||||
background-color: var(--input-disabled-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-btn__toggle.dropdown-btn__toggle:after {
|
|
||||||
@include vertical-align();
|
|
||||||
|
|
||||||
right: .75rem;
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
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 '../hooks';
|
|
||||||
import './DropdownBtn.scss';
|
|
||||||
|
|
||||||
export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & {
|
|
||||||
text: ReactNode;
|
|
||||||
noCaret?: boolean;
|
|
||||||
className?: string;
|
|
||||||
dropdownClassName?: string;
|
|
||||||
inline?: boolean;
|
|
||||||
minWidth?: number;
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const DropdownBtn: FC<DropdownBtnProps> = ({
|
|
||||||
text,
|
|
||||||
disabled = false,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
dropdownClassName,
|
|
||||||
noCaret,
|
|
||||||
end = false,
|
|
||||||
minWidth,
|
|
||||||
inline,
|
|
||||||
size,
|
|
||||||
}) => {
|
|
||||||
const [isOpen, toggle] = useToggle();
|
|
||||||
const toggleClasses = classNames('dropdown-btn__toggle', className, {
|
|
||||||
'btn-block': !inline,
|
|
||||||
'dropdown-btn__toggle--with-caret': !noCaret,
|
|
||||||
});
|
|
||||||
const menuStyle = { minWidth: minWidth && `${minWidth}px` };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
|
|
||||||
<DropdownToggle size={size} caret={!noCaret} className={toggleClasses} color="primary">
|
|
||||||
{text}
|
|
||||||
</DropdownToggle>
|
|
||||||
<DropdownMenu className="w-100" end={end} style={menuStyle}>{children}</DropdownMenu>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,31 +0,0 @@
|
||||||
@import '../base';
|
|
||||||
|
|
||||||
.nav-pills__nav {
|
|
||||||
position: sticky !important;
|
|
||||||
top: $headerHeight - 1px;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills__nav-link.nav-pills__nav-link {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
padding-bottom: calc(.5rem - 3px) !important;
|
|
||||||
border-bottom: 3px solid transparent !important;
|
|
||||||
color: #5d6778;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
@media (min-width: $smMin) and (max-width: $mdMax) {
|
|
||||||
font-size: 89%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills__nav-link:hover {
|
|
||||||
color: $mainColor !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills__nav-link.active {
|
|
||||||
border-color: $mainColor !important;
|
|
||||||
background-color: var(--primary-color) !important;
|
|
||||||
color: $mainColor !important;
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
|
||||||
import { Children, isValidElement } from 'react';
|
|
||||||
import { NavLink as RouterNavLink } from 'react-router-dom';
|
|
||||||
import { Card, Nav, NavLink } from 'reactstrap';
|
|
||||||
import './NavPills.scss';
|
|
||||||
|
|
||||||
type NavPillsProps = PropsWithChildren<{
|
|
||||||
fill?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
type NavPillProps = PropsWithChildren<{
|
|
||||||
to: string;
|
|
||||||
replace?: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const NavPillItem: FC<NavPillProps> = ({ children, ...rest }) => (
|
|
||||||
<NavLink className="nav-pills__nav-link" tag={RouterNavLink} {...rest}>
|
|
||||||
{children}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const NavPills: FC<NavPillsProps> = ({ children, fill = false, className = '' }) => (
|
|
||||||
<Card className={`nav-pills__nav p-0 overflow-hidden ${className}`} body>
|
|
||||||
<Nav pills fill={fill}>
|
|
||||||
{Children.map(children, (child) => {
|
|
||||||
if (!isValidElement(child) || child.type !== NavPillItem) {
|
|
||||||
throw new Error('Only NavPillItem children are allowed inside NavPills.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return child;
|
|
||||||
})}
|
|
||||||
</Nav>
|
|
||||||
</Card>
|
|
||||||
);
|
|
|
@ -1,21 +0,0 @@
|
||||||
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
|
||||||
import { DropdownBtn } from './DropdownBtn';
|
|
||||||
|
|
||||||
export type DropdownBtnMenuProps = PropsWithChildren<{
|
|
||||||
minWidth?: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const RowDropdownBtn: FC<DropdownBtnMenuProps> = ({ children, minWidth }) => (
|
|
||||||
<DropdownBtn
|
|
||||||
text={<FontAwesomeIcon className="px-1" icon={menuIcon} />}
|
|
||||||
size="sm"
|
|
||||||
minWidth={minWidth}
|
|
||||||
end
|
|
||||||
noCaret
|
|
||||||
inline
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DropdownBtn>
|
|
||||||
);
|
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './DropdownBtn';
|
|
||||||
export * from './RowDropdownBtn';
|
|
||||||
export * from './NavPills';
|
|
|
@ -1,8 +0,0 @@
|
||||||
.ordering-dropdown__menu--link.ordering-dropdown__menu--link {
|
|
||||||
min-width: 11rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ordering-dropdown__sort-icon {
|
|
||||||
margin: 3.5px 0 0;
|
|
||||||
float: right;
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
import { faSortAmountDown as sortDescIcon, faSortAmountUp as sortAscIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
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 './ordering';
|
|
||||||
import { determineOrderDir } from './ordering';
|
|
||||||
import './OrderingDropdown.scss';
|
|
||||||
|
|
||||||
export type OrderingDropdownProps<T extends string = string> = {
|
|
||||||
items: Record<T, string>;
|
|
||||||
order: Order<T>;
|
|
||||||
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
|
||||||
isButton?: boolean;
|
|
||||||
right?: boolean;
|
|
||||||
prefixed?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OrderingDropdown<T extends string = string>(
|
|
||||||
{ items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>,
|
|
||||||
) {
|
|
||||||
const handleItemClick = (fieldKey: T) => () => {
|
|
||||||
const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
|
|
||||||
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UncontrolledDropdown>
|
|
||||||
<DropdownToggle
|
|
||||||
caret
|
|
||||||
color={isButton ? 'primary' : 'link'}
|
|
||||||
className={classNames({
|
|
||||||
'dropdown-btn__toggle btn-block pe-4 overflow-hidden': isButton,
|
|
||||||
'btn-sm p-0': !isButton,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{!isButton && <>Order by</>}
|
|
||||||
{isButton && !order.field && <i>Order by...</i>}
|
|
||||||
{isButton && order.field && <>{prefixed && 'Order by: '}{items[order.field]} - <small>{order.dir ?? 'DESC'}</small></>}
|
|
||||||
</DropdownToggle>
|
|
||||||
<DropdownMenu
|
|
||||||
end={right}
|
|
||||||
className={classNames('w-100', { 'ordering-dropdown__menu--link': !isButton })}
|
|
||||||
>
|
|
||||||
{toPairs(items).map(([fieldKey, fieldValue]) => (
|
|
||||||
<DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
|
||||||
{fieldValue}
|
|
||||||
{order.field === fieldKey && (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={order.dir === 'ASC' ? sortAscIcon : sortDescIcon}
|
|
||||||
className="ordering-dropdown__sort-icon"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DropdownItem>
|
|
||||||
))}
|
|
||||||
<DropdownItem divider />
|
|
||||||
<DropdownItem disabled={!order.field} onClick={() => onChange()}>
|
|
||||||
<i>Clear selection</i>
|
|
||||||
</DropdownItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
</UncontrolledDropdown>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './ordering';
|
|
||||||
export * from './OrderingDropdown';
|
|
|
@ -1,41 +0,0 @@
|
||||||
export type OrderDir = 'ASC' | 'DESC' | undefined;
|
|
||||||
|
|
||||||
export type Order<Fields> = {
|
|
||||||
field?: Fields;
|
|
||||||
dir?: OrderDir;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const determineOrderDir = <T extends string = string>(
|
|
||||||
currentField: T,
|
|
||||||
newField?: T,
|
|
||||||
currentOrderDir?: OrderDir,
|
|
||||||
): OrderDir => {
|
|
||||||
if (currentField !== newField) {
|
|
||||||
return 'ASC';
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = {
|
|
||||||
ASC: 'DESC',
|
|
||||||
DESC: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sortList = <List>(list: List[], { field, dir }: Order<keyof List>) => (
|
|
||||||
!field || !dir ? list : list.sort((a, b) => {
|
|
||||||
const greaterThan = dir === 'ASC' ? 1 : -1;
|
|
||||||
const smallerThan = dir === 'ASC' ? -1 : 1;
|
|
||||||
|
|
||||||
return a[field] > b[field] ? greaterThan : smallerThan;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export const orderToString = <T>(order: Order<T>): string | undefined => (
|
|
||||||
order.dir ? `${order.field}-${order.dir}` : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
export const stringToOrder = <T>(order: string): Order<T> => {
|
|
||||||
const [field, dir] = order.split('-') as [T | undefined, OrderDir | undefined];
|
|
||||||
return { field, dir };
|
|
||||||
};
|
|
|
@ -1,17 +0,0 @@
|
||||||
export const MAIN_COLOR = '#4696e5';
|
|
||||||
|
|
||||||
export const MAIN_COLOR_ALPHA = 'rgba(70, 150, 229, 0.4)';
|
|
||||||
|
|
||||||
export const HIGHLIGHTED_COLOR = '#f77f28';
|
|
||||||
|
|
||||||
export const HIGHLIGHTED_COLOR_ALPHA = 'rgba(247, 127, 40, 0.4)';
|
|
||||||
|
|
||||||
export const PRIMARY_LIGHT_COLOR = 'white';
|
|
||||||
|
|
||||||
export const PRIMARY_DARK_COLOR = '#161b22';
|
|
||||||
|
|
||||||
export type Theme = 'dark' | 'light';
|
|
||||||
|
|
||||||
export const changeThemeInMarkup = (theme: Theme) => document.querySelector('html')?.setAttribute('data-theme', theme);
|
|
||||||
|
|
||||||
export const isDarkThemeEnabled = (): boolean => document.querySelector('html')?.getAttribute('data-theme') === 'dark';
|
|
|
@ -1,67 +0,0 @@
|
||||||
@import '../base';
|
|
||||||
|
|
||||||
// Light theme colors
|
|
||||||
$lightPrimaryColor: #ffffff;
|
|
||||||
$lightPrimaryColorAlfa: rgba($lightPrimaryColor, .5);
|
|
||||||
$lightSecondaryColor: $lightColor;
|
|
||||||
$lightTextColor: #232323;
|
|
||||||
$lightBorderColor: rgb(0 0 0 / .125);
|
|
||||||
$lightTableBorderColor: $mediumGrey;
|
|
||||||
$lightActiveColor: $lightGrey;
|
|
||||||
$lightBrandColor: $mainColor;
|
|
||||||
$lightInputColor: $lightPrimaryColor;
|
|
||||||
$lightInputTextColor: #495057;
|
|
||||||
$lightDisabledInputColor: $lightColor;
|
|
||||||
$lightBorderInputColor: rgb(0 0 0 / .19);
|
|
||||||
$lightTableHighlightColor: rgb(0 0 0 / .075);
|
|
||||||
|
|
||||||
// Dark theme colors
|
|
||||||
$darkPrimaryColor: #161b22;
|
|
||||||
$darkPrimaryColorAlfa: rgba($darkPrimaryColor, .8);
|
|
||||||
$darkSecondaryColor: #0f131a;
|
|
||||||
$darkTextColor: rgb(201 209 217);
|
|
||||||
$darkBorderColor: rgb(255 255 255 / .15);
|
|
||||||
$darkTableBorderColor: #393d43;
|
|
||||||
$darkActiveColor: $darkSecondaryColor;
|
|
||||||
$darkBrandColor: #0b2d4e;
|
|
||||||
$darkInputColor: darken($darkPrimaryColor, 2%);
|
|
||||||
$darkInputTextColor: $darkTextColor;
|
|
||||||
$darkDisabledInputColor: lighten($darkPrimaryColor, 2%);
|
|
||||||
$darkBorderInputColor: $darkBorderColor;
|
|
||||||
$darkTableHighlightColor: $darkBorderColor;
|
|
||||||
|
|
||||||
html:not([data-theme='dark']) {
|
|
||||||
--color-scheme: initial;
|
|
||||||
--primary-color: #{$lightPrimaryColor};
|
|
||||||
--primary-color-alfa: #{$lightPrimaryColorAlfa};
|
|
||||||
--secondary-color: #{$lightSecondaryColor};
|
|
||||||
--text-color: #{$lightTextColor};
|
|
||||||
--border-color: #{$lightBorderColor};
|
|
||||||
--active-color: #{$lightActiveColor};
|
|
||||||
--brand-color: #{$lightBrandColor};
|
|
||||||
--input-color: #{$lightInputColor};
|
|
||||||
--input-disabled-color: #{$lightDisabledInputColor};
|
|
||||||
--input-border-color: #{$lightBorderInputColor};
|
|
||||||
--input-text-color: #{$lightInputTextColor};
|
|
||||||
--table-border-color: #{$lightTableBorderColor};
|
|
||||||
--table-highlight-color: #{$lightTableHighlightColor};
|
|
||||||
--btn-close-filter: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='dark'] {
|
|
||||||
--color-scheme: dark;
|
|
||||||
--primary-color: #{$darkPrimaryColor};
|
|
||||||
--primary-color-alfa: #{$darkPrimaryColorAlfa};
|
|
||||||
--secondary-color: #{$darkSecondaryColor};
|
|
||||||
--text-color: #{$darkTextColor};
|
|
||||||
--border-color: #{$darkBorderColor};
|
|
||||||
--active-color: #{$darkActiveColor};
|
|
||||||
--brand-color: #{$darkBrandColor};
|
|
||||||
--input-color: #{$darkInputColor};
|
|
||||||
--input-disabled-color: #{$darkDisabledInputColor};
|
|
||||||
--input-border-color: #{$darkBorderInputColor};
|
|
||||||
--input-text-color: #{$darkInputTextColor};
|
|
||||||
--table-border-color: #{$darkTableBorderColor};
|
|
||||||
--table-highlight-color: #{$darkTableHighlightColor};
|
|
||||||
--btn-close-filter: invert(1);
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
@import '../base';
|
|
||||||
|
|
||||||
.responsive-table__header {
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.responsive-table.table > :not(:first-child) {
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.responsive-table__row {
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
display: block;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
border-top: 2px solid var(--border-color);
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.responsive-table__cell.responsive-table__cell {
|
|
||||||
vertical-align: middle !important;
|
|
||||||
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
padding: .5rem;
|
|
||||||
font-size: .9rem;
|
|
||||||
|
|
||||||
&[data-th]:before {
|
|
||||||
content: attr(data-th) ': ';
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
position: absolute;
|
|
||||||
top: 3.5px;
|
|
||||||
right: .5rem;
|
|
||||||
width: auto;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.responsive-table__cell.responsive-table__cell .btn-sm {
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
margin-top: 0.16rem;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
import qs from 'qs';
|
|
||||||
|
|
||||||
// FIXME Use URLSearchParams instead of qs package
|
|
||||||
|
|
||||||
export const parseQuery = <T>(search: string) => qs.parse(search, { ignoreQueryPrefix: true }) as unknown as T;
|
|
||||||
|
|
||||||
export const stringifyQuery = (query: any): string => qs.stringify(query, { arrayFormat: 'brackets' });
|
|
|
@ -1,8 +0,0 @@
|
||||||
import { render } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
|
|
||||||
export const renderWithEvents = (element: ReactElement) => ({
|
|
||||||
user: userEvent.setup(),
|
|
||||||
...render(element),
|
|
||||||
});
|
|
|
@ -1,50 +0,0 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import type { PropsWithChildren } from 'react';
|
|
||||||
import type { MessageProps } from '../../src';
|
|
||||||
import { Message } from '../../src';
|
|
||||||
|
|
||||||
describe('<Message />', () => {
|
|
||||||
const setUp = (props: PropsWithChildren<MessageProps> = {}) => render(<Message {...props} />);
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[true, 'col-md-12'],
|
|
||||||
[false, 'col-md-10 offset-md-1'],
|
|
||||||
[undefined, 'col-md-10 offset-md-1'],
|
|
||||||
])('renders expected classes based on width', (fullWidth, expectedClass) => {
|
|
||||||
const { container } = setUp({ fullWidth });
|
|
||||||
expect(container.firstChild?.firstChild).toHaveClass(expectedClass);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[true, 'These are the children contents'],
|
|
||||||
[false, 'These are the children contents'],
|
|
||||||
[true, undefined],
|
|
||||||
[false, undefined],
|
|
||||||
])('renders expected content', (loading, children) => {
|
|
||||||
setUp({ loading, children });
|
|
||||||
|
|
||||||
expect(screen.queryAllByRole('img', { hidden: true })).toHaveLength(loading ? 1 : 0);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
expect(screen.getByText(children || 'Loading...')).toHaveClass('ms-2');
|
|
||||||
} else {
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent(children || '');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['error', 'border-danger', 'text-danger'],
|
|
||||||
['default', '', 'text-muted'],
|
|
||||||
[undefined, '', 'text-muted'],
|
|
||||||
])('renders proper classes based on message type', (type, expectedCardClass, expectedH3Class) => {
|
|
||||||
const { container } = setUp({ type: type as 'default' | 'error' | undefined });
|
|
||||||
|
|
||||||
expect(container.querySelector('.card-body')).toHaveAttribute('class', expect.stringContaining(expectedCardClass));
|
|
||||||
expect(screen.getByRole('heading')).toHaveClass(`text-center mb-0 ${expectedH3Class}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([{ className: 'foo' }, { className: 'bar' }, {}])('renders provided classes', ({ className }) => {
|
|
||||||
const { container } = setUp({ className });
|
|
||||||
expect(container.firstChild).toHaveClass(`g-0${className ? ` ${className}` : ''}`);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,33 +0,0 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import type { ResultProps, ResultType } from '../../src';
|
|
||||||
import { Result } from '../../src';
|
|
||||||
|
|
||||||
describe('<Result />', () => {
|
|
||||||
const setUp = (props: ResultProps) => render(<Result {...props} />);
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['success' as ResultType, 'bg-main text-white'],
|
|
||||||
['error' as ResultType, 'bg-danger text-white'],
|
|
||||||
['warning' as ResultType, 'bg-warning'],
|
|
||||||
])('renders expected classes based on type', (type, expectedClasses) => {
|
|
||||||
setUp({ type });
|
|
||||||
expect(screen.getByRole('document')).toHaveClass(expectedClasses);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['foo'],
|
|
||||||
['bar'],
|
|
||||||
])('renders provided classes in root element', (className) => {
|
|
||||||
const { container } = setUp({ type: 'success', className });
|
|
||||||
expect(container.firstChild).toHaveClass(className);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([{ small: true }, { small: false }])('renders small results properly', ({ small }) => {
|
|
||||||
const { container } = setUp({ type: 'success', small });
|
|
||||||
const bigElement = container.querySelectorAll('.col-md-10');
|
|
||||||
const smallElement = container.querySelectorAll('.col-12');
|
|
||||||
|
|
||||||
expect(bigElement).toHaveLength(small ? 0 : 1);
|
|
||||||
expect(smallElement).toHaveLength(small ? 1 : 0);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import type { SimpleCardProps } from '../../src';
|
|
||||||
import { SimpleCard } from '../../src';
|
|
||||||
|
|
||||||
const setUp = ({ children, ...rest }: SimpleCardProps = {}) => render(<SimpleCard {...rest}>{children}</SimpleCard>);
|
|
||||||
|
|
||||||
describe('<SimpleCard />', () => {
|
|
||||||
it('does not render title if not provided', () => {
|
|
||||||
setUp();
|
|
||||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders provided title', () => {
|
|
||||||
setUp({ title: 'Cool title' });
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Cool title');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders children inside body', () => {
|
|
||||||
setUp({ children: 'Hello world' });
|
|
||||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['primary', 'danger', 'warning'])('passes extra props to nested card', (color) => {
|
|
||||||
const { container } = setUp({ className: 'foo', color, children: 'Hello world' });
|
|
||||||
expect(container.firstChild).toHaveAttribute('class', `foo card bg-${color}`);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { Checkbox } from '../../src';
|
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<Checkbox />', () => {
|
|
||||||
it.each([['foo'], ['bar'], ['baz']])('includes extra class names when provided', (className) => {
|
|
||||||
const { container } = render(<Checkbox className={className} />);
|
|
||||||
expect(container.firstChild).toHaveAttribute('class', `form-check form-checkbox ${className}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([[true], [false]])('marks input as checked if defined', (checked) => {
|
|
||||||
render(<Checkbox checked={checked}>Foo</Checkbox>);
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
expect(screen.getByLabelText('Foo')).toBeChecked();
|
|
||||||
} else {
|
|
||||||
expect(screen.getByLabelText('Foo')).not.toBeChecked();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([['foo'], ['bar'], ['baz']])('renders provided children inside the label', (children) => {
|
|
||||||
render(<Checkbox>{children}</Checkbox>);
|
|
||||||
expect(screen.getByText(children)).toHaveAttribute('class', 'form-check-label');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([[true], [false]])('changes checked status on input change', async (checked) => {
|
|
||||||
const onChange = vi.fn();
|
|
||||||
const { user } = renderWithEvents(<Checkbox onChange={onChange} checked={checked}>Foo</Checkbox>);
|
|
||||||
|
|
||||||
expect(onChange).not.toHaveBeenCalled();
|
|
||||||
await user.click(screen.getByLabelText('Foo'));
|
|
||||||
expect(onChange).toHaveBeenCalledWith(!checked, expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([[true], [false]])('allows setting inline rendering', (inline) => {
|
|
||||||
const { container } = render(<Checkbox inline={inline} />);
|
|
||||||
|
|
||||||
if (inline) {
|
|
||||||
expect(container.firstChild).toHaveAttribute('style', 'display: inline-block;');
|
|
||||||
} else {
|
|
||||||
expect(container.firstChild).not.toHaveAttribute('style');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,47 +0,0 @@
|
||||||
import { screen } from '@testing-library/react';
|
|
||||||
import type { PropsWithChildren } from 'react';
|
|
||||||
import type { DropdownBtnProps } from '../../src';
|
|
||||||
import { DropdownBtn } from '../../src';
|
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<DropdownBtn />', () => {
|
|
||||||
const setUp = (props: PropsWithChildren<DropdownBtnProps>) => renderWithEvents(
|
|
||||||
<DropdownBtn children="foo" {...props} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each([['foo'], ['bar'], ['baz']])('displays provided text in button', (text) => {
|
|
||||||
setUp({ text });
|
|
||||||
expect(screen.getByRole('button')).toHaveTextContent(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([['foo'], ['bar'], ['baz']])('displays provided children in menu', async (children) => {
|
|
||||||
const { user } = setUp({ text: '', children });
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button'));
|
|
||||||
expect(screen.getByRole('menu')).toHaveTextContent(children);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[undefined, 'dropdown-btn__toggle btn-block'],
|
|
||||||
['', 'dropdown-btn__toggle btn-block'],
|
|
||||||
['foo', 'dropdown-btn__toggle btn-block foo'],
|
|
||||||
['bar', 'dropdown-btn__toggle btn-block bar'],
|
|
||||||
])('includes provided classes', (className, expectedClasses) => {
|
|
||||||
setUp({ text: '', className });
|
|
||||||
expect(screen.getByRole('button')).toHaveClass(expectedClasses);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[100, 'min-width: 100px; '],
|
|
||||||
[250, 'min-width: 250px; '],
|
|
||||||
[undefined, ''],
|
|
||||||
])('renders proper styles when minWidth is provided', async (minWidth, expectedStyle) => {
|
|
||||||
const { user } = setUp({ text: '', minWidth });
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button'));
|
|
||||||
expect(screen.getByRole('menu')).toHaveAttribute(
|
|
||||||
'style',
|
|
||||||
`${expectedStyle}position: absolute; left: 0px; top: 0px; transform: translate(0px, 0px);`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,56 +0,0 @@
|
||||||
/* eslint-disable no-console */
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { NavPillItem, NavPills } from '../../src';
|
|
||||||
|
|
||||||
describe('<NavPills />', () => {
|
|
||||||
let originalError: typeof console.error;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalError = console.error;
|
|
||||||
console.error = () => {}; // Suppress errors logged during this test
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
console.error = originalError;
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['Foo'],
|
|
||||||
[<span key="1">Hi!</span>],
|
|
||||||
[[<NavPillItem key="1" to="" />, <span key="2">Hi!</span>]],
|
|
||||||
])('throws error when any of the children is not a NavPillItem', (children) => {
|
|
||||||
expect.assertions(1);
|
|
||||||
expect(() => render(<NavPills>{children}</NavPills>)).toThrow(
|
|
||||||
'Only NavPillItem children are allowed inside NavPills.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[undefined],
|
|
||||||
[true],
|
|
||||||
[false],
|
|
||||||
])('renders provided items', (fill) => {
|
|
||||||
const { container } = render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<NavPills fill={fill}>
|
|
||||||
<NavPillItem to="1">1</NavPillItem>
|
|
||||||
<NavPillItem to="2">2</NavPillItem>
|
|
||||||
<NavPillItem to="3">3</NavPillItem>
|
|
||||||
</NavPills>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const links = screen.getAllByRole('link');
|
|
||||||
expect(links).toHaveLength(3);
|
|
||||||
links.forEach((link, index) => {
|
|
||||||
expect(link).toHaveTextContent(`${index + 1}`);
|
|
||||||
expect(link).toHaveAttribute('href', `/${index + 1}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fill) {
|
|
||||||
expect(container.querySelector('.nav')).toHaveClass('nav-fill');
|
|
||||||
} else {
|
|
||||||
expect(container.querySelector('.nav')).not.toHaveClass('nav-fill');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,28 +0,0 @@
|
||||||
import { screen } from '@testing-library/react';
|
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
|
||||||
import type { DropdownBtnMenuProps } from '../../src';
|
|
||||||
import { RowDropdownBtn } from '../../src';
|
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<RowDropdownBtn />', () => {
|
|
||||||
const setUp = (props: Partial<DropdownBtnMenuProps> = {}) => renderWithEvents(
|
|
||||||
<RowDropdownBtn {...fromPartial<DropdownBtnMenuProps>({ ...props })}>
|
|
||||||
the children
|
|
||||||
</RowDropdownBtn>,
|
|
||||||
);
|
|
||||||
|
|
||||||
it('renders expected components', () => {
|
|
||||||
setUp();
|
|
||||||
const toggle = screen.getByRole('button');
|
|
||||||
|
|
||||||
expect(toggle).toBeInTheDocument();
|
|
||||||
expect(toggle).toHaveClass('btn-sm');
|
|
||||||
expect(toggle).toHaveClass('dropdown-btn__toggle');
|
|
||||||
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders expected children', () => {
|
|
||||||
setUp();
|
|
||||||
expect(screen.getByText('the children')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,107 +0,0 @@
|
||||||
import { screen } from '@testing-library/react';
|
|
||||||
import { values } from 'ramda';
|
|
||||||
import type { OrderDir, OrderingDropdownProps } from '../../src';
|
|
||||||
import { OrderingDropdown } from '../../src';
|
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<OrderingDropdown />', () => {
|
|
||||||
const items = {
|
|
||||||
foo: 'Foo',
|
|
||||||
bar: 'Bar',
|
|
||||||
baz: 'Hello World',
|
|
||||||
};
|
|
||||||
const setUp = (props: Partial<OrderingDropdownProps> = {}) => renderWithEvents(
|
|
||||||
<OrderingDropdown items={items} order={{}} onChange={vi.fn()} {...props} />,
|
|
||||||
);
|
|
||||||
const setUpWithDisplayedMenu = async (props: Partial<OrderingDropdownProps> = {}) => {
|
|
||||||
const result = setUp(props);
|
|
||||||
const { user } = result;
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button'));
|
|
||||||
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
it('properly renders provided list of items', async () => {
|
|
||||||
await setUpWithDisplayedMenu();
|
|
||||||
|
|
||||||
const dropdownItems = screen.getAllByRole('menuitem');
|
|
||||||
|
|
||||||
expect(dropdownItems).toHaveLength(values(items).length);
|
|
||||||
expect(dropdownItems[0]).toHaveTextContent('Foo');
|
|
||||||
expect(dropdownItems[1]).toHaveTextContent('Bar');
|
|
||||||
expect(dropdownItems[2]).toHaveTextContent('Hello World');
|
|
||||||
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['foo', 0],
|
|
||||||
['bar', 1],
|
|
||||||
['baz', 2],
|
|
||||||
])('properly marks selected field as active with proper icon', async (field, expectedActiveIndex) => {
|
|
||||||
await setUpWithDisplayedMenu({ order: { field, dir: 'DESC' } });
|
|
||||||
|
|
||||||
const dropdownItems = screen.getAllByRole('menuitem');
|
|
||||||
|
|
||||||
expect(dropdownItems).toHaveLength(4);
|
|
||||||
expect(screen.queryByRole('button', { name: 'Clear selection' })).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
dropdownItems.forEach((item, index) => {
|
|
||||||
if (index === expectedActiveIndex) {
|
|
||||||
expect(item).toHaveAttribute('class', expect.stringContaining('active'));
|
|
||||||
} else {
|
|
||||||
expect(item).not.toHaveAttribute('class', expect.stringContaining('active'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[{} as any, 'foo', 'ASC'],
|
|
||||||
[{ field: 'baz', dir: 'ASC' } as any, 'foo', 'ASC'],
|
|
||||||
[{ field: 'foo', dir: 'ASC' } as any, 'foo', 'DESC'],
|
|
||||||
[{ field: 'foo', dir: 'DESC' } as any, undefined, undefined],
|
|
||||||
])(
|
|
||||||
'triggers change with proper params depending on clicked item and initial state',
|
|
||||||
async (initialOrder, expectedNewField, expectedNewDir) => {
|
|
||||||
const onChange = vi.fn();
|
|
||||||
const { user } = await setUpWithDisplayedMenu({ onChange, order: initialOrder });
|
|
||||||
|
|
||||||
await user.click(screen.getAllByRole('menuitem')[0]);
|
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onChange).toHaveBeenCalledWith(expectedNewField, expectedNewDir);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('clears selection when last item is clicked', async () => {
|
|
||||||
const onChange = vi.fn();
|
|
||||||
const { user } = await setUpWithDisplayedMenu({ onChange, order: { field: 'baz', dir: 'ASC' } });
|
|
||||||
|
|
||||||
await user.click(screen.getAllByRole('menuitem')[3]);
|
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onChange).toHaveBeenCalledWith();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[{ isButton: false }, /Order by$/],
|
|
||||||
[{ isButton: true }, 'Order by...'],
|
|
||||||
[
|
|
||||||
{ isButton: true, order: { field: 'foo', dir: 'ASC' as OrderDir } },
|
|
||||||
'Order by: Foo - ASC',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir } },
|
|
||||||
'Order by: Hello World - DESC',
|
|
||||||
],
|
|
||||||
[{ isButton: true, order: { field: 'baz' } }, 'Order by: Hello World - DESC'],
|
|
||||||
[
|
|
||||||
{ isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir }, prefixed: false },
|
|
||||||
/^Hello World - DESC/,
|
|
||||||
],
|
|
||||||
])('with %s props displays %s in toggle', async (props, expectedText) => {
|
|
||||||
setUp(props);
|
|
||||||
expect(screen.getByRole('button')).toHaveTextContent(expectedText);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,46 +0,0 @@
|
||||||
import type { OrderDir } from '../../src';
|
|
||||||
import { determineOrderDir, orderToString, stringToOrder } from '../../src';
|
|
||||||
|
|
||||||
describe('ordering', () => {
|
|
||||||
describe('determineOrderDir', () => {
|
|
||||||
it('returns ASC when current order field and selected field are different', () => {
|
|
||||||
expect(determineOrderDir('foo', 'bar')).toEqual('ASC');
|
|
||||||
expect(determineOrderDir('bar', 'foo')).toEqual('ASC');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns ASC when no current order dir is provided', () => {
|
|
||||||
expect(determineOrderDir('foo', 'foo')).toEqual('ASC');
|
|
||||||
expect(determineOrderDir('bar', 'bar')).toEqual('ASC');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns DESC when current order field and selected field are equal and current order dir is ASC', () => {
|
|
||||||
expect(determineOrderDir('foo', 'foo', 'ASC')).toEqual('DESC');
|
|
||||||
expect(determineOrderDir('bar', 'bar', 'ASC')).toEqual('DESC');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined when current order field and selected field are equal and current order dir is DESC', () => {
|
|
||||||
expect(determineOrderDir('foo', 'foo', 'DESC')).toBeUndefined();
|
|
||||||
expect(determineOrderDir('bar', 'bar', 'DESC')).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('orderToString', () => {
|
|
||||||
it.each([
|
|
||||||
[{}, undefined],
|
|
||||||
[{ field: 'foo' }, undefined],
|
|
||||||
[{ field: 'foo', dir: 'ASC' as OrderDir }, 'foo-ASC'],
|
|
||||||
[{ field: 'bar', dir: 'DESC' as OrderDir }, 'bar-DESC'],
|
|
||||||
])('casts the order to string', (order, expectedResult) => {
|
|
||||||
expect(orderToString(order)).toEqual(expectedResult);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('stringToOrder', () => {
|
|
||||||
it.each([
|
|
||||||
['foo-ASC', { field: 'foo', dir: 'ASC' }],
|
|
||||||
['bar-DESC', { field: 'bar', dir: 'DESC' }],
|
|
||||||
])('casts a string to an order objects', (order, expectedResult) => {
|
|
||||||
expect(stringToOrder(order)).toEqual(expectedResult);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,25 +0,0 @@
|
||||||
import { parseQuery, stringifyQuery } from '../../src/utils';
|
|
||||||
|
|
||||||
describe('query', () => {
|
|
||||||
describe('parseQuery', () => {
|
|
||||||
it.each([
|
|
||||||
['', {}],
|
|
||||||
['foo=bar', { foo: 'bar' }],
|
|
||||||
['?foo=bar', { foo: 'bar' }],
|
|
||||||
['?foo=bar&baz=123', { foo: 'bar', baz: '123' }],
|
|
||||||
])('parses query string as expected', (queryString, expectedResult) => {
|
|
||||||
expect(parseQuery(queryString)).toEqual(expectedResult);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('stringifyQuery', () => {
|
|
||||||
it.each([
|
|
||||||
[{}, ''],
|
|
||||||
[{ foo: 'bar' }, 'foo=bar'],
|
|
||||||
[{ foo: 'bar', baz: '123' }, 'foo=bar&baz=123'],
|
|
||||||
[{ bar: 'foo', list: ['one', 'two'] }, encodeURI('bar=foo&list[]=one&list[]=two')],
|
|
||||||
])('stringifies query as expected', (queryObj, expectedResult) => {
|
|
||||||
expect(stringifyQuery(queryObj)).toEqual(expectedResult);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.shlink-layout__swipeable {
|
.shlink-layout__swipeable {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
.aside-menu {
|
.aside-menu {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
|
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.highlight-card.highlight-card {
|
.highlight-card.highlight-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.short-url-form p:last-child {
|
.short-url-form p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../../utils/mixins/vertical-align';
|
@import '../../utils/mixins/vertical-align';
|
||||||
|
|
||||||
@mixin text-ellipsis() {
|
@mixin text-ellipsis() {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/sticky-cell';
|
@import '../utils/mixins/sticky-cell';
|
||||||
|
|
||||||
.tags-table__header-cell.tags-table__header-cell {
|
.tags-table__header-cell.tags-table__header-cell {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.react-tags {
|
.react-tags {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
@import '../mixins/vertical-align';
|
@import '../mixins/vertical-align';
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.icon-input-container {
|
.icon-input-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
@import '../mixins/vertical-align';
|
@import '../mixins/vertical-align';
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.react-datepicker__close-icon.react-datepicker__close-icon {
|
.react-datepicker__close-icon.react-datepicker__close-icon {
|
||||||
@include vertical-align();
|
@include vertical-align();
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
@mixin sticky-cell($with-separators: true) {
|
@mixin sticky-cell($with-separators: true) {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/sticky-cell';
|
@import '../utils/mixins/sticky-cell';
|
||||||
|
|
||||||
.visits-table {
|
.visits-table {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.doughnut-chart-legend {
|
.doughnut-chart-legend {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.line-chart-card__body canvas {
|
.line-chart-card__body canvas {
|
||||||
height: 300px !important;
|
height: 300px !important;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../../utils/mixins/fit-with-margin';
|
@import '../../utils/mixins/fit-with-margin';
|
||||||
|
|
||||||
.map-modal__modal.map-modal__modal {
|
.map-modal__modal.map-modal__modal {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.app-container {
|
.app-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/horizontal-align';
|
@import '../utils/mixins/horizontal-align';
|
||||||
|
|
||||||
.app-update-banner.app-update-banner {
|
.app-update-banner.app-update-banner {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
$mainCardWidth: 720px;
|
$mainCardWidth: 720px;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.main-header.main-header {
|
.main-header.main-header {
|
||||||
color: white;
|
color: white;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.no-menu-wrapper {
|
.no-menu-wrapper {
|
||||||
padding: 15px 0 0;
|
padding: 15px 0 0;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.shlink-versions-container--with-sidebar {
|
.shlink-versions-container--with-sidebar {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base'; // Before bootstrap stylesheet. Includes SASS var overrides
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet. Includes SASS var overrides
|
||||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||||
@import '@shlinkio/shlink-frontend-kit/index'; // After bootstrap. Includes CSS overwrites
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites
|
||||||
@import '@shlinkio/shlink-web-component/index';
|
@import '@shlinkio/shlink-web-component/index';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
@import '../utils/mixins/thin-scroll';
|
@import '../utils/mixins/thin-scroll';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import '@shlinkio/shlink-frontend-kit/base';
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
.server-error__container {
|
.server-error__container {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
@ -22,7 +22,6 @@
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@shlinkio/shlink-frontend-kit": ["shlink-frontend-kit/src"],
|
|
||||||
"@shlinkio/shlink-web-component": ["shlink-web-component/src"],
|
"@shlinkio/shlink-web-component": ["shlink-web-component/src"],
|
||||||
"@shlinkio/shlink-web-component/api-contract": ["shlink-web-component/src/api-contract"]
|
"@shlinkio/shlink-web-component/api-contract": ["shlink-web-component/src/api-contract"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,6 @@ export default defineConfig({
|
||||||
base: !homepage ? undefined : homepage, // Not using just homepage because empty string should be discarded
|
base: !homepage ? undefined : homepage, // Not using just homepage because empty string should be discarded
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@shlinkio/shlink-frontend-kit': path.resolve(__dirname, './shlink-frontend-kit/src'),
|
|
||||||
'@shlinkio/shlink-web-component': path.resolve(__dirname, './shlink-web-component/src'),
|
'@shlinkio/shlink-web-component': path.resolve(__dirname, './shlink-web-component/src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue