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 (