diff --git a/package.json b/package.json index e5acb9acc..fa8d782f3 100644 --- a/package.json +++ b/package.json @@ -133,5 +133,10 @@ "eslint --fix", "git add" ] + }, + "jest": { + "moduleNameMapper": { + "\\.(svg|png)$": "/src/test/empty-string.js" + } } } diff --git a/src/App.js b/src/App.js index 262b90fdd..1c3bc2c78 100644 --- a/src/App.js +++ b/src/App.js @@ -4,11 +4,16 @@ import { createHashHistory as createHistory } from 'history' import { Spring, animated } from 'react-spring' import { useTheme } from '@aragon/ui' import { network, web3Providers } from './environment' -import { parsePath, getAppPath, getPreferencesSearch } from './routing' +import { + ARAGONID_ENS_DOMAIN, + getAppPath, + getPreferencesSearch, + parsePath, +} from './routing' import initWrapper, { + pollConnectivity, pollMainAccount, pollNetwork, - pollConnectivity, } from './aragonjs-wrapper' import Wrapper from './Wrapper' import { Onboarding } from './onboarding' @@ -130,7 +135,7 @@ class App extends React.Component { // Handle URL changes handleHistoryChange = ({ pathname, search, state = {} }) => { if (!state.alreadyParsed) { - this.updateLocator(parsePath(this.history, pathname, search)) + this.updateLocator(parsePath(pathname, search)) } } @@ -164,6 +169,15 @@ class App extends React.Component { this.updateDao(null) } + // Replace URL with non-aragonid.eth version + if (locator.dao && locator.dao.endsWith(ARAGONID_ENS_DOMAIN)) { + this.history.replace({ + pathname: locator.pathname.replace(`.${ARAGONID_ENS_DOMAIN}`, ''), + search: locator.search, + state: { alreadyParsed: true }, + }) + } + this.setState({ locator, prevLocator }) } @@ -259,6 +273,24 @@ class App extends React.Component { }, }) }, + onRequestPath: ({ appAddress, path, resolve, reject }) => { + const { locator } = this.state + if (appAddress !== locator.instanceId) { + reject( + `Can’t change the path of ${appAddress}: the app is not currently active.` + ) + return + } + + resolve() + + window.location.hash = getAppPath({ + dao, + instanceId: locator.instanceId, + instancePath: path, + mode: APP_MODE_ORG, + }) + }, }) .then(wrapper => { log('wrapper', wrapper) @@ -311,7 +343,7 @@ class App extends React.Component { closePreferences = () => { const { locator } = this.state - this.historyPush(getAppPath(locator)) + this.historyPush(getAppPath({ ...locator, search: '' })) } openPreferences = (screen, data) => { diff --git a/src/Wrapper.js b/src/Wrapper.js index e1b8827f7..6ce728959 100644 --- a/src/Wrapper.js +++ b/src/Wrapper.js @@ -86,11 +86,24 @@ class Wrapper extends React.PureComponent { componentDidUpdate(prevProps) { this.updateIdentityEvents(prevProps) + this.updateInstancePath(prevProps) if (prevProps.locator !== this.props.locator) { this.showOrgUpgradePanelIfFirstVisit() } } + updateInstancePath(prevProps) { + const { locator, wrapper } = this.props + + const updated = + locator.instanceId !== prevProps.locator.instanceId || + locator.instancePath !== prevProps.locator.instancePath + + if (wrapper && updated) { + wrapper.setAppPath(locator.instanceId, locator.instancePath) + } + } + getAppInstancesGroups = memoize(apps => apps.reduce((groups, app) => { const group = groups.find(({ appId }) => appId === app.appId) @@ -153,9 +166,9 @@ class Wrapper extends React.PureComponent { } } - openApp = (instanceId, { params, localPath } = {}) => { + openApp = (instanceId, { instancePath } = {}) => { const { historyPush, locator } = this.props - historyPush(getAppPath({ dao: locator.dao, instanceId, params, localPath })) + historyPush(getAppPath({ dao: locator.dao, instanceId, instancePath })) } handleAppIFrameRef = appIFrame => { @@ -200,14 +213,16 @@ class Wrapper extends React.PureComponent { this.setState({ appLoading: false }) } - // params need to be a string - handleParamsRequest = params => { - this.openApp(this.props.locator.instanceId, { params }) + handleAppMessage = ({ data: { name, value } }) => { + const { wrapper, locator } = this.props + if (name === 'ready') { + wrapper.setAppPath(locator.instanceId, locator.instancePath) + } } // Update the local path of the current instance - handlePathRequest = localPath => { - this.openApp(this.props.locator.instanceId, { localPath }) + handlePathRequest = instancePath => { + this.openApp(this.props.locator.instanceId, { instancePath }) } handleUpgradeModalOpen = () => { @@ -309,10 +324,7 @@ class Wrapper extends React.PureComponent { daoLoading={daoStatus === DAO_STATUS_LOADING} instanceId={locator.instanceId} > - {this.renderApp(locator.instanceId, { - params: locator.params, - localPath: locator.localPath, - })} + {this.renderApp(locator.instanceId, locator.instancePath)} ) } - renderApp(instanceId, { params, localPath }) { + renderApp(instanceId, instancePath) { const { apps, appsStatus, @@ -379,7 +391,7 @@ class Wrapper extends React.PureComponent { apps={apps} appsLoading={appsLoading} permissionsLoading={permissionsLoading} - localPath={localPath} + localPath={instancePath} onMessage={this.handleAppMessage} onPathRequest={this.handlePathRequest} wrapper={wrapper} @@ -394,13 +406,13 @@ class Wrapper extends React.PureComponent { diff --git a/src/apps/AppCenter/AppCenter.js b/src/apps/AppCenter/AppCenter.js index c05728f24..6d0171c28 100644 --- a/src/apps/AppCenter/AppCenter.js +++ b/src/apps/AppCenter/AppCenter.js @@ -1,190 +1,210 @@ -import React from 'react' +import React, { useCallback, useMemo, useState } from 'react' import PropTypes from 'prop-types' -import { Button, Header, IconRefresh, Tabs, useLayout } from '@aragon/ui' -import InstalledApps from './InstalledApps/InstalledApps' -import DiscoverApps from './DiscoverApps/DiscoverApps' -import UpgradeAppPanel from './UpgradeAppPanel' -import EmptyBlock from './EmptyBlock' -import { KERNEL_APP_BASE_NAMESPACE } from '../../aragonos-utils' +import { Button, Header, IconRefresh, Tabs } from '@aragon/ui' import { AppInstanceGroupType, AragonType, DaoAddressType, RepoType, } from '../../prop-types' +import { log, removeStartingSlash } from '../../utils' import { repoBaseUrl } from '../../url-utils' -import { log } from '../../utils' +import { KERNEL_APP_BASE_NAMESPACE } from '../../aragonos-utils' +import InstalledApps from './InstalledApps/InstalledApps' +import DiscoverApps from './DiscoverApps/DiscoverApps' +import UpgradeAppPanel from './UpgradeAppPanel' +import EmptyBlock from './EmptyBlock' const SCREENS = [ { id: 'installed', label: 'Installed apps' }, { id: 'discover', label: 'Discover apps' }, ] -class AppCenter extends React.Component { - static defaultProps = { - canUpgradeOrg: false, +function getLocation(localPath, extendedRepos) { + const defaultScreen = { screen: 'installed' } + + if (!localPath) { + return defaultScreen } - static propTypes = { - appInstanceGroups: PropTypes.arrayOf(AppInstanceGroupType).isRequired, - daoAddress: DaoAddressType.isRequired, - compactMode: PropTypes.bool, - onParamsRequest: PropTypes.func.isRequired, - params: PropTypes.string, - repos: PropTypes.arrayOf(RepoType).isRequired, - reposLoading: PropTypes.bool.isRequired, - canUpgradeOrg: PropTypes.bool.isRequired, - onUpgradeAll: PropTypes.func.isRequired, - wrapper: AragonType, + + const [screen, data = null] = removeStartingSlash(localPath).split('/') + + if (screen === 'installed') { + return { + screen, + openedRepo: (data && getRepoFromName(extendedRepos, data)) || null, + } } - state = { - upgradePanelOpened: false, + + if (screen === 'discover') { + return { screen } } - handleUpgradeApp = async (appId, appAddress) => { - const { daoAddress, wrapper } = this.props + return defaultScreen +} - log('setApp', appId, appAddress) - // Calculate the path, if it exists - const updatePath = await wrapper.getTransactionPath( - daoAddress.address, // destination (Kernel) - 'setApp', // method - [KERNEL_APP_BASE_NAMESPACE, appId, appAddress] // params - ) - // Try to perform the path - wrapper.performTransactionPath(updatePath) +function useUpgradeApp(wrapper, kernelAddress, { onDone }) { + const upgradeApp = useCallback( + async (appId, appAddress) => { + log('setApp', appId, appAddress) - this.setState({ upgradePanelOpened: false }) - } - getLocation() { - const { params } = this.props + // Calculate the path, if it exists + const updatePath = await wrapper.getTransactionPath( + kernelAddress, // destination (Kernel) + 'setApp', // method + [KERNEL_APP_BASE_NAMESPACE, appId, appAddress] // params + ) - if (!params) { - return { activeTab: 0, openedRepoName: null } - } + // Try to perform the path + wrapper.performTransactionPath(updatePath) - const [tabId, ...repoNameParts] = params.split('.') - const repoName = repoNameParts.join('.') // repair the ENS name - const activeTab = SCREENS.findIndex(({ id }) => id === tabId) - const hasRepo = Boolean(this.getRepoFromName(repoName)) + if (onDone) { + onDone() + } + }, + [wrapper, kernelAddress, onDone] + ) + return upgradeApp +} + +// Extend, cache and return the repos +function getExtendedRepos(appInstanceGroups, repos) { + return repos.map(repo => { + const appGroup = appInstanceGroups.find( + appGroup => appGroup.appId === repo.appId + ) + const { name, instances, repoName } = appGroup return { - activeTab: activeTab === -1 ? 0 : activeTab, - openedRepoName: hasRepo ? repoName : null, - } - } - updateLocation({ activeTab, openedRepoName }) { - const location = this.getLocation() - if (activeTab !== undefined) { - location.activeTab = activeTab - } - if (openedRepoName !== undefined) { - location.openedRepoName = openedRepoName + ...repo, + // Use latest version’s assets + baseUrl: repoBaseUrl(repo.appId, repo.latestVersion), + instances, + name, + repoName, } + }) +} - this.props.onParamsRequest( - `${SCREENS[location.activeTab].id}${ - location.openedRepoName ? `.${location.openedRepoName}` : '' - }` - ) - } - getRepos() { - const { appInstanceGroups, repos } = this.props - return repos.map(repo => { - const appGroup = appInstanceGroups.find( - appGroup => appGroup.appId === repo.appId - ) - return { - ...repo, - // Use latest version's assets - baseUrl: repoBaseUrl(repo.appId, repo.latestVersion), - name: appGroup.name, - instances: appGroup.instances, - repoName: appGroup.repoName, - } - }) - } - getRepoFromName(repoName) { - return this.getRepos().find(repo => repo.repoName === repoName) - } - handleScreenChange = tabIndex => { - this.updateLocation({ activeTab: tabIndex }) - } - handleOpenRepo = repoName => { - this.updateLocation({ openedRepoName: repoName }) - } - handleCloseRepo = () => { - this.updateLocation({ openedRepoName: null }) - } - handleOpenUpgradePanel = () => { - this.setState({ upgradePanelOpened: true }) - } - handleCloseUpgradePanel = () => { - this.setState({ upgradePanelOpened: false }) - } +function getRepoFromName(extendedRepos, name) { + return extendedRepos.find(repo => repo.repoName === name) +} + +const AppCenter = React.memo(function AppCenter({ + appInstanceGroups, + canUpgradeOrg, + compactMode, + daoAddress, + localPath, + onPathRequest, + onUpgradeAll, + repos, + reposLoading, + wrapper, +}) { + const [upgradePanelOpened, setUpgradePanelOpened] = useState(false) + + const extendedRepos = useMemo( + () => getExtendedRepos(appInstanceGroups, repos), + [appInstanceGroups, repos] + ) + + const { screen, openedRepo } = getLocation(localPath, extendedRepos) + + const openUpgradePanel = useCallback(() => { + setUpgradePanelOpened(true) + }, []) - render() { - const { - compactMode, - reposLoading, - onUpgradeAll, - canUpgradeOrg, - } = this.props - const { upgradePanelOpened } = this.state - const { activeTab, openedRepoName } = this.getLocation() - const repos = this.getRepos() - const currentRepo = openedRepoName && this.getRepoFromName(openedRepoName) - - return ( - -
} - display={compactMode ? 'icon' : 'label'} - /> - ) - } + const closeUpgradePanel = useCallback(() => { + setUpgradePanelOpened(false) + }, []) + + const upgradeApp = useUpgradeApp(wrapper, daoAddress.address, { + onDone() { + closeUpgradePanel() + }, + }) + + const changeScreen = useCallback( + screenIndex => { + onPathRequest(`/${SCREENS[screenIndex].id}`) + }, + [onPathRequest] + ) + + const handleOpenRepo = useCallback( + repoName => { + onPathRequest(`/installed/${repoName}`) + }, + [onPathRequest] + ) + + const handleCloseRepo = useCallback(() => { + onPathRequest(`/installed`) + }, [onPathRequest]) + + return ( + +
} + /> + ) + } + /> + {!openedRepo && ( + screen.label)} + selected={SCREENS.findIndex(s => s.id === screen)} + onChange={changeScreen} /> - {!openedRepoName && ( - screen.label)} - selected={activeTab} - onChange={this.handleScreenChange} + )} + {screen === 'installed' && + (reposLoading ? ( + Loading apps… + ) : ( + - )} - {activeTab === 0 && - (reposLoading ? ( - Loading apps… - ) : ( - - ))} - {activeTab === 1 && } + ))} + {screen === 'discover' && } - - - ) - } + + + ) +}) + +AppCenter.propTypes = { + appInstanceGroups: PropTypes.arrayOf(AppInstanceGroupType).isRequired, + canUpgradeOrg: PropTypes.bool.isRequired, + compactMode: PropTypes.bool, + daoAddress: DaoAddressType.isRequired, + localPath: PropTypes.string, + onPathRequest: PropTypes.func.isRequired, + onUpgradeAll: PropTypes.func.isRequired, + repos: PropTypes.arrayOf(RepoType).isRequired, + reposLoading: PropTypes.bool.isRequired, + wrapper: AragonType, } -export default React.memo(props => { - const { layoutName } = useLayout() - const compactMode = layoutName === 'small' - return -}) +AppCenter.defaultProps = { + canUpgradeOrg: false, +} + +export default AppCenter diff --git a/src/apps/Permissions/Permissions.js b/src/apps/Permissions/Permissions.js index fc5b35602..1b194f7b4 100644 --- a/src/apps/Permissions/Permissions.js +++ b/src/apps/Permissions/Permissions.js @@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useRef, useState } from 'react' import PropTypes from 'prop-types' import { AppType, AragonType } from '../../prop-types' import { Button, GU, Header, IconPlus, useLayout } from '@aragon/ui' +import { removeStartingSlash } from '../../utils' import { addressesEqual, isAddress } from '../../web3-utils' import { usePermissions } from '../../contexts/PermissionsContext' import LocalLabelAppBadge from '../../components/LocalLabelAppBadge/LocalLabelAppBadge' @@ -31,7 +32,7 @@ function getLocation(localPath, apps) { data = null, secondaryScreen = null, secondaryData = null, - ] = localPath.replace(/^\//, '').split('/') + ] = removeStartingSlash(localPath).split('/') if (screen === 'app' && isAddress(data)) { return { diff --git a/src/aragonjs-wrapper.js b/src/aragonjs-wrapper.js index 3ec318484..6f730aa8f 100644 --- a/src/aragonjs-wrapper.js +++ b/src/aragonjs-wrapper.js @@ -233,6 +233,7 @@ const subscribe = ( onIdentityIntent, onSignatures, onTransaction, + onRequestPath, }, { ipfsConf } ) => { @@ -245,6 +246,7 @@ const subscribe = ( identityIntents, signatures, transactions, + pathIntents, } = wrapper const workerSubscriptionPool = new WorkerSubscriptionPool() @@ -259,6 +261,7 @@ const subscribe = ( signatures: signatures.subscribe(onSignatures), connectedApp: null, connectedWorkers: workerSubscriptionPool, + pathIntents: pathIntents.subscribe(onRequestPath), apps: apps.subscribe(apps => { onApps( @@ -339,6 +342,7 @@ const initWrapper = async ( onSignatures = noop, onDaoAddress = noop, onWeb3 = noop, + onRequestPath = noop, } = {} ) => { const isDomain = isValidEnsName(dao) @@ -400,6 +404,7 @@ const initWrapper = async ( onIdentityIntent, onSignatures, onTransaction, + onRequestPath, }, { ipfsConf } ) diff --git a/src/routing.js b/src/routing.js index f38c3338d..cd1c2ad92 100644 --- a/src/routing.js +++ b/src/routing.js @@ -1,8 +1,22 @@ import { staticApps } from './static-apps' import { APP_MODE_START, APP_MODE_ORG, APP_MODE_SETUP } from './symbols' import { isAddress, isValidEnsName } from './web3-utils' +import { addStartingSlash } from './utils' -const ARAGONID_ENS_DOMAIN = 'aragonid.eth' +export const ARAGONID_ENS_DOMAIN = 'aragonid.eth' + +function encodeAppPath(path) { + return addStartingSlash( + path + .split('/') + .map(v => encodeURIComponent(v)) + .join('/') + ) +} + +function decodeAppPathParts(pathParts) { + return pathParts.map(v => decodeURIComponent(v)).join('/') +} /* * Parse a path and a search query and return a “locator” object. @@ -21,23 +35,23 @@ const ARAGONID_ENS_DOMAIN = 'aragonid.eth' * * /{dao_address} * /{dao_address}/permissions - * /{dao_address}/0x{app_instance_address}?p={app_params} + * /{dao_address}/0x{app_instance_address} * * * Available modes: - * - start: the screen you see when opening /. - * - setup: the onboarding screens. - * - org: when the path starts with a DAO address. - * - invalid: the DAO given is not valid + * - APP_MODE_START: the screen you see when opening /. + * - APP_MODE_SETUP: the onboarding screens. + * - APP_MODE_ORG: when the path starts with a DAO address or ENS name. */ -export function parsePath(history, pathname, search = '') { +export function parsePath(pathname, search = '') { const path = pathname + search const [, ...parts] = pathname.split('/') + const base = { path, pathname, search } // Onboarding if (!parts[0] || parts[0] === 'open' || parts[0] === 'create') { return { - path, + ...base, mode: parts[0] === 'create' ? APP_MODE_SETUP : APP_MODE_START, action: parts[0], preferences: parsePreferences(search), @@ -51,50 +65,30 @@ export function parsePath(history, pathname, search = '') { // Assume .aragonid.eth if not given a valid address or a valid ENS domain if (!validAddress && !validDomain) { dao += `.${ARAGONID_ENS_DOMAIN}` - } else if (validDomain && dao.endsWith(ARAGONID_ENS_DOMAIN)) { - // Replace URL with non-aragonid.eth version - history.replace({ - pathname: pathname.replace(`.${ARAGONID_ENS_DOMAIN}`, ''), - search, - state: { - alreadyParsed: true, - }, - }) } - // Organization - const rawParams = search && search.split('?p=')[1] - let params = null - if (rawParams) { - try { - params = decodeURIComponent(rawParams) - } catch (err) { - console.log('The params (“p”) URL parameter is not valid.') - } - } + const [, instanceId, ...instancePathParts] = parts - const [, instanceId, ...appParts] = parts + // The local path of an app (internal or external) + const instancePath = `/${ + instancePathParts ? decodeAppPathParts(instancePathParts) : '' + }` - const completeLocator = { - path, - mode: APP_MODE_ORG, + return { + ...base, dao, instanceId: instanceId || 'home', - params, - parts: appParts, - localPath: appParts.length ? `/${appParts.join('/')}` : '', + instancePath, + mode: APP_MODE_ORG, preferences: parsePreferences(search), } - - return completeLocator } // Return a path string from a locator object. export function getAppPath({ - dao: fullDao, + dao, instanceId = 'home', - localPath = '', - params, + instancePath = '', search = '', mode, action, @@ -107,26 +101,21 @@ export function getAppPath({ return `/${action}` } - if (!fullDao) { - return `/${search}` + // Only keep the DAO name if it ends in aragonid.eth + if (dao.endsWith(ARAGONID_ENS_DOMAIN)) { + dao = dao.substr(0, dao.indexOf(ARAGONID_ENS_DOMAIN) - 1) } - const dao = - fullDao.indexOf(ARAGONID_ENS_DOMAIN) > -1 - ? fullDao.substr(0, fullDao.indexOf(ARAGONID_ENS_DOMAIN) - 1) - : fullDao - - // The search takes priority over app params for now. App params are going to - // be replaced soon so it shouldn’t be an issue. - if (!search && params) { - search = `?p=${encodeURIComponent(params)}` + if (!dao) { + return `/${search}` } - if (staticApps.has(instanceId)) { - return '/' + dao + staticApps.get(instanceId).route + localPath + search - } + // Either the address of an app instance or the path of an internal app. + const instancePart = staticApps.has(instanceId) + ? staticApps.get(instanceId).route + : `/${instanceId}` - return `/${dao}/${instanceId}${search}` + return '/' + dao + instancePart + encodeAppPath(instancePath) + search } // Preferences diff --git a/src/routing.test.js b/src/routing.test.js new file mode 100644 index 000000000..bd506c39a --- /dev/null +++ b/src/routing.test.js @@ -0,0 +1,116 @@ +import { parsePath } from './routing' +import { APP_MODE_START, APP_MODE_SETUP, APP_MODE_ORG } from './symbols' + +const ADDRESS = '0xc41e4c10b37d3397a99d4a90e7d85508a69a5c4c' + +function locator(data) { + return { + // Just for convenience, set `pathname` to `path` if `pathname` doesn’t exist. + pathname: data.pathname === undefined ? data.path : data.pathname, + preferences: { params: new Map(), path: '' }, + search: '', + ...data, + } +} + +describe('parsePath()', () => { + test('handles modes', () => { + expect(parsePath('/')).toEqual( + locator({ + path: '/', + action: '', + mode: APP_MODE_START, + }) + ) + + expect(parsePath('/create')).toEqual( + locator({ + path: '/create', + action: 'create', + mode: APP_MODE_SETUP, + }) + ) + + expect(parsePath('/open')).toEqual( + locator({ + path: '/open', + action: 'open', + mode: APP_MODE_START, + }) + ) + + expect(parsePath('/p')).toEqual( + locator({ + path: '/p', + mode: APP_MODE_ORG, + dao: 'p.aragonid.eth', + instanceId: 'home', + instancePath: '/', + }) + ) + }) + + test('handles org paths', () => { + expect(parsePath(`/p/${ADDRESS}`)).toEqual( + locator({ + path: `/p/${ADDRESS}`, + dao: 'p.aragonid.eth', + instanceId: ADDRESS, + instancePath: '/', + mode: APP_MODE_ORG, + }) + ) + }) + + test('handles app paths', () => { + expect(parsePath(`/p/${ADDRESS}/test`)).toEqual( + locator({ + path: `/p/${ADDRESS}/test`, + dao: 'p.aragonid.eth', + instanceId: ADDRESS, + instancePath: '/test', + mode: APP_MODE_ORG, + }) + ) + }) + + test('handles preferences paths', () => { + expect(parsePath('/open', '?preferences=/network')).toEqual( + locator({ + path: '/open?preferences=/network', + pathname: '/open', + action: 'open', + mode: APP_MODE_START, + preferences: { params: new Map(), path: 'network' }, + search: '?preferences=/network', + }) + ) + }) + + test('handles an app path with a preference path', () => { + expect(parsePath(`/p/${ADDRESS}/test`, '?preferences=/network')).toEqual( + locator({ + path: `/p/${ADDRESS}/test?preferences=/network`, + pathname: `/p/${ADDRESS}/test`, + dao: 'p.aragonid.eth', + instanceId: ADDRESS, + instancePath: '/test', + mode: APP_MODE_ORG, + preferences: { params: new Map(), path: 'network' }, + search: '?preferences=/network', + }) + ) + }) + + test('handles malformed paths', () => { + expect(parsePath(`/p/${ADDRESS}///`)).toEqual( + locator({ + path: `/p/${ADDRESS}///`, + dao: 'p.aragonid.eth', + instanceId: ADDRESS, + instancePath: '///', + mode: APP_MODE_ORG, + }) + ) + }) +}) diff --git a/src/test/empty-string.js b/src/test/empty-string.js new file mode 100644 index 000000000..147f8630d --- /dev/null +++ b/src/test/empty-string.js @@ -0,0 +1,2 @@ +// used by Jest to replace image imports +export default '' diff --git a/src/utils.js b/src/utils.js index e481f322f..217733a75 100644 --- a/src/utils.js +++ b/src/utils.js @@ -71,6 +71,10 @@ export function removeStartingSlash(str) { return str.replace(/^\/+/, '') } +export function addStartingSlash(str) { + return str.startsWith('/') ? str : `/${str}` +} + // Append a trailing slash if needed export function appendTrailingSlash(str) { return str + (str.endsWith('/') ? '' : '/')