diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c09131..8684440f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ 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). +## [3.5.0] - 2022-01-01 +### Added +* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter". + + Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit. + + If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval. + +* [#547](https://github.com/shlinkio/shlink-web-client/pull/547) Improved domains page, to tell which of the domains are not properly configured. + + Now, when this section is loaded, it tries to call the `GET /rest/health` endpoint for each one of the domains, and displays a warning icon on each one that failed. + + The warning includes a link to the documentation, explaining what are the steps to get it fixed. + +* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist. +* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. +* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. +* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs. +* [#542](https://github.com/shlinkio/shlink-web-client/pull/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params. + +### Changed +* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. +* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `-` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0 + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [3.4.2] - 2021-12-07 ### Added * *Nothing* diff --git a/package-lock.json b/package-lock.json index 7e92e210..edbb0d32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@fortawesome/free-regular-svg-icons": "^5.15.2", "@fortawesome/free-solid-svg-icons": "^5.15.2", "@fortawesome/react-fontawesome": "^0.1.14", - "axios": "^0.21.1", + "axios": "^0.21.2", "bootstrap": "^4.6.0", "bottlejs": "^2.0.0", "bowser": "^2.11.0", @@ -6470,11 +6470,11 @@ } }, "node_modules/axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz", + "integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==", "dependencies": { - "follow-redirects": "^1.10.0" + "follow-redirects": "^1.14.0" } }, "node_modules/axobject-query": { @@ -13791,9 +13791,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", - "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==", "funding": [ { "type": "individual", @@ -13802,6 +13802,11 @@ ], "engines": { "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, "node_modules/for-in": { @@ -39831,11 +39836,11 @@ "dev": true }, "axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz", + "integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==", "requires": { - "follow-redirects": "^1.10.0" + "follow-redirects": "^1.14.0" } }, "axobject-query": { @@ -45629,9 +45634,9 @@ } }, "follow-redirects": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", - "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==" }, "for-in": { "version": "1.0.2", diff --git a/package.json b/package.json index 2385201b..f1cae125 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@fortawesome/free-regular-svg-icons": "^5.15.2", "@fortawesome/free-solid-svg-icons": "^5.15.2", "@fortawesome/react-fontawesome": "^0.1.14", - "axios": "^0.21.1", + "axios": "^0.21.2", "bootstrap": "^4.6.0", "bottlejs": "^2.0.0", "bowser": "^2.11.0", diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 6c783531..4cada88b 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -11,31 +11,34 @@ import { ShlinkVisits, ShlinkVisitsParams, ShlinkShortUrlData, - ShlinkDomain, ShlinkDomainsResponse, ShlinkVisitsOverview, ShlinkEditDomainRedirects, ShlinkDomainRedirects, ShlinkShortUrlsListParams, + ShlinkShortUrlsListNormalizedParams, } from '../types'; import { stringifyQuery } from '../../utils/helpers/query'; +import { orderToString } from '../../utils/helpers/ordering'; -const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : ''; +const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : ''; const rejectNilProps = reject(isNil); +const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { + const { orderBy = {}, ...rest } = params; + + return { ...rest, orderBy: orderToString(orderBy) }; +}; export default class ShlinkApiClient { - private apiVersion: number; - public constructor( private readonly axios: AxiosInstance, private readonly baseUrl: string, private readonly apiKey: string, ) { - this.apiVersion = 2; } public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise => - this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params) + this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params)) .then(({ data }) => data.shortUrls); public readonly createShortUrl = async (options: ShortUrlData): Promise => { @@ -69,7 +72,10 @@ export default class ShlinkApiClient { this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) .then(() => {}); - /* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */ + // eslint-disable-next-line valid-jsdoc + /** + * @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead + */ public readonly updateShortUrlTags = async ( shortCode: string, domain: OptionalString, @@ -107,43 +113,21 @@ 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); + public readonly listDomains = async (): Promise => + this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains); public readonly editDomainRedirects = async ( domainRedirects: ShlinkEditDomainRedirects, ): Promise => this.performRequest('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data); - private readonly performRequest = async (url: string, method: Method = 'GET', query = {}, body = {}): Promise> => { - try { - return await this.axios({ - method, - url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`, - headers: { 'X-Api-Key': this.apiKey }, - params: rejectNilProps(query), - data: body, - paramsSerializer: stringifyQuery, - }); - } catch (e: any) { - const { response } = e; - - // Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error - // when performed from the browser (due to the preflight request not returning a 2xx status. - // See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here. - // The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as - // if a request has been performed to a not supported API version. - const apiVersionIsNotSupported = !response; - - // When the request is not invalid or we have already tried both API versions, throw the error and let the - // caller handle it - if (!apiVersionIsNotSupported || this.apiVersion === 2) { - throw e; - } - - this.apiVersion = this.apiVersion - 1; - - return await this.performRequest(url, method, query, body); - } - }; + private readonly performRequest = async (url: string, method: Method = 'GET', query = {}, body = {}): Promise> => + this.axios({ + method, + url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`, + headers: { 'X-Api-Key': this.apiKey }, + params: rejectNilProps(query), + data: body, + paramsSerializer: stringifyQuery, + }); } diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 478194fc..4c656819 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -1,7 +1,6 @@ import { Visit } from '../../visits/types'; import { OptionalString } from '../../utils/utils'; -import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; -import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams'; +import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data'; export interface ShlinkShortUrlsResponse { data: ShortUrl[]; @@ -84,6 +83,7 @@ export interface ShlinkDomain { export interface ShlinkDomainsResponse { data: ShlinkDomain[]; + defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10 } export interface ShlinkShortUrlsListParams { @@ -93,7 +93,11 @@ export interface ShlinkShortUrlsListParams { searchTerm?: string; startDate?: string; endDate?: string; - orderBy?: OrderBy; + orderBy?: ShortUrlsOrder; +} + +export interface ShlinkShortUrlsListNormalizedParams extends Omit { + orderBy?: string; } export interface ProblemDetailsError { diff --git a/src/common/NoMenuLayout.tsx b/src/common/NoMenuLayout.tsx index dfddde05..ea3862f2 100644 --- a/src/common/NoMenuLayout.tsx +++ b/src/common/NoMenuLayout.tsx @@ -1,6 +1,4 @@ import { FC } from 'react'; import './NoMenuLayout.scss'; -const NoMenuLayout: FC = ({ children }) =>
{children}
; - -export default NoMenuLayout; +export const NoMenuLayout: FC = ({ children }) =>
{children}
; diff --git a/src/container/index.ts b/src/container/index.ts index 1fbf7bd3..aedf0ece 100644 --- a/src/container/index.ts +++ b/src/container/index.ts @@ -18,7 +18,8 @@ import { ConnectDecorator } from './types'; type LazyActionMap = Record; const bottle = new Bottle(); -const { container } = bottle; + +export const { container } = bottle; const lazyService = (container: IContainer, serviceName: string) => (...args: any[]) => (container[serviceName] as T)(...args) as K; @@ -44,5 +45,3 @@ provideUtilsServices(bottle); provideMercureServices(bottle); provideSettingsServices(bottle, connect); provideDomainsServices(bottle, connect); - -export default container; diff --git a/src/container/store.ts b/src/container/store.ts index 3196d6c2..e99dbd8f 100644 --- a/src/container/store.ts +++ b/src/container/store.ts @@ -2,6 +2,8 @@ import ReduxThunk from 'redux-thunk'; import { applyMiddleware, compose, createStore } from 'redux'; import { save, load, RLSOptions } from 'redux-localstorage-simple'; import reducers from '../reducers'; +import { migrateDeprecatedSettings } from '../settings/helpers'; +import { ShlinkState } from './types'; const isProduction = process.env.NODE_ENV !== 'production'; const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; @@ -12,9 +14,8 @@ const localStorageConfig: RLSOptions = { namespaceSeparator: '.', debounce: 300, }; +const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState); -const store = createStore(reducers, load(localStorageConfig), composeEnhancers( +export const store = createStore(reducers, preloadedState, composeEnhancers( applyMiddleware(save(localStorageConfig), ReduxThunk), )); - -export default store; diff --git a/src/container/types.ts b/src/container/types.ts index f4d20282..e3289da8 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -4,7 +4,6 @@ import { Settings } from '../settings/reducers/settings'; import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation'; import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion'; import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition'; -import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams'; import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList'; import { TagDeletion } from '../tags/reducers/tagDelete'; import { TagEdition } from '../tags/reducers/tagEdit'; @@ -20,7 +19,6 @@ export interface ShlinkState { servers: ServersMap; selectedServer: SelectedServer; shortUrlsList: ShortUrlsList; - shortUrlsListParams: ShortUrlsListParams; shortUrlCreationResult: ShortUrlCreation; shortUrlDeletion: ShortUrlDeletion; shortUrlEdition: ShortUrlEdition; diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index 2c6bdef9..db35cdaa 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -1,20 +1,26 @@ -import { FC } from 'react'; +import { FC, useEffect } from 'react'; import { Button, UncontrolledTooltip } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faBan as forbiddenIcon, - faCheck as defaultDomainIcon, + faDotCircle as defaultDomainIcon, faEdit as editIcon, } from '@fortawesome/free-solid-svg-icons'; -import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types'; +import { ShlinkDomainRedirects } from '../api/types'; import { useToggle } from '../utils/helpers/hooks'; import { OptionalString } from '../utils/utils'; +import { SelectedServer } from '../servers/data'; +import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features'; import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal'; +import { Domain } from './data'; +import { DomainStatusIcon } from './helpers/DomainStatusIcon'; interface DomainRowProps { - domain: ShlinkDomain; + domain: Domain; defaultRedirects?: ShlinkDomainRedirects; editDomainRedirects: (domain: string, redirects: Partial) => Promise; + checkDomainHealth: (domain: string) => void; + selectedServer: SelectedServer; } const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => ( @@ -30,13 +36,20 @@ const DefaultDomain: FC = () => ( ); -export const DomainRow: FC = ({ domain, editDomainRedirects, defaultRedirects }) => { +export const DomainRow: FC = ( + { domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer }, +) => { const [ isOpen, toggle ] = useToggle(); - const { domain: authority, isDefault, redirects } = domain; + const { domain: authority, isDefault, redirects, status } = domain; + const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer); + + useEffect(() => { + checkDomainHealth(domain.domain); + }, []); return ( - {isDefault ? : ''} + {isDefault && } {authority} {redirects?.baseUrlRedirect ?? } @@ -47,13 +60,16 @@ export const DomainRow: FC = ({ domain, editDomainRedirects, def {redirects?.invalidShortUrlRedirect ?? } + + + - - - {isDefault && ( + {!canEditDomain && ( Redirects for default domain cannot be edited here.
diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx index 69f67a30..d266b119 100644 --- a/src/domains/ManageDomains.tsx +++ b/src/domains/ManageDomains.tsx @@ -5,6 +5,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError'; import { SimpleCard } from '../utils/SimpleCard'; import SearchField from '../utils/SearchField'; import { ShlinkDomainRedirects } from '../api/types'; +import { SelectedServer } from '../servers/data'; import { DomainsList } from './reducers/domainsList'; import { DomainRow } from './DomainRow'; @@ -12,16 +13,18 @@ interface ManageDomainsProps { listDomains: Function; filterDomains: (searchTerm: string) => void; editDomainRedirects: (domain: string, redirects: Partial) => Promise; + checkDomainHealth: (domain: string) => void; domainsList: DomainsList; + selectedServer: SelectedServer; } -const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ]; +const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ]; export const ManageDomains: FC = ( - { listDomains, domainsList, filterDomains, editDomainRedirects }, + { listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer }, ) => { - const { filteredDomains: domains, loading, error, errorData } = domainsList; - const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects; + const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList; + const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects; useEffect(() => { listDomains(); @@ -53,7 +56,9 @@ export const ManageDomains: FC = ( key={domain.domain} domain={domain} editDomainRedirects={editDomainRedirects} - defaultRedirects={defaultRedirects} + checkDomainHealth={checkDomainHealth} + defaultRedirects={resolvedDefaultRedirects} + selectedServer={selectedServer} /> ))} diff --git a/src/domains/data/index.ts b/src/domains/data/index.ts new file mode 100644 index 00000000..e427d87d --- /dev/null +++ b/src/domains/data/index.ts @@ -0,0 +1,7 @@ +import { ShlinkDomain } from '../../api/types'; + +export type DomainStatus = 'validating' | 'valid' | 'invalid'; + +export interface Domain extends ShlinkDomain { + status: DomainStatus; +} diff --git a/src/domains/helpers/DomainStatusIcon.tsx b/src/domains/helpers/DomainStatusIcon.tsx new file mode 100644 index 00000000..03952883 --- /dev/null +++ b/src/domains/helpers/DomainStatusIcon.tsx @@ -0,0 +1,62 @@ +import { FC, useEffect, useRef, useState } from 'react'; +import { UncontrolledTooltip } from 'reactstrap'; +import { ExternalLink } from 'react-external-link'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faTimes as invalidIcon, + faCheck as checkIcon, + faCircleNotch as loadingStatusIcon, +} from '@fortawesome/free-solid-svg-icons'; +import { MediaMatcher } from '../../utils/types'; +import { DomainStatus } from '../data'; + +interface DomainStatusIconProps { + status: DomainStatus; + matchMedia?: MediaMatcher; +} + +export const DomainStatusIcon: FC = ({ status, matchMedia = window.matchMedia }) => { + const ref = useRef(); + const matchesMobile = () => matchMedia('(max-width: 991px)').matches; + const [ isMobile, setIsMobile ] = useState(matchesMobile()); + + useEffect(() => { + const listener = () => setIsMobile(matchesMobile()); + + window.addEventListener('resize', listener); + + return () => window.removeEventListener('resize', listener); + }, []); + + if (status === 'validating') { + return ; + } + + return ( + <> + { + ref.current = el; + }} + > + {status === 'valid' + ? + : } + + ref.current) as any} + placement={isMobile ? 'top-start' : 'left'} + autohide={status === 'valid'} + > + {status === 'valid' ? 'Congratulations! This domain is properly configured.' : ( + + Oops! There is some missing configuration, and short URLs shared with this domain will not work. +
+ Check the documentation in order to + find out what is missing. +
+ )} +
+ + ); +}; diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index c8ee513d..1769b189 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -1,10 +1,13 @@ import { Action, Dispatch } from 'redux'; -import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; +import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { parseApiError } from '../../api/utils'; import { ApiErrorAction } from '../../api/types/actions'; +import { Domain, DomainStatus } from '../data'; +import { hasServerData } from '../../servers/data'; +import { replaceAuthorityFromUri } from '../../utils/helpers/uri'; import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects'; /* eslint-disable padding-line-between-statements */ @@ -12,24 +15,32 @@ 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'; export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; +export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN'; /* eslint-enable padding-line-between-statements */ export interface DomainsList { - domains: ShlinkDomain[]; - filteredDomains: ShlinkDomain[]; + domains: Domain[]; + filteredDomains: Domain[]; + defaultRedirects?: ShlinkDomainRedirects; loading: boolean; error: boolean; errorData?: ProblemDetailsError; } export interface ListDomainsAction extends Action { - domains: ShlinkDomain[]; + domains: Domain[]; + defaultRedirects?: ShlinkDomainRedirects; } interface FilterDomainsAction extends Action { searchTerm: string; } +interface ValidateDomain extends Action { + domain: string; + status: DomainStatus; +} + const initialState: DomainsList = { domains: [], filteredDomains: [], @@ -40,15 +51,20 @@ const initialState: DomainsList = { export type DomainsCombinedAction = ListDomainsAction & ApiErrorAction & FilterDomainsAction -& EditDomainRedirectsAction; +& EditDomainRedirectsAction +& ValidateDomain; export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => - (d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects }; + (d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects }; + +export const replaceStatusOnDomain = (domain: string, status: DomainStatus) => + (d: Domain): Domain => d.domain !== domain ? d : { ...d, status }; export default buildReducer({ [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), [LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }), - [LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }), + [LIST_DOMAINS]: (_, { domains, defaultRedirects }) => + ({ ...initialState, domains, filteredDomains: domains, defaultRedirects }), [FILTER_DOMAINS]: (state, { searchTerm }) => ({ ...state, filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)), @@ -58,6 +74,11 @@ export default buildReducer({ domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)), filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), }), + [VALIDATE_DOMAIN]: (state, { domain, status }) => ({ + ...state, + domains: state.domains.map(replaceStatusOnDomain(domain, status)), + filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)), + }), }, initialState); export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( @@ -68,12 +89,42 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () const { listDomains } = buildShlinkApiClient(getState); try { - const domains = await listDomains(); + const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({ + domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), + defaultRedirects, + })); - dispatch({ type: LIST_DOMAINS, domains }); + dispatch({ type: LIST_DOMAINS, domains, defaultRedirects }); } catch (e: any) { dispatch({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); } }; export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm }); + +export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async ( + dispatch: Dispatch, + getState: GetState, +) => { + const { selectedServer } = getState(); + + if (!hasServerData(selectedServer)) { + dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + + return; + } + + try { + const { url, ...rest } = selectedServer; + const { health } = buildShlinkApiClient({ + ...rest, + url: replaceAuthorityFromUri(url, domain), + }); + + const { status } = await health(); + + dispatch({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' }); + } catch (e) { + dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + } +}; diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts index e6f01b1b..9e440b1a 100644 --- a/src/domains/services/provideServices.ts +++ b/src/domains/services/provideServices.ts @@ -1,6 +1,6 @@ import Bottle from 'bottlejs'; import { ConnectDecorator } from '../../container/types'; -import { filterDomains, listDomains } from '../reducers/domainsList'; +import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList'; import { DomainSelector } from '../DomainSelector'; import { ManageDomains } from '../ManageDomains'; import { editDomainRedirects } from '../reducers/domainRedirects'; @@ -12,14 +12,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ManageDomains', () => ManageDomains); bottle.decorator('ManageDomains', connect( - [ 'domainsList' ], - [ 'listDomains', 'filterDomains', 'editDomainRedirects' ], + [ 'domainsList', 'selectedServer' ], + [ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ], )); // Actions bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); bottle.serviceFactory('filterDomains', () => filterDomains); bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient'); + bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient'); }; export default provideServices; diff --git a/src/index.tsx b/src/index.tsx index cc47e20c..eb2e31dd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,8 +2,8 @@ import { render } from 'react-dom'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import { homepage } from '../package.json'; -import container from './container'; -import store from './container/store'; +import { container } from './container'; +import { store } from './container/store'; import { fixLeafletIcons } from './utils/helpers/leaflet'; import { register as registerServiceWorker } from './serviceWorkerRegistration'; import 'react-datepicker/dist/react-datepicker.css'; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 2257cdda..3c85f168 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -2,7 +2,6 @@ import { combineReducers } from 'redux'; import serversReducer from '../servers/reducers/servers'; import selectedServerReducer from '../servers/reducers/selectedServer'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; -import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation'; import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; @@ -24,7 +23,6 @@ export default combineReducers({ servers: serversReducer, selectedServer: selectedServerReducer, shortUrlsList: shortUrlsListReducer, - shortUrlsListParams: shortUrlsListParamsReducer, shortUrlCreationResult: shortUrlCreationReducer, shortUrlDeletion: shortUrlDeletionReducer, shortUrlEdition: shortUrlEditionReducer, diff --git a/src/servers/CreateServer.scss b/src/servers/CreateServer.scss deleted file mode 100644 index 4861c9af..00000000 --- a/src/servers/CreateServer.scss +++ /dev/null @@ -1,10 +0,0 @@ -@import '../utils/base'; - -.create-server__label { - font-weight: 700; - cursor: pointer; - - @media (min-width: $mdMin) { - text-align: right; - } -} diff --git a/src/servers/CreateServer.tsx b/src/servers/CreateServer.tsx index e24e548c..0412016a 100644 --- a/src/servers/CreateServer.tsx +++ b/src/servers/CreateServer.tsx @@ -1,14 +1,14 @@ -import { FC } from 'react'; +import { FC, useEffect, useState } from 'react'; import { v4 as uuid } from 'uuid'; import { RouterProps } from 'react-router'; import { Button } from 'reactstrap'; import { Result } from '../utils/Result'; -import NoMenuLayout from '../common/NoMenuLayout'; -import { StateFlagTimeout } from '../utils/helpers/hooks'; +import { NoMenuLayout } from '../common/NoMenuLayout'; +import { StateFlagTimeout, useToggle } from '../utils/helpers/hooks'; import { ServerForm } from './helpers/ServerForm'; import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { ServerData, ServersMap, ServerWithId } from './data'; -import './CreateServer.scss'; +import { DuplicatedServersModal } from './helpers/DuplicatedServersModal'; const SHOW_IMPORT_MSG_TIME = 4000; @@ -32,16 +32,30 @@ const CreateServer = (ImportServersBtn: FC, useStateFlagT const hasServers = !!Object.keys(servers).length; const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); - const handleSubmit = (serverData: ServerData) => { + const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle(); + const [ serverData, setServerData ] = useState(); + const save = () => { + if (!serverData) { + return; + } + const id = uuid(); createServer({ ...serverData, id }); push(`/server/${id}`); }; + useEffect(() => { + const serverExists = Object.values(servers).some( + ({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey, + ); + + serverExists ? toggleConfirmModal() : save(); + }, [ serverData ]); + return ( - Add new server} onSubmit={handleSubmit}> + Add new server} onSubmit={setServerData}> {!hasServers && } {hasServers && } @@ -50,6 +64,13 @@ const CreateServer = (ImportServersBtn: FC, useStateFlagT {serversImported && } {errorImporting && } + + ); }; diff --git a/src/servers/EditServer.tsx b/src/servers/EditServer.tsx index f6576066..514ebcba 100644 --- a/src/servers/EditServer.tsx +++ b/src/servers/EditServer.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { Button } from 'reactstrap'; -import NoMenuLayout from '../common/NoMenuLayout'; +import { NoMenuLayout } from '../common/NoMenuLayout'; import { ServerForm } from './helpers/ServerForm'; import { withSelectedServer } from './helpers/withSelectedServer'; import { isServerWithId, ServerData } from './data'; diff --git a/src/servers/ManageServers.tsx b/src/servers/ManageServers.tsx index 9554f2e8..532d1329 100644 --- a/src/servers/ManageServers.tsx +++ b/src/servers/ManageServers.tsx @@ -3,7 +3,7 @@ import { Button, Row } from 'reactstrap'; import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Link } from 'react-router-dom'; -import NoMenuLayout from '../common/NoMenuLayout'; +import { NoMenuLayout } from '../common/NoMenuLayout'; import { SimpleCard } from '../utils/SimpleCard'; import SearchField from '../utils/SearchField'; import { Result } from '../utils/Result'; diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 9b99cc6f..a68d3e17 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -44,7 +44,7 @@ export const Overview = ( const history = useHistory(); useEffect(() => { - listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } }); + listShortUrls({ itemsPerPage: 5, orderBy: { field: 'dateCreated', dir: 'DESC' } }); listTags(); loadVisitsOverview(); }, []); diff --git a/src/servers/helpers/DuplicatedServersModal.tsx b/src/servers/helpers/DuplicatedServersModal.tsx new file mode 100644 index 00000000..b2fb3d78 --- /dev/null +++ b/src/servers/helpers/DuplicatedServersModal.tsx @@ -0,0 +1,40 @@ +import { FC, Fragment } from 'react'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { ServerData } from '../data'; + +interface DuplicatedServersModalProps { + duplicatedServers: ServerData[]; + isOpen: boolean; + onDiscard: () => void; + onSave: () => void; +} + +export const DuplicatedServersModal: FC = ( + { isOpen, duplicatedServers, onDiscard, onSave }, +) => { + const hasMultipleServers = duplicatedServers.length > 1; + + return ( + + Duplicated server{hasMultipleServers && 's'} + +

{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}

+
    + {duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? ( + +
  • URL: {url}
  • +
  • API key: {apiKey}
  • +
    + ) :
  • {url} - {apiKey}
  • )} +
+ + {hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}? + +
+ + + + +
+ ); +}; diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index 3f27cfc2..2a91e71c 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -1,10 +1,12 @@ -import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react'; +import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react'; import { Button, UncontrolledTooltip } from 'reactstrap'; -import { pipe } from 'ramda'; +import { complement, pipe } from 'ramda'; import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import ServersImporter from '../services/ServersImporter'; -import { ServerData } from '../data'; +import { useToggle } from '../../utils/helpers/hooks'; +import { ServersImporter } from '../services/ServersImporter'; +import { ServerData, ServersMap } from '../data'; +import { DuplicatedServersModal } from './DuplicatedServersModal'; import './ImportServersBtn.scss'; type Ref = RefObject | MutableRefObject; @@ -18,11 +20,16 @@ export interface ImportServersBtnProps { interface ImportServersBtnConnectProps extends ImportServersBtnProps { createServers: (servers: ServerData[]) => void; + servers: ServersMap; fileRef: Ref; } +const serversFiltering = (servers: ServerData[]) => + ({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey); + const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC => ({ createServers, + servers, fileRef, children, onImport = () => {}, @@ -31,15 +38,37 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC { const ref = fileRef ?? useRef(); - const onChange = async ({ target }: ChangeEvent) => + const [ serversToCreate, setServersToCreate ] = useState(); + const [ duplicatedServers, setDuplicatedServers ] = useState([]); + const [ isModalOpen,, showModal, hideModal ] = useToggle(); + const create = pipe(createServers, onImport); + const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal); + const createNonDuplicatedServers = pipe( + () => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))), + hideModal, + ); + const onFile = async ({ target }: ChangeEvent) => importServersFromFile(target.files?.[0]) - .then(pipe(createServers, onImport)) + .then(setServersToCreate) .then(() => { // Reset input after processing file (target as { value: string | null }).value = null; }) .catch(onImportError); + useEffect(() => { + if (!serversToCreate) { + return; + } + + const existingServers = Object.values(servers); + const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers)); + const hasDuplicatedServers = !!duplicatedServers.length; + + !hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers); + hasDuplicatedServers && showModal(); + }, [ serversToCreate ]); + return ( <>