mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-03-28 08:39:13 +03:00
Merge pull request #339 from acelaya-forks/feature/domains-dropdown
Feature/domains dropdown
This commit is contained in:
commit
5182f9d147
19 changed files with 387 additions and 7 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
* [#309](https://github.com/shlinkio/shlink-web-client/issues/309) Added new domain selector component in create URL form which allows selecting from previously used domains or set a new one.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## [2.6.2] - 2020-11-14
|
## [2.6.2] - 2020-11-14
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
|
@ -11,6 +11,7 @@ import provideTagsServices from '../tags/services/provideServices';
|
||||||
import provideUtilsServices from '../utils/services/provideServices';
|
import provideUtilsServices from '../utils/services/provideServices';
|
||||||
import provideMercureServices from '../mercure/services/provideServices';
|
import provideMercureServices from '../mercure/services/provideServices';
|
||||||
import provideSettingsServices from '../settings/services/provideServices';
|
import provideSettingsServices from '../settings/services/provideServices';
|
||||||
|
import provideDomainsServices from '../domains/services/provideServices';
|
||||||
import { ConnectDecorator } from './types';
|
import { ConnectDecorator } from './types';
|
||||||
|
|
||||||
type LazyActionMap = Record<string, Function>;
|
type LazyActionMap = Record<string, Function>;
|
||||||
|
@ -41,5 +42,6 @@ provideVisitsServices(bottle, connect);
|
||||||
provideUtilsServices(bottle);
|
provideUtilsServices(bottle);
|
||||||
provideMercureServices(bottle);
|
provideMercureServices(bottle);
|
||||||
provideSettingsServices(bottle, connect);
|
provideSettingsServices(bottle, connect);
|
||||||
|
provideDomainsServices(bottle, connect);
|
||||||
|
|
||||||
export default container;
|
export default container;
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { TagsList } from '../tags/reducers/tagsList';
|
||||||
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
||||||
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||||
import { TagVisits } from '../visits/reducers/tagVisits';
|
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||||
|
import { DomainsList } from '../domains/reducers/domainsList';
|
||||||
|
|
||||||
export interface ShlinkState {
|
export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
|
@ -33,6 +34,7 @@ export interface ShlinkState {
|
||||||
tagEdit: TagEdition;
|
tagEdit: TagEdition;
|
||||||
mercureInfo: MercureInfo;
|
mercureInfo: MercureInfo;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
domainsList: DomainsList;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|
31
src/domains/DomainSelector.scss
Normal file
31
src/domains/DomainSelector.scss
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
|
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
|
||||||
|
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover,
|
||||||
|
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active {
|
||||||
|
text-align: left;
|
||||||
|
color: #6c757d;
|
||||||
|
border-color: #ced4da;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active,
|
||||||
|
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
|
||||||
|
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domains-dropdown__back-btn.domains-dropdown__back-btn,
|
||||||
|
.domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
|
||||||
|
border-color: #ced4da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn::after {
|
||||||
|
right: .75rem;
|
||||||
|
|
||||||
|
@include vertical-align();
|
||||||
|
}
|
||||||
|
|
||||||
|
.domains-dropdown__menu {
|
||||||
|
width: 100%;
|
||||||
|
}
|
95
src/domains/DomainSelector.tsx
Normal file
95
src/domains/DomainSelector.tsx
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownToggle,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputProps,
|
||||||
|
UncontrolledTooltip,
|
||||||
|
} from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { isEmpty, pipe } from 'ramda';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { DomainsList } from './reducers/domainsList';
|
||||||
|
import './DomainSelector.scss';
|
||||||
|
|
||||||
|
export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> {
|
||||||
|
value?: string;
|
||||||
|
onChange: (domain: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainSelectorConnectProps extends DomainSelectorProps {
|
||||||
|
listDomains: Function;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
|
||||||
|
const [ inputDisplayed,, showInput, hideInput ] = useToggle();
|
||||||
|
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
||||||
|
const { domains } = domainsList;
|
||||||
|
const valueIsEmpty = isEmpty(value);
|
||||||
|
const unselectDomain = () => onChange('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listDomains();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return inputDisplayed ? (
|
||||||
|
<InputGroup>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
placeholder="Domain"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon addonType="append">
|
||||||
|
<Button
|
||||||
|
id="backToDropdown"
|
||||||
|
outline
|
||||||
|
type="button"
|
||||||
|
className="domains-dropdown__back-btn"
|
||||||
|
onClick={pipe(unselectDomain, hideInput)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUndo} />
|
||||||
|
</Button>
|
||||||
|
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
||||||
|
Existing domains
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
) : (
|
||||||
|
<Dropdown isOpen={isDropdownOpen} toggle={toggleDropdown}>
|
||||||
|
<DropdownToggle
|
||||||
|
caret
|
||||||
|
className={classNames(
|
||||||
|
'domains-dropdown__toggle-btn btn-block',
|
||||||
|
{ 'domains-dropdown__toggle-btn--active': !valueIsEmpty },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{valueIsEmpty && <>Domain</>}
|
||||||
|
{!valueIsEmpty && <>Domain: {value}</>}
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu className="domains-dropdown__menu">
|
||||||
|
{domains.map(({ domain, isDefault }) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={domain}
|
||||||
|
active={value === domain || isDefault && valueIsEmpty}
|
||||||
|
onClick={() => onChange(domain)}
|
||||||
|
>
|
||||||
|
{domain}
|
||||||
|
{isDefault && <span className="float-right text-muted">default</span>}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
<DropdownItem divider />
|
||||||
|
<DropdownItem onClick={pipe(unselectDomain, showInput)}>
|
||||||
|
<i>New domain</i>
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
49
src/domains/reducers/domainsList.ts
Normal file
49
src/domains/reducers/domainsList.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { Action, Dispatch } from 'redux';
|
||||||
|
import { ShlinkDomain } from '../../utils/services/types';
|
||||||
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
|
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||||
|
import { GetState } from '../../container/types';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||||
|
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||||
|
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export interface DomainsList {
|
||||||
|
domains: ShlinkDomain[];
|
||||||
|
loading: boolean;
|
||||||
|
error: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListDomainsAction extends Action<string> {
|
||||||
|
domains: ShlinkDomain[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: DomainsList = {
|
||||||
|
domains: [],
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildReducer<DomainsList, ListDomainsAction>({
|
||||||
|
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||||
|
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
|
||||||
|
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||||
|
dispatch: Dispatch,
|
||||||
|
getState: GetState,
|
||||||
|
) => {
|
||||||
|
dispatch({ type: LIST_DOMAINS_START });
|
||||||
|
const { listDomains } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const domains = await listDomains();
|
||||||
|
|
||||||
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: LIST_DOMAINS_ERROR });
|
||||||
|
}
|
||||||
|
};
|
15
src/domains/services/provideServices.ts
Normal file
15
src/domains/services/provideServices.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
import { listDomains } from '../reducers/domainsList';
|
||||||
|
import { DomainSelector } from '../DomainSelector';
|
||||||
|
|
||||||
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||||
|
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
|
@ -16,6 +16,7 @@ import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||||
import settingsReducer from '../settings/reducers/settings';
|
import settingsReducer from '../settings/reducers/settings';
|
||||||
|
import domainsListReducer from '../domains/reducers/domainsList';
|
||||||
import { ShlinkState } from '../container/types';
|
import { ShlinkState } from '../container/types';
|
||||||
|
|
||||||
export default combineReducers<ShlinkState>({
|
export default combineReducers<ShlinkState>({
|
||||||
|
@ -36,4 +37,5 @@ export default combineReducers<ShlinkState>({
|
||||||
tagEdit: tagEditReducer,
|
tagEdit: tagEditReducer,
|
||||||
mercureInfo: mercureInfoReducer,
|
mercureInfo: mercureInfoReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
|
domainsList: domainsListReducer,
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
|
import { DomainSelectorProps } from '../domains/DomainSelector';
|
||||||
import { ShortUrlData } from './data';
|
import { ShortUrlData } from './data';
|
||||||
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||||
|
@ -46,6 +47,7 @@ const CreateShortUrl = (
|
||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||||
ForServerVersion: FC<Versions>,
|
ForServerVersion: FC<Versions>,
|
||||||
|
DomainSelector: FC<DomainSelectorProps>,
|
||||||
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
|
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
|
||||||
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
||||||
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
||||||
|
@ -87,6 +89,7 @@ const CreateShortUrl = (
|
||||||
|
|
||||||
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
|
const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
|
||||||
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
||||||
|
const showDomainSelector = versionMatch(currentServerVersion, { minVersion: '2.4.0' });
|
||||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -123,10 +126,18 @@ const CreateShortUrl = (
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-4">
|
<div className="col-sm-4">
|
||||||
{renderOptionalInput('domain', 'Domain', 'text', {
|
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text', {
|
||||||
disabled: disableDomain,
|
disabled: disableDomain,
|
||||||
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
||||||
})}
|
})}
|
||||||
|
{showDomainSelector && (
|
||||||
|
<FormGroup>
|
||||||
|
<DomainSelector
|
||||||
|
value={shortUrlCreation.domain}
|
||||||
|
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -145,6 +156,7 @@ const CreateShortUrl = (
|
||||||
<ForServerVersion minVersion="1.16.0">
|
<ForServerVersion minVersion="1.16.0">
|
||||||
<div className="mb-4 text-right">
|
<div className="mb-4 text-right">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
inline
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
checked={shortUrlCreation.findIfExists}
|
checked={shortUrlCreation.findIfExists}
|
||||||
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
||||||
|
|
|
@ -51,7 +51,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
);
|
);
|
||||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
|
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
|
||||||
|
|
||||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
|
bottle.serviceFactory(
|
||||||
|
'CreateShortUrl',
|
||||||
|
CreateShortUrl,
|
||||||
|
'TagsSelector',
|
||||||
|
'CreateShortUrlResult',
|
||||||
|
'ForServerVersion',
|
||||||
|
'DomainSelector',
|
||||||
|
);
|
||||||
bottle.decorator(
|
bottle.decorator(
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
|
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
|
||||||
|
|
|
@ -17,6 +17,8 @@ interface TagsSelectorConnectProps extends TagsSelectorProps {
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||||
{ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
|
{ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
|
||||||
) => {
|
) => {
|
||||||
|
@ -55,8 +57,8 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
onSuggestionsFetchRequested={() => {}}
|
onSuggestionsFetchRequested={noop}
|
||||||
onSuggestionsClearRequested={() => {}}
|
onSuggestionsClearRequested={noop}
|
||||||
onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
|
onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
|
||||||
addTag(suggestion);
|
addTag(suggestion);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -7,6 +7,7 @@ export interface BooleanControlProps {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
|
onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BooleanControlWithTypeProps extends BooleanControlProps {
|
interface BooleanControlWithTypeProps extends BooleanControlProps {
|
||||||
|
@ -14,7 +15,7 @@ interface BooleanControlWithTypeProps extends BooleanControlProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const BooleanControl: FC<BooleanControlWithTypeProps> = (
|
const BooleanControl: FC<BooleanControlWithTypeProps> = (
|
||||||
{ checked = false, onChange = identity, className, children, type },
|
{ checked = false, onChange = identity, className, children, type, inline = false },
|
||||||
) => {
|
) => {
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
const onChecked = (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked, e);
|
const onChecked = (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked, e);
|
||||||
|
@ -22,9 +23,10 @@ const BooleanControl: FC<BooleanControlWithTypeProps> = (
|
||||||
'custom-switch': type === 'switch',
|
'custom-switch': type === 'switch',
|
||||||
'custom-checkbox': type === 'checkbox',
|
'custom-checkbox': type === 'checkbox',
|
||||||
};
|
};
|
||||||
|
const style = inline ? { display: 'inline-block' } : {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={classNames('custom-control', typeClasses, className)}>
|
<span className={classNames('custom-control', typeClasses, className)} style={style}>
|
||||||
<input type="checkbox" className="custom-control-input" id={id} checked={checked} onChange={onChecked} />
|
<input type="checkbox" className="custom-control-input" id={id} checked={checked} onChange={onChecked} />
|
||||||
<label className="custom-control-label" htmlFor={id}>{children}</label>
|
<label className="custom-control-label" htmlFor={id}>{children}</label>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -13,6 +13,8 @@ import {
|
||||||
ShlinkVisits,
|
ShlinkVisits,
|
||||||
ShlinkVisitsParams,
|
ShlinkVisitsParams,
|
||||||
ShlinkShortUrlMeta,
|
ShlinkShortUrlMeta,
|
||||||
|
ShlinkDomain,
|
||||||
|
ShlinkDomainsResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||||
|
@ -93,6 +95,9 @@ export default class ShlinkApiClient {
|
||||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||||
.then((resp) => resp.data);
|
.then((resp) => resp.data);
|
||||||
|
|
||||||
|
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
||||||
|
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
||||||
|
|
||||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
||||||
try {
|
try {
|
||||||
return await this.axios({
|
return await this.axios({
|
||||||
|
|
|
@ -64,3 +64,12 @@ export interface ProblemDetailsError {
|
||||||
message?: string; // Deprecated
|
message?: string; // Deprecated
|
||||||
[extraProps: string]: any;
|
[extraProps: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShlinkDomain {
|
||||||
|
domain: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkDomainsResponse {
|
||||||
|
data: ShlinkDomain[];
|
||||||
|
}
|
||||||
|
|
42
test/domains/DomainSelector.test.tsx
Normal file
42
test/domains/DomainSelector.test.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { DropdownItem, DropdownMenu, InputGroup } from 'reactstrap';
|
||||||
|
import { DomainSelector } from '../../src/domains/DomainSelector';
|
||||||
|
import { DomainsList } from '../../src/domains/reducers/domainsList';
|
||||||
|
import { ShlinkDomain } from '../../src/utils/services/types';
|
||||||
|
|
||||||
|
describe('<DomainSelector />', () => {
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const domainsList = Mock.of<DomainsList>({
|
||||||
|
domains: [
|
||||||
|
Mock.of<ShlinkDomain>({ domain: 'foo.com' }),
|
||||||
|
Mock.of<ShlinkDomain>({ domain: 'bar.com' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = shallow(<DomainSelector domainsList={domainsList} listDomains={jest.fn()} onChange={jest.fn()} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
|
afterEach(() => wrapper.unmount());
|
||||||
|
|
||||||
|
it('shows dropdown by default', () => {
|
||||||
|
const input = wrapper.find(InputGroup);
|
||||||
|
const dropdown = wrapper.find(DropdownMenu);
|
||||||
|
|
||||||
|
expect(input).toHaveLength(0);
|
||||||
|
expect(dropdown).toHaveLength(1);
|
||||||
|
expect(dropdown.find(DropdownItem)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows to toggle between dropdown and input', () => {
|
||||||
|
wrapper.find(DropdownItem).last().simulate('click');
|
||||||
|
expect(wrapper.find(InputGroup)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(DropdownMenu)).toHaveLength(0);
|
||||||
|
|
||||||
|
wrapper.find('.domains-dropdown__back-btn').simulate('click');
|
||||||
|
expect(wrapper.find(InputGroup)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(DropdownMenu)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
63
test/domains/reducers/domainsList.test.ts
Normal file
63
test/domains/reducers/domainsList.test.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import reducer, {
|
||||||
|
LIST_DOMAINS,
|
||||||
|
LIST_DOMAINS_ERROR,
|
||||||
|
LIST_DOMAINS_START,
|
||||||
|
ListDomainsAction,
|
||||||
|
listDomains as listDomainsAction,
|
||||||
|
} from '../../../src/domains/reducers/domainsList';
|
||||||
|
import { ShlinkDomain } from '../../../src/utils/services/types';
|
||||||
|
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
|
||||||
|
|
||||||
|
describe('domainsList', () => {
|
||||||
|
const domains = [ Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>() ];
|
||||||
|
|
||||||
|
describe('reducer', () => {
|
||||||
|
const action = (type: string, args: Partial<ListDomainsAction> = {}) => Mock.of<ListDomainsAction>(
|
||||||
|
{ type, ...args },
|
||||||
|
);
|
||||||
|
|
||||||
|
it('returns loading on LIST_DOMAINS_START', () => {
|
||||||
|
expect(reducer(undefined, action(LIST_DOMAINS_START))).toEqual({ domains: [], loading: true, error: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error on LIST_DOMAINS_ERROR', () => {
|
||||||
|
expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual({ domains: [], loading: false, error: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns domains on LIST_DOMAINS', () => {
|
||||||
|
expect(reducer(undefined, action(LIST_DOMAINS, { domains }))).toEqual({ domains, loading: false, error: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listDomains', () => {
|
||||||
|
const dispatch = jest.fn();
|
||||||
|
const getState = jest.fn();
|
||||||
|
const listDomains = jest.fn();
|
||||||
|
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ listDomains });
|
||||||
|
|
||||||
|
beforeEach(jest.clearAllMocks);
|
||||||
|
|
||||||
|
it('dispatches error when loading domains fails', async () => {
|
||||||
|
listDomains.mockRejectedValue(new Error('error'));
|
||||||
|
|
||||||
|
await listDomainsAction(buildShlinkApiClient)()(dispatch, getState);
|
||||||
|
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS_ERROR });
|
||||||
|
expect(listDomains).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches domains once loaded', async () => {
|
||||||
|
listDomains.mockResolvedValue(domains);
|
||||||
|
|
||||||
|
await listDomainsAction(buildShlinkApiClient)()(dispatch, getState);
|
||||||
|
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS, domains });
|
||||||
|
expect(listDomains).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -13,7 +13,7 @@ describe('<CreateShortUrl />', () => {
|
||||||
const createShortUrl = jest.fn(async () => Promise.resolve());
|
const createShortUrl = jest.fn(async () => Promise.resolve());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => null, () => null);
|
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => null, () => null, () => null);
|
||||||
|
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<CreateShortUrl
|
<CreateShortUrl
|
||||||
|
|
|
@ -60,4 +60,11 @@ describe('<Checkbox />', () => {
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledWith(false, e);
|
expect(onChange).toHaveBeenCalledWith(false, e);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows setting inline rendering', () => {
|
||||||
|
const wrapped = createComponent({ inline: true });
|
||||||
|
const control = wrapped.find('.custom-control');
|
||||||
|
|
||||||
|
expect(control.prop('style')).toEqual({ display: 'inline-block' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { AxiosInstance, AxiosRequestConfig } from 'axios';
|
import { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||||
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
|
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
|
||||||
import { OptionalString } from '../../../src/utils/utils';
|
import { OptionalString } from '../../../src/utils/utils';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { ShlinkDomain } from '../../../src/utils/services/types';
|
||||||
|
|
||||||
describe('ShlinkApiClient', () => {
|
describe('ShlinkApiClient', () => {
|
||||||
const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
|
const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
|
||||||
|
@ -251,4 +253,20 @@ describe('ShlinkApiClient', () => {
|
||||||
expect(result).toEqual(expectedData);
|
expect(result).toEqual(expectedData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('listDomains', () => {
|
||||||
|
it('returns domains', async () => {
|
||||||
|
const expectedData = [Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>()];
|
||||||
|
const resp = {
|
||||||
|
domains: { data: expectedData },
|
||||||
|
};
|
||||||
|
const axiosSpy = createAxiosMock({ data: resp });
|
||||||
|
const { listDomains } = new ShlinkApiClient(axiosSpy, '', '');
|
||||||
|
|
||||||
|
const result = await listDomains();
|
||||||
|
|
||||||
|
expect(axiosSpy).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(expectedData);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue