diff --git a/client/app/pages/dashboards/components/DashboardHeader.jsx b/client/app/pages/dashboards/components/DashboardHeader.jsx
index 3cd5c7adfd..b8b27a3920 100644
--- a/client/app/pages/dashboards/components/DashboardHeader.jsx
+++ b/client/app/pages/dashboards/components/DashboardHeader.jsx
@@ -119,6 +119,8 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
managePermissions,
gridDisabled,
isDashboardOwnerOrAdmin,
+ isDuplicating,
+ duplicateDashboard,
} = dashboardConfiguration;
const archive = () => {
@@ -142,6 +144,14 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
setEditingLayout(true)}>Edit
+ {!isDuplicating && dashboard.canEdit() && (
+
+
+ Fork
+ (opens in a new tab)
+
+
+ )}
{clientConfig.showPermissionsControl && isDashboardOwnerOrAdmin && (
Manage Permissions
diff --git a/client/app/pages/dashboards/hooks/useDashboard.js b/client/app/pages/dashboards/hooks/useDashboard.js
index b8ee116eb5..43eeb336d9 100644
--- a/client/app/pages/dashboards/hooks/useDashboard.js
+++ b/client/app/pages/dashboards/hooks/useDashboard.js
@@ -15,6 +15,7 @@ import ShareDashboardDialog from "../components/ShareDashboardDialog";
import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler";
import useRefreshRateHandler from "./useRefreshRateHandler";
import useEditModeHandler from "./useEditModeHandler";
+import useDuplicateDashboard from "./useDuplicateDashboard";
import { policy } from "@/services/policy";
export { DashboardStatusEnum } from "./useEditModeHandler";
@@ -53,6 +54,8 @@ function useDashboard(dashboardData) {
[dashboard]
);
+ const [isDuplicating, duplicateDashboard] = useDuplicateDashboard(dashboard);
+
const managePermissions = useCallback(() => {
const aclUrl = `api/dashboards/${dashboard.id}/acl`;
PermissionsEditorDialog.showModal({
@@ -243,6 +246,8 @@ function useDashboard(dashboardData) {
showAddTextboxDialog,
showAddWidgetDialog,
managePermissions,
+ isDuplicating,
+ duplicateDashboard,
};
}
diff --git a/client/app/pages/dashboards/hooks/useDuplicateDashboard.js b/client/app/pages/dashboards/hooks/useDuplicateDashboard.js
new file mode 100644
index 0000000000..80d68af211
--- /dev/null
+++ b/client/app/pages/dashboards/hooks/useDuplicateDashboard.js
@@ -0,0 +1,40 @@
+import { noop, extend, pick } from "lodash";
+import { useCallback, useState } from "react";
+import url from "url";
+import qs from "query-string";
+import { Dashboard } from "@/services/dashboard";
+
+function keepCurrentUrlParams(targetUrl) {
+ const currentUrlParams = qs.parse(window.location.search);
+ targetUrl = url.parse(targetUrl);
+ const targetUrlParams = qs.parse(targetUrl.search);
+ return url.format(
+ extend(pick(targetUrl, ["protocol", "auth", "host", "pathname"]), {
+ search: qs.stringify(extend(currentUrlParams, targetUrlParams)),
+ })
+ );
+}
+
+export default function useDuplicateDashboard(dashboard) {
+ const [isDuplicating, setIsDuplicating] = useState(false);
+
+ const duplicateDashboard = useCallback(() => {
+ // To prevent opening the same tab, name must be unique for each browser
+ const tabName = `duplicatedDashboardTab/${Math.random().toString()}`;
+
+ // We should open tab here because this moment is a part of user interaction;
+ // later browser will block such attempts
+ const tab = window.open("", tabName);
+
+ setIsDuplicating(true);
+ Dashboard.fork({ id: dashboard.id })
+ .then(newDashboard => {
+ tab.location = keepCurrentUrlParams(newDashboard.getUrl());
+ })
+ .finally(() => {
+ setIsDuplicating(false);
+ });
+ }, [dashboard.id]);
+
+ return [isDuplicating, isDuplicating ? noop : duplicateDashboard];
+}
diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js
index 11c8899945..a4d3550ba5 100644
--- a/client/app/services/dashboard.js
+++ b/client/app/services/dashboard.js
@@ -172,6 +172,7 @@ const DashboardService = {
favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
+ fork: ({ id }) => axios.post(`api/dashboards/${id}/fork`, { id }).then(transformResponse),
};
_.extend(Dashboard, DashboardService);
@@ -265,3 +266,7 @@ Dashboard.prototype.favorite = function favorite() {
Dashboard.prototype.unfavorite = function unfavorite() {
return Dashboard.unfavorite(this);
};
+
+Dashboard.prototype.getUrl = function getUrl() {
+ return urlForDashboard(this);
+};
diff --git a/redash/handlers/api.py b/redash/handlers/api.py
index b5aaa23923..48428daf0f 100644
--- a/redash/handlers/api.py
+++ b/redash/handlers/api.py
@@ -12,6 +12,7 @@
from redash.handlers.base import org_scoped_rule
from redash.handlers.dashboards import (
DashboardFavoriteListResource,
+ DashboardForkResource,
DashboardListResource,
DashboardResource,
DashboardShareResource,
@@ -190,6 +191,7 @@ def json_representation(data, code, headers=None):
"/api/dashboards//favorite",
endpoint="dashboard_favorite",
)
+api.add_org_resource(DashboardForkResource, "/api/dashboards//fork", endpoint="dashboard_fork")
api.add_org_resource(MyDashboardsResource, "/api/dashboards/my", endpoint="my_dashboards")
diff --git a/redash/handlers/dashboards.py b/redash/handlers/dashboards.py
index f6917b194b..56813ea16c 100644
--- a/redash/handlers/dashboards.py
+++ b/redash/handlers/dashboards.py
@@ -398,3 +398,16 @@ def get(self):
)
return response
+
+
+class DashboardForkResource(BaseResource):
+ @require_permission("edit_dashboard")
+ def post(self, dashboard_id):
+ dashboard = models.Dashboard.get_by_id_and_org(dashboard_id, self.current_org)
+
+ fork_dashboard = dashboard.fork(self.current_user)
+ models.db.session.commit()
+
+ self.record_event({"action": "fork", "object_id": dashboard_id, "object_type": "dashboard"})
+
+ return DashboardSerializer(fork_dashboard, with_widgets=True).serialize()
diff --git a/redash/models/__init__.py b/redash/models/__init__.py
index ead454bbd7..908f571ad3 100644
--- a/redash/models/__init__.py
+++ b/redash/models/__init__.py
@@ -1131,6 +1131,21 @@ def by_user(cls, user):
def get_by_slug_and_org(cls, slug, org):
return cls.query.filter(cls.slug == slug, cls.org == org).one()
+ def fork(self, user):
+ forked_list = ["org", "layout", "dashboard_filters_enabled", "tags"]
+
+ kwargs = {a: getattr(self, a) for a in forked_list}
+ forked_dashboard = Dashboard(name="Copy of (#{}) {}".format(self.id, self.name), user=user, **kwargs)
+
+ for w in self.widgets:
+ forked_w = w.copy(forked_dashboard.id)
+ fw = Widget(**forked_w)
+ db.session.add(fw)
+
+ forked_dashboard.slug = forked_dashboard.id
+ db.session.add(forked_dashboard)
+ return forked_dashboard
+
@hybrid_property
def lowercase_name(self):
"Optional property useful for sorting purposes."
@@ -1190,6 +1205,15 @@ def __str__(self):
def get_by_id_and_org(cls, object_id, org):
return super(Widget, cls).get_by_id_and_org(object_id, org, Dashboard)
+ def copy(self, dashboard_id):
+ return {
+ "options": self.options,
+ "width": self.width,
+ "text": self.text,
+ "visualization_id": self.visualization_id,
+ "dashboard_id": dashboard_id,
+ }
+
@generic_repr("id", "object_type", "object_id", "action", "user_id", "org_id", "created_at")
class Event(db.Model):
diff --git a/tests/handlers/test_dashboards.py b/tests/handlers/test_dashboards.py
index 748f5fc36a..5f81b27010 100644
--- a/tests/handlers/test_dashboards.py
+++ b/tests/handlers/test_dashboards.py
@@ -151,6 +151,15 @@ def test_works_for_non_owner_with_permission(self):
self.assertEqual(rv.json["name"], new_name)
+class TestDashboardForkResourcePost(BaseTestCase):
+ def test_forks_a_dashboard(self):
+ dashboard = self.factory.create_dashboard()
+
+ rv = self.make_request("post", "/api/dashboards/{}/fork".format(dashboard.id))
+
+ self.assertEqual(rv.status_code, 200)
+
+
class TestDashboardResourceDelete(BaseTestCase):
def test_delete_dashboard(self):
d = self.factory.create_dashboard()