diff --git a/superset-frontend/src/components/GenericLink/GenericLink.test.tsx b/superset-frontend/src/components/GenericLink/GenericLink.test.tsx new file mode 100644 index 000000000000..c8f2ba5f5f41 --- /dev/null +++ b/superset-frontend/src/components/GenericLink/GenericLink.test.tsx @@ -0,0 +1,59 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import { GenericLink } from './GenericLink'; + +test('renders', () => { + render(Link to Explore, { + useRouter: true, + }); + expect(screen.getByText('Link to Explore')).toBeVisible(); +}); + +test('navigates to internal URL', () => { + render(Link to Explore, { + useRouter: true, + }); + const internalLink = screen.getByTestId('internal-link'); + expect(internalLink).toHaveAttribute('href', '/explore'); +}); + +test('navigates to external URL', () => { + render( + + Link to external website + , + { useRouter: true }, + ); + const externalLink = screen.getByTestId('external-link'); + expect(externalLink).toHaveAttribute('href', 'https://superset.apache.org/'); +}); + +test('navigates to external URL without host', () => { + render( + + Link to external website + , + { useRouter: true }, + ); + const externalLink = screen.getByTestId('external-link'); + expect(externalLink).toHaveAttribute('href', '//superset.apache.org/'); +}); diff --git a/superset-frontend/src/components/GenericLink/GenericLink.tsx b/superset-frontend/src/components/GenericLink/GenericLink.tsx new file mode 100644 index 000000000000..2bc111d1b60f --- /dev/null +++ b/superset-frontend/src/components/GenericLink/GenericLink.tsx @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { Link, LinkProps } from 'react-router-dom'; +import { isUrlExternal, parseUrl } from 'src/utils/urlUtils'; + +export const GenericLink = ({ + to, + component, + replace, + innerRef, + children, + ...rest +}: React.PropsWithoutRef> & + React.RefAttributes) => { + if (typeof to === 'string' && isUrlExternal(to)) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +}; diff --git a/superset-frontend/src/utils/urlUtils.test.ts b/superset-frontend/src/utils/urlUtils.test.ts new file mode 100644 index 000000000000..5f413ac59034 --- /dev/null +++ b/superset-frontend/src/utils/urlUtils.test.ts @@ -0,0 +1,54 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * 'License'); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isUrlExternal, parseUrl } from './urlUtils'; + +test('isUrlExternal', () => { + expect(isUrlExternal('http://google.com')).toBeTruthy(); + expect(isUrlExternal('https://google.com')).toBeTruthy(); + expect(isUrlExternal('//google.com')).toBeTruthy(); + expect(isUrlExternal('google.com')).toBeTruthy(); + expect(isUrlExternal('www.google.com')).toBeTruthy(); + expect(isUrlExternal('mailto:mail@example.com')).toBeTruthy(); + + // treat all urls starting with protocol or hostname as external + // such urls are not handled well by react-router Link component + expect(isUrlExternal('http://localhost:8888/port')).toBeTruthy(); + expect(isUrlExternal('https://localhost/secure')).toBeTruthy(); + expect(isUrlExternal('http://localhost/about')).toBeTruthy(); + expect(isUrlExternal('HTTP://localhost/about')).toBeTruthy(); + expect(isUrlExternal('//localhost/about')).toBeTruthy(); + expect(isUrlExternal('localhost/about')).toBeTruthy(); + + expect(isUrlExternal('/about')).toBeFalsy(); + expect(isUrlExternal('#anchor')).toBeFalsy(); +}); + +test('parseUrl', () => { + expect(parseUrl('http://google.com')).toEqual('http://google.com'); + expect(parseUrl('//google.com')).toEqual('//google.com'); + expect(parseUrl('mailto:mail@example.com')).toEqual( + 'mailto:mail@example.com', + ); + expect(parseUrl('google.com')).toEqual('//google.com'); + expect(parseUrl('www.google.com')).toEqual('//www.google.com'); + + expect(parseUrl('/about')).toEqual('/about'); + expect(parseUrl('#anchor')).toEqual('#anchor'); +}); diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts index 2aac5f3e281a..24442a2f485b 100644 --- a/superset-frontend/src/utils/urlUtils.ts +++ b/superset-frontend/src/utils/urlUtils.ts @@ -16,7 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { JsonObject, QueryFormData, SupersetClient } from '@superset-ui/core'; +import { + isDefined, + JsonObject, + QueryFormData, + SupersetClient, +} from '@superset-ui/core'; import rison from 'rison'; import { isEmpty } from 'lodash'; import { @@ -175,3 +180,29 @@ export function getDashboardPermalink({ anchor, }); } + +const externalUrlRegex = + /^([^:/?#]+:)?(?:(\/\/)?([^/?#]*))?([^?#]+)?(\?[^#]*)?(#.*)?/; + +// group 1 matches protocol +// group 2 matches '//' +// group 3 matches hostname +export function isUrlExternal(url: string) { + const match = url.match(externalUrlRegex) || []; + return ( + (typeof match[1] === 'string' && match[1].length > 0) || + match[2] === '//' || + (typeof match[3] === 'string' && match[3].length > 0) + ); +} + +export function parseUrl(url: string) { + const match = url.match(externalUrlRegex) || []; + // if url is external but start with protocol or '//', + // it can't be used correctly with element + // in such case, add '//' prefix + if (isUrlExternal(url) && !isDefined(match[1]) && !url.startsWith('//')) { + return `//${url}`; + } + return url; +} diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx index c5b182871f17..107b072f2578 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx @@ -60,6 +60,7 @@ import ImportModelsModal from 'src/components/ImportModal/index'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import WarningIconWithTooltip from 'src/components/WarningIconWithTooltip'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; +import { GenericLink } from 'src/components/GenericLink/GenericLink'; import AddDatasetModal from './AddDatasetModal'; import { @@ -289,7 +290,11 @@ const DatasetList: FunctionComponent = ({ }, }, }: any) => { - const titleLink = {datasetTitle}; + const titleLink = ( + // exploreUrl can be a link to Explore or an external link + // in the first case use SPA routing, else use HTML anchor + {datasetTitle} + ); try { const parsedExtra = JSON.parse(extra); return (