diff --git a/apps/admin-x-design-system/src/global/form/URLTextField.tsx b/apps/admin-x-design-system/src/global/form/URLTextField.tsx index 93985811f552..08f14875c5cc 100644 --- a/apps/admin-x-design-system/src/global/form/URLTextField.tsx +++ b/apps/admin-x-design-system/src/global/form/URLTextField.tsx @@ -1,97 +1,7 @@ import React, {useEffect, useState} from 'react'; -import isEmail from 'validator/es/lib/isEmail'; import {useFocusContext} from '../../providers/DesignSystemProvider'; import TextField, {TextFieldProps} from './TextField'; - -export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => { - if (nullable && !value) { - return {save: null, display: ''}; - } - - let url = value.trim(); - - if (!url) { - if (baseUrl) { - return {save: '/', display: baseUrl}; - } - return {save: '', display: ''}; - } - - // if we have an email address, add the mailto: - if (isEmail(url)) { - return {save: `mailto:${url}`, display: `mailto:${url}`}; - } - - const isAnchorLink = url.match(/^#/); - - if (isAnchorLink) { - return {save: url, display: url}; - } - - if (!baseUrl) { - // Absolute URL with no base URL - if (!url.startsWith('http')) { - url = `https://${url}`; - } - } - - // If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc - if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) { - return {save: url, display: url}; - } - - let parsedUrl: URL; - - try { - parsedUrl = new URL(url, baseUrl); - } catch (e) { - return {save: url, display: url}; - } - - if (!baseUrl) { - return {save: parsedUrl.toString(), display: parsedUrl.toString()}; - } - const parsedBaseUrl = new URL(baseUrl); - - let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0; - - // if our path is only missing a trailing / mark it as relative - if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) { - isRelativeToBasePath = true; - } - - const isOnSameHost = parsedUrl.host === parsedBaseUrl.host; - - // if relative to baseUrl, remove the base url before sending to action - if (!isAnchorLink && isOnSameHost && isRelativeToBasePath) { - url = url.replace(/^[a-zA-Z0-9-]+:/, ''); - url = url.replace(/^\/\//, ''); - url = url.replace(parsedBaseUrl.host, ''); - url = url.replace(parsedBaseUrl.pathname, ''); - - // handle case where url path is same as baseUrl path but missing trailing slash - if (parsedUrl.pathname.slice(-1) !== '/') { - url = url.replace(parsedBaseUrl.pathname.slice(0, -1), ''); - } - - if (!url.match(/^\//)) { - url = `/${url}`; - } - - if (!url.match(/\/$/) && !url.match(/[.#?]/)) { - url = `${url}/`; - } - } - - if (url.match(/^(\/\/|#)/)) { - return {save: url, display: url}; - } - - // we update with the relative URL but then transform it back to absolute - // for the input value. This avoids problems where the underlying relative - // value hasn't changed even though the input value has - return {save: url, display: new URL(url, baseUrl).toString()}; -}; +import {formatUrl} from '../../utils/formatUrl'; export interface URLTextFieldProps extends Omit { baseUrl?: string; diff --git a/apps/admin-x-design-system/src/index.ts b/apps/admin-x-design-system/src/index.ts index c2298cb1a287..1a0a95358caf 100644 --- a/apps/admin-x-design-system/src/index.ts +++ b/apps/admin-x-design-system/src/index.ts @@ -50,7 +50,7 @@ export {default as Toggle} from './global/form/Toggle'; export type {ToggleProps} from './global/form/Toggle'; export {default as ToggleGroup} from './global/form/ToggleGroup'; export type {ToggleGroupProps} from './global/form/ToggleGroup'; -export {default as URLTextField, formatUrl} from './global/form/URLTextField'; +export {default as URLTextField} from './global/form/URLTextField'; export type {URLTextFieldProps} from './global/form/URLTextField'; export {default as ConfirmationModal, ConfirmationModalContent} from './global/modal/ConfirmationModal'; @@ -166,6 +166,7 @@ export {default as useSortableIndexedList} from './hooks/useSortableIndexedList' export {debounce} from './utils/debounce'; export {confirmIfDirty} from './utils/modals'; +export {formatUrl} from './utils/formatUrl'; export {default as DesignSystemApp} from './DesignSystemApp'; export type {DesignSystemAppProps} from './DesignSystemApp'; diff --git a/apps/admin-x-design-system/src/utils/formatUrl.ts b/apps/admin-x-design-system/src/utils/formatUrl.ts new file mode 100644 index 000000000000..b05852be6c31 --- /dev/null +++ b/apps/admin-x-design-system/src/utils/formatUrl.ts @@ -0,0 +1,100 @@ +import isEmail from 'validator/es/lib/isEmail'; + +export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => { + if (nullable && !value) { + return {save: null, display: ''}; + } + + let url = value.trim(); + + if (!url) { + if (baseUrl) { + return {save: '/', display: baseUrl}; + } + return {save: '', display: ''}; + } + + // if we have an email address, add the mailto: + if (isEmail(url)) { + return {save: `mailto:${url}`, display: `mailto:${url}`}; + } + + const isAnchorLink = url.match(/^#/); + if (isAnchorLink) { + return {save: url, display: url}; + } + + const isProtocolRelative = url.match(/^(\/\/)/); + if (isProtocolRelative) { + return {save: url, display: url}; + } + + if (!baseUrl) { + // Absolute URL with no base URL + if (!url.startsWith('http')) { + url = `https://${url}`; + } + } + + // If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc + if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) { + return {save: url, display: url}; + } + + let parsedUrl: URL; + + try { + parsedUrl = new URL(url, baseUrl); + } catch (e) { + return {save: url, display: url}; + } + + if (!baseUrl) { + return {save: parsedUrl.toString(), display: parsedUrl.toString()}; + } + const parsedBaseUrl = new URL(baseUrl); + + let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0; + + // if our path is only missing a trailing / mark it as relative + if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) { + isRelativeToBasePath = true; + } + + const isOnSameHost = parsedUrl.host === parsedBaseUrl.host; + + // if relative to baseUrl, remove the base url before sending to action + if (isOnSameHost && isRelativeToBasePath) { + url = url.replace(/^[a-zA-Z0-9-]+:/, ''); + url = url.replace(/^\/\//, ''); + url = url.replace(parsedBaseUrl.host, ''); + url = url.replace(parsedBaseUrl.pathname, ''); + + if (!url.match(/^\//)) { + url = `/${url}`; + } + } + + if (!url.match(/\/$/) && !url.match(/[.#?]/)) { + url = `${url}/`; + } + + // we update with the relative URL but then transform it back to absolute + // for the input value. This avoids problems where the underlying relative + // value hasn't changed even though the input value has + return {save: url, display: displayFromBase(url, baseUrl)}; +}; + +const displayFromBase = (url: string, baseUrl: string) => { + // Ensure base url has a trailing slash + if (!baseUrl.endsWith('/')) { + baseUrl += '/'; + } + + // Remove leading slash from url + if (url.startsWith('/')) { + url = url.substring(1); + } + + return new URL(url, baseUrl).toString(); +}; diff --git a/apps/admin-x-design-system/test/unit/utils/formatUrl.test.ts b/apps/admin-x-design-system/test/unit/utils/formatUrl.test.ts new file mode 100644 index 000000000000..2f61dd649b6f --- /dev/null +++ b/apps/admin-x-design-system/test/unit/utils/formatUrl.test.ts @@ -0,0 +1,69 @@ +import * as assert from 'assert/strict'; +import {formatUrl} from '../../../src/utils/formatUrl'; + +describe('formatUrl', function () { + it('displays empty string if the input is empty and nullable is true', function () { + const formattedUrl = formatUrl('', undefined, true); + assert.deepEqual(formattedUrl, {save: null, display: ''}); + }); + + it('displays empty string value if the input has only whitespace', function () { + const formattedUrl = formatUrl(''); + assert.deepEqual(formattedUrl, {save: '', display: ''}); + }); + + it('displays base value if the input has only whitespace and base url is available', function () { + const formattedUrl = formatUrl('', 'http://example.com'); + assert.deepEqual(formattedUrl, {save: '/', display: 'http://example.com'}); + }); + + it('displays a mailto address for an email address', function () { + const formattedUrl = formatUrl('test@example.com'); + assert.deepEqual(formattedUrl, {save: 'mailto:test@example.com', display: 'mailto:test@example.com'}); + }); + + it('displays an anchor link without formatting', function () { + const formattedUrl = formatUrl('#section'); + assert.deepEqual(formattedUrl, {save: '#section', display: '#section'}); + }); + + it('displays a protocol-relative link without formatting', function () { + const formattedUrl = formatUrl('//example.com'); + assert.deepEqual(formattedUrl, {save: '//example.com', display: '//example.com'}); + }); + + it('adds https:// automatically', function () { + const formattedUrl = formatUrl('example.com'); + assert.deepEqual(formattedUrl, {save: 'https://example.com/', display: 'https://example.com/'}); + }); + + it('saves a relative URL if the input is a pathname', function () { + const formattedUrl = formatUrl('/path', 'http://example.com'); + assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/path/'}); + }); + + it('saves a relative URL if the input is a pathname, even if the base url has an non-empty pathname', function () { + const formattedUrl = formatUrl('/path', 'http://example.com/blog'); + assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/blog/path/'}); + }); + + it('saves a relative URL if the input includes the base url', function () { + const formattedUrl = formatUrl('http://example.com/path', 'http://example.com'); + assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/path/'}); + }); + + it('saves a relative URL if the input includes the base url, even if the base url has an non-empty pathname', function () { + const formattedUrl = formatUrl('http://example.com/blog/path', 'http://example.com/blog'); + assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/blog/path/'}); + }); + + it('saves an absolute URL if the input has a different pathname to the base url', function () { + const formattedUrl = formatUrl('http://example.com/path', 'http://example.com/blog'); + assert.deepEqual(formattedUrl, {save: 'http://example.com/path', display: 'http://example.com/path'}); + }); + + it('saves an absolte URL if the input has a different hostname to the base url', function () { + const formattedUrl = formatUrl('http://another.com/path', 'http://example.com'); + assert.deepEqual(formattedUrl, {save: 'http://another.com/path', display: 'http://another.com/path'}); + }); +});