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[]; +}