From 983e4db3b135622692d2805f69b7f8cc97ab0ddb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 25 Nov 2020 21:05:27 +0100 Subject: [PATCH 1/7] Created component to allow selecting from existing domains list --- src/container/index.ts | 2 + src/container/types.ts | 2 + src/domains/DomainsDropdown.scss | 20 ++++++ src/domains/DomainsDropdown.tsx | 82 ++++++++++++++++++++++ src/domains/reducers/domainsList.ts | 49 +++++++++++++ src/domains/services/provideServices.ts | 15 ++++ src/reducers/index.ts | 2 + src/short-urls/CreateShortUrl.tsx | 13 +++- src/short-urls/services/provideServices.ts | 9 ++- src/tags/helpers/TagsSelector.tsx | 6 +- src/utils/services/ShlinkApiClient.ts | 5 ++ src/utils/services/types.ts | 9 +++ 12 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 src/domains/DomainsDropdown.scss create mode 100644 src/domains/DomainsDropdown.tsx create mode 100644 src/domains/reducers/domainsList.ts create mode 100644 src/domains/services/provideServices.ts diff --git a/src/container/index.ts b/src/container/index.ts index 83885353..685fbf84 100644 --- a/src/container/index.ts +++ b/src/container/index.ts @@ -11,6 +11,7 @@ import provideTagsServices from '../tags/services/provideServices'; import provideUtilsServices from '../utils/services/provideServices'; import provideMercureServices from '../mercure/services/provideServices'; import provideSettingsServices from '../settings/services/provideServices'; +import provideDomainsServices from '../domains/services/provideServices'; import { ConnectDecorator } from './types'; type LazyActionMap = Record; @@ -41,5 +42,6 @@ provideVisitsServices(bottle, connect); provideUtilsServices(bottle); provideMercureServices(bottle); provideSettingsServices(bottle, connect); +provideDomainsServices(bottle, connect); export default container; diff --git a/src/container/types.ts b/src/container/types.ts index a9ebbde3..d5c4d8e0 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -14,6 +14,7 @@ import { TagsList } from '../tags/reducers/tagsList'; import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail'; import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits'; import { TagVisits } from '../visits/reducers/tagVisits'; +import { DomainsList } from '../domains/reducers/domainsList'; export interface ShlinkState { servers: ServersMap; @@ -33,6 +34,7 @@ export interface ShlinkState { tagEdit: TagEdition; mercureInfo: MercureInfo; settings: Settings; + domainsList: DomainsList; } export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any; diff --git a/src/domains/DomainsDropdown.scss b/src/domains/DomainsDropdown.scss new file mode 100644 index 00000000..7ec41da9 --- /dev/null +++ b/src/domains/DomainsDropdown.scss @@ -0,0 +1,20 @@ +@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: #6c757d; + background-color: white; +} + +.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn::after { + right: .75rem; + + @include vertical-align(); +} + +.domains-dropdown__menu { + width: 100%; +} diff --git a/src/domains/DomainsDropdown.tsx b/src/domains/DomainsDropdown.tsx new file mode 100644 index 00000000..731c524f --- /dev/null +++ b/src/domains/DomainsDropdown.tsx @@ -0,0 +1,82 @@ +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 { useToggle } from '../utils/helpers/hooks'; +import { DomainsList } from './reducers/domainsList'; +import './DomainsDropdown.scss'; + +export interface DomainsDropdownProps extends Omit { + value?: string; + onChange: (domain?: string) => void; +} + +interface DomainsDropdownConnectProps extends DomainsDropdownProps { + listDomains: Function; + domainsList: DomainsList; +} + +export const DomainsDropdown = ({ listDomains, value, domainsList, onChange }: DomainsDropdownConnectProps) => { + const [ inputDisplayed,, showInput, hideInput ] = useToggle(); + const [ isDropdownOpen, toggleDropdown ] = useToggle(); + const { domains } = domainsList; + const defaultDomain = domains.find(({ isDefault }) => isDefault); + const valueIsEmpty = isEmpty(value); + const unselectDomain = () => onChange(''); + + useEffect(() => { + listDomains(); + }, []); + + return inputDisplayed ? ( + + onChange(e.target.value)} + /> + + + + Existing domains + + + + ) : ( + + + Domain: {valueIsEmpty ? defaultDomain?.domain : value} + + + {domains.map(({ domain, isDefault }) => ( + onChange(domain)} + > + {domain} + {isDefault && default} + + ))} + + + New domain + + + + ); +}; diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts new file mode 100644 index 00000000..abb8c15f --- /dev/null +++ b/src/domains/reducers/domainsList.ts @@ -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; +} + +interface ListDomainsAction extends Action { + domains: ShlinkDomain[]; +} + +const initialState: DomainsList = { + domains: [], + loading: false, + error: false, +}; + +export default buildReducer({ + [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({ type: LIST_DOMAINS, domains }); + } catch (e) { + dispatch({ type: LIST_DOMAINS_ERROR }); + } +}; diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts new file mode 100644 index 00000000..61655ee5 --- /dev/null +++ b/src/domains/services/provideServices.ts @@ -0,0 +1,15 @@ +import Bottle from 'bottlejs'; +import { ConnectDecorator } from '../../container/types'; +import { listDomains } from '../reducers/domainsList'; +import { DomainsDropdown } from '../DomainsDropdown'; + +const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { + // Components + bottle.serviceFactory('DomainsDropdown', () => DomainsDropdown); + bottle.decorator('DomainsDropdown', connect([ 'domainsList' ], [ 'listDomains' ])); + + // Actions + bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); +}; + +export default provideServices; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 58bf18ad..6ec599e1 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -16,6 +16,7 @@ import tagDeleteReducer from '../tags/reducers/tagDelete'; import tagEditReducer from '../tags/reducers/tagEdit'; import mercureInfoReducer from '../mercure/reducers/mercureInfo'; import settingsReducer from '../settings/reducers/settings'; +import domainsListReducer from '../domains/reducers/domainsList'; import { ShlinkState } from '../container/types'; export default combineReducers({ @@ -36,4 +37,5 @@ export default combineReducers({ tagEdit: tagEditReducer, mercureInfo: mercureInfoReducer, settings: settingsReducer, + domainsList: domainsListReducer, }); diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx index 8b1a4b50..9c9a072c 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/src/short-urls/CreateShortUrl.tsx @@ -13,6 +13,7 @@ import { useToggle } from '../utils/helpers/hooks'; import { isReachableServer, SelectedServer } from '../servers/data'; import { formatIsoDate } from '../utils/helpers/date'; import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; +import { DomainsDropdownProps } from '../domains/DomainsDropdown'; import { ShortUrlData } from './data'; import { ShortUrlCreation } from './reducers/shortUrlCreation'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; @@ -46,6 +47,7 @@ const CreateShortUrl = ( TagsSelector: FC, CreateShortUrlResult: FC, ForServerVersion: FC, + DomainsDropdown: FC, ) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => { const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState); const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle(); @@ -87,6 +89,7 @@ const CreateShortUrl = ( const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : ''; const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' }); + const showDomainsDropdown = versionMatch(currentServerVersion, { minVersion: '2.4.0' }); const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' }); return ( @@ -123,10 +126,18 @@ const CreateShortUrl = ( })}
- {renderOptionalInput('domain', 'Domain', 'text', { + {!showDomainsDropdown && renderOptionalInput('domain', 'Domain', 'text', { disabled: disableDomain, ...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' }, })} + {showDomainsDropdown && ( + + setShortUrlCreation({ ...shortUrlCreation, domain })} + /> + + )}
diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index b7ee0102..d0ab357c 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -51,7 +51,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { ); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout'); - bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion'); + bottle.serviceFactory( + 'CreateShortUrl', + CreateShortUrl, + 'TagsSelector', + 'CreateShortUrlResult', + 'ForServerVersion', + 'DomainsDropdown', + ); bottle.decorator( 'CreateShortUrl', connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ]), diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx index 7717d198..1611ac03 100644 --- a/src/tags/helpers/TagsSelector.tsx +++ b/src/tags/helpers/TagsSelector.tsx @@ -17,6 +17,8 @@ interface TagsSelectorConnectProps extends TagsSelectorProps { tagsList: TagsList; } +const noop = () => {}; + const TagsSelector = (colorGenerator: ColorGenerator) => ( { tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps, ) => { @@ -55,8 +57,8 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( {suggestion} )} - onSuggestionsFetchRequested={() => {}} - onSuggestionsClearRequested={() => {}} + onSuggestionsFetchRequested={noop} + onSuggestionsClearRequested={noop} onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData) => { addTag(suggestion); }} diff --git a/src/utils/services/ShlinkApiClient.ts b/src/utils/services/ShlinkApiClient.ts index a4db10ad..03f5fa71 100644 --- a/src/utils/services/ShlinkApiClient.ts +++ b/src/utils/services/ShlinkApiClient.ts @@ -13,6 +13,8 @@ import { ShlinkVisits, ShlinkVisitsParams, ShlinkShortUrlMeta, + ShlinkDomain, + ShlinkDomainsResponse, } from './types'; const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : ''; @@ -93,6 +95,9 @@ export default class ShlinkApiClient { this.performRequest('/mercure-info', 'GET') .then((resp) => resp.data); + public readonly listDomains = async (): Promise => + this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data); + private readonly performRequest = async (url: string, method: Method = 'GET', query = {}, body = {}): Promise> => { try { return await this.axios({ diff --git a/src/utils/services/types.ts b/src/utils/services/types.ts index 9c10ce7d..df7687c3 100644 --- a/src/utils/services/types.ts +++ b/src/utils/services/types.ts @@ -64,3 +64,12 @@ export interface ProblemDetailsError { message?: string; // Deprecated [extraProps: string]: any; } + +export interface ShlinkDomain { + domain: string; + isDefault: boolean; +} + +export interface ShlinkDomainsResponse { + data: ShlinkDomain[]; +} From 369fcf2f6a61517b665412c37ad361e3b369891f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Nov 2020 09:34:41 +0100 Subject: [PATCH 2/7] Improved design on domains dropdown --- src/domains/DomainsDropdown.scss | 8 +++++++- src/domains/DomainsDropdown.tsx | 7 ++++--- src/short-urls/CreateShortUrl.tsx | 1 + src/utils/BooleanControl.tsx | 6 ++++-- test/short-urls/CreateShortUrl.test.tsx | 2 +- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/domains/DomainsDropdown.scss b/src/domains/DomainsDropdown.scss index 7ec41da9..ba789483 100644 --- a/src/domains/DomainsDropdown.scss +++ b/src/domains/DomainsDropdown.scss @@ -5,10 +5,16 @@ .domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active { text-align: left; color: #6c757d; - border-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__toggle-btn.domains-dropdown__toggle-btn::after { right: .75rem; diff --git a/src/domains/DomainsDropdown.tsx b/src/domains/DomainsDropdown.tsx index 731c524f..c46ef3b5 100644 --- a/src/domains/DomainsDropdown.tsx +++ b/src/domains/DomainsDropdown.tsx @@ -14,6 +14,7 @@ import { 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 './DomainsDropdown.scss'; @@ -32,7 +33,6 @@ export const DomainsDropdown = ({ listDomains, value, domainsList, onChange }: D const [ inputDisplayed,, showInput, hideInput ] = useToggle(); const [ isDropdownOpen, toggleDropdown ] = useToggle(); const { domains } = domainsList; - const defaultDomain = domains.find(({ isDefault }) => isDefault); const valueIsEmpty = isEmpty(value); const unselectDomain = () => onChange(''); @@ -58,8 +58,9 @@ export const DomainsDropdown = ({ listDomains, value, domainsList, onChange }: D ) : ( - - Domain: {valueIsEmpty ? defaultDomain?.domain : value} + + {valueIsEmpty && <>Domain} + {!valueIsEmpty && <>Domain: {value}} {domains.map(({ domain, isDefault }) => ( diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx index 9c9a072c..963f039d 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/src/short-urls/CreateShortUrl.tsx @@ -156,6 +156,7 @@ const CreateShortUrl = (
setShortUrlCreation({ ...shortUrlCreation, findIfExists })} diff --git a/src/utils/BooleanControl.tsx b/src/utils/BooleanControl.tsx index 9b25b69c..72f7056e 100644 --- a/src/utils/BooleanControl.tsx +++ b/src/utils/BooleanControl.tsx @@ -7,6 +7,7 @@ export interface BooleanControlProps { checked?: boolean; onChange?: (checked: boolean, e: ChangeEvent) => void; className?: string; + inline?: boolean; } interface BooleanControlWithTypeProps extends BooleanControlProps { @@ -14,7 +15,7 @@ interface BooleanControlWithTypeProps extends BooleanControlProps { } const BooleanControl: FC = ( - { checked = false, onChange = identity, className, children, type }, + { checked = false, onChange = identity, className, children, type, inline = false }, ) => { const id = uuid(); const onChecked = (e: ChangeEvent) => onChange(e.target.checked, e); @@ -22,9 +23,10 @@ const BooleanControl: FC = ( 'custom-switch': type === 'switch', 'custom-checkbox': type === 'checkbox', }; + const style = inline ? { display: 'inline-block' } : {}; return ( - + diff --git a/test/short-urls/CreateShortUrl.test.tsx b/test/short-urls/CreateShortUrl.test.tsx index 13493238..4aeb72bb 100644 --- a/test/short-urls/CreateShortUrl.test.tsx +++ b/test/short-urls/CreateShortUrl.test.tsx @@ -13,7 +13,7 @@ describe('', () => { const createShortUrl = jest.fn(async () => Promise.resolve()); beforeEach(() => { - const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => null, () => null); + const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => null, () => null, () => null); wrapper = shallow( Date: Sat, 28 Nov 2020 09:58:05 +0100 Subject: [PATCH 3/7] Renamed DomainsDropdown to DomainSelector --- ...mainsDropdown.scss => DomainSelector.scss} | 5 ++++ ...DomainsDropdown.tsx => DomainSelector.tsx} | 24 ++++++++++++++----- src/domains/services/provideServices.ts | 6 ++--- src/short-urls/CreateShortUrl.tsx | 12 +++++----- src/short-urls/services/provideServices.ts | 2 +- 5 files changed, 33 insertions(+), 16 deletions(-) rename src/domains/{DomainsDropdown.scss => DomainSelector.scss} (83%) rename src/domains/{DomainsDropdown.tsx => DomainSelector.tsx} (76%) diff --git a/src/domains/DomainsDropdown.scss b/src/domains/DomainSelector.scss similarity index 83% rename from src/domains/DomainsDropdown.scss rename to src/domains/DomainSelector.scss index ba789483..c9ab0ba0 100644 --- a/src/domains/DomainsDropdown.scss +++ b/src/domains/DomainSelector.scss @@ -15,6 +15,11 @@ 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; diff --git a/src/domains/DomainsDropdown.tsx b/src/domains/DomainSelector.tsx similarity index 76% rename from src/domains/DomainsDropdown.tsx rename to src/domains/DomainSelector.tsx index c46ef3b5..fe3c6780 100644 --- a/src/domains/DomainsDropdown.tsx +++ b/src/domains/DomainSelector.tsx @@ -17,19 +17,19 @@ import { isEmpty, pipe } from 'ramda'; import classNames from 'classnames'; import { useToggle } from '../utils/helpers/hooks'; import { DomainsList } from './reducers/domainsList'; -import './DomainsDropdown.scss'; +import './DomainSelector.scss'; -export interface DomainsDropdownProps extends Omit { +export interface DomainSelectorProps extends Omit { value?: string; onChange: (domain?: string) => void; } -interface DomainsDropdownConnectProps extends DomainsDropdownProps { +interface DomainSelectorConnectProps extends DomainSelectorProps { listDomains: Function; domainsList: DomainsList; } -export const DomainsDropdown = ({ listDomains, value, domainsList, onChange }: DomainsDropdownConnectProps) => { +export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => { const [ inputDisplayed,, showInput, hideInput ] = useToggle(); const [ isDropdownOpen, toggleDropdown ] = useToggle(); const { domains } = domainsList; @@ -48,7 +48,13 @@ export const DomainsDropdown = ({ listDomains, value, domainsList, onChange }: D onChange={(e) => onChange(e.target.value)} /> - @@ -58,7 +64,13 @@ export const DomainsDropdown = ({ listDomains, value, domainsList, onChange }: D ) : ( - + {valueIsEmpty && <>Domain} {!valueIsEmpty && <>Domain: {value}} diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts index 61655ee5..bd56d8a2 100644 --- a/src/domains/services/provideServices.ts +++ b/src/domains/services/provideServices.ts @@ -1,12 +1,12 @@ import Bottle from 'bottlejs'; import { ConnectDecorator } from '../../container/types'; import { listDomains } from '../reducers/domainsList'; -import { DomainsDropdown } from '../DomainsDropdown'; +import { DomainSelector } from '../DomainSelector'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components - bottle.serviceFactory('DomainsDropdown', () => DomainsDropdown); - bottle.decorator('DomainsDropdown', connect([ 'domainsList' ], [ 'listDomains' ])); + bottle.serviceFactory('DomainSelector', () => DomainSelector); + bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ])); // Actions bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx index 963f039d..da07dffa 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/src/short-urls/CreateShortUrl.tsx @@ -13,7 +13,7 @@ import { useToggle } from '../utils/helpers/hooks'; import { isReachableServer, SelectedServer } from '../servers/data'; import { formatIsoDate } from '../utils/helpers/date'; import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; -import { DomainsDropdownProps } from '../domains/DomainsDropdown'; +import { DomainSelectorProps } from '../domains/DomainSelector'; import { ShortUrlData } from './data'; import { ShortUrlCreation } from './reducers/shortUrlCreation'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; @@ -47,7 +47,7 @@ const CreateShortUrl = ( TagsSelector: FC, CreateShortUrlResult: FC, ForServerVersion: FC, - DomainsDropdown: FC, + DomainSelector: FC, ) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => { const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState); const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle(); @@ -89,7 +89,7 @@ const CreateShortUrl = ( const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : ''; const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' }); - const showDomainsDropdown = versionMatch(currentServerVersion, { minVersion: '2.4.0' }); + const showDomainSelector = versionMatch(currentServerVersion, { minVersion: '2.4.0' }); const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' }); return ( @@ -126,13 +126,13 @@ const CreateShortUrl = ( })}
- {!showDomainsDropdown && renderOptionalInput('domain', 'Domain', 'text', { + {!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text', { disabled: disableDomain, ...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' }, })} - {showDomainsDropdown && ( + {showDomainSelector && ( - setShortUrlCreation({ ...shortUrlCreation, domain })} /> diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index d0ab357c..0735af20 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -57,7 +57,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion', - 'DomainsDropdown', + 'DomainSelector', ); bottle.decorator( 'CreateShortUrl', From dc397d4b825a471f8a373626559cc8baf6ca0e76 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Nov 2020 11:45:04 +0100 Subject: [PATCH 4/7] Improved existing tests --- test/utils/Checkbox.test.tsx | 7 +++++++ test/utils/services/ShlinkApiClient.test.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/test/utils/Checkbox.test.tsx b/test/utils/Checkbox.test.tsx index f9d08d48..444924f4 100644 --- a/test/utils/Checkbox.test.tsx +++ b/test/utils/Checkbox.test.tsx @@ -60,4 +60,11 @@ describe('', () => { 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' }); + }); }); diff --git a/test/utils/services/ShlinkApiClient.test.ts b/test/utils/services/ShlinkApiClient.test.ts index b581885d..4838712f 100644 --- a/test/utils/services/ShlinkApiClient.test.ts +++ b/test/utils/services/ShlinkApiClient.test.ts @@ -1,6 +1,8 @@ import { AxiosInstance, AxiosRequestConfig } from 'axios'; import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; import { OptionalString } from '../../../src/utils/utils'; +import { Mock } from 'ts-mockery'; +import { ShlinkDomain } from '../../../src/utils/services/types'; describe('ShlinkApiClient', () => { const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance; @@ -251,4 +253,20 @@ describe('ShlinkApiClient', () => { expect(result).toEqual(expectedData); }); }); + + describe('listDomains', () => { + it('returns domains', async () => { + const expectedData = [Mock.all(), Mock.all()]; + 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); + }); + }); }); From 02c712523674125998ce97f7618e69255b27eabe Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Nov 2020 12:22:52 +0100 Subject: [PATCH 5/7] Created domainsList reducer test --- src/domains/reducers/domainsList.ts | 2 +- test/domains/reducers/domainsList.test.ts | 63 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/domains/reducers/domainsList.test.ts diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index abb8c15f..4a51acab 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -16,7 +16,7 @@ export interface DomainsList { error: boolean; } -interface ListDomainsAction extends Action { +export interface ListDomainsAction extends Action { domains: ShlinkDomain[]; } diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts new file mode 100644 index 00000000..1e090770 --- /dev/null +++ b/test/domains/reducers/domainsList.test.ts @@ -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(), Mock.all(), Mock.all() ]; + + describe('reducer', () => { + const action = (type: string, args: Partial = {}) => Mock.of( + { 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({ 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); + }); + }); +}); From ff48c0cd45cdd6b7afeb5e853c4019a34edb0acc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Nov 2020 12:36:40 +0100 Subject: [PATCH 6/7] Added DomainSelector test --- src/domains/DomainSelector.tsx | 2 +- test/domains/DomainSelector.test.tsx | 42 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 test/domains/DomainSelector.test.tsx diff --git a/src/domains/DomainSelector.tsx b/src/domains/DomainSelector.tsx index fe3c6780..ec53331e 100644 --- a/src/domains/DomainSelector.tsx +++ b/src/domains/DomainSelector.tsx @@ -21,7 +21,7 @@ import './DomainSelector.scss'; export interface DomainSelectorProps extends Omit { value?: string; - onChange: (domain?: string) => void; + onChange: (domain: string) => void; } interface DomainSelectorConnectProps extends DomainSelectorProps { diff --git a/test/domains/DomainSelector.test.tsx b/test/domains/DomainSelector.test.tsx new file mode 100644 index 00000000..2e7a3d2f --- /dev/null +++ b/test/domains/DomainSelector.test.tsx @@ -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('', () => { + let wrapper: ShallowWrapper; + const domainsList = Mock.of({ + domains: [ + Mock.of({ domain: 'foo.com' }), + Mock.of({ domain: 'bar.com' }), + ], + }); + + beforeEach(() => { + wrapper = shallow(); + }); + + 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); + }); +}); From 4e1579832efcbbe24944fe9687d1f312218c88f8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Nov 2020 12:38:16 +0100 Subject: [PATCH 7/7] Updated changelog --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7021d265..1ce3226f 100644 --- a/CHANGELOG.md +++ b/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). +## [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 ### Added * *Nothing*