Skip to content

Commit

Permalink
add fork dashboard function (#6588)
Browse files Browse the repository at this point in the history
* add fork dashboard function

* add test

* fix

---------

Co-authored-by: guyu <[email protected]>
  • Loading branch information
gaecoli and guyu authored Nov 11, 2023
1 parent 13e61fc commit 2d87951
Show file tree
Hide file tree
Showing 8 changed files with 108 additions and 0 deletions.
10 changes: 10 additions & 0 deletions client/app/pages/dashboards/components/DashboardHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
managePermissions,
gridDisabled,
isDashboardOwnerOrAdmin,
isDuplicating,
duplicateDashboard,
} = dashboardConfiguration;

const archive = () => {
Expand All @@ -142,6 +144,14 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
<Menu.Item className={cx({ hidden: gridDisabled })}>
<PlainButton onClick={() => setEditingLayout(true)}>Edit</PlainButton>
</Menu.Item>
{!isDuplicating && dashboard.canEdit() && (
<Menu.Item>
<PlainButton onClick={duplicateDashboard}>
Fork <i className="fa fa-external-link m-l-5" aria-hidden="true" />
<span className="sr-only">(opens in a new tab)</span>
</PlainButton>
</Menu.Item>
)}
{clientConfig.showPermissionsControl && isDashboardOwnerOrAdmin && (
<Menu.Item>
<PlainButton onClick={managePermissions}>Manage Permissions</PlainButton>
Expand Down
5 changes: 5 additions & 0 deletions client/app/pages/dashboards/hooks/useDashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -243,6 +246,8 @@ function useDashboard(dashboardData) {
showAddTextboxDialog,
showAddWidgetDialog,
managePermissions,
isDuplicating,
duplicateDashboard,
};
}

Expand Down
40 changes: 40 additions & 0 deletions client/app/pages/dashboards/hooks/useDuplicateDashboard.js
Original file line number Diff line number Diff line change
@@ -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];
}
5 changes: 5 additions & 0 deletions client/app/services/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
};
2 changes: 2 additions & 0 deletions redash/handlers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from redash.handlers.base import org_scoped_rule
from redash.handlers.dashboards import (
DashboardFavoriteListResource,
DashboardForkResource,
DashboardListResource,
DashboardResource,
DashboardShareResource,
Expand Down Expand Up @@ -190,6 +191,7 @@ def json_representation(data, code, headers=None):
"/api/dashboards/<object_id>/favorite",
endpoint="dashboard_favorite",
)
api.add_org_resource(DashboardForkResource, "/api/dashboards/<dashboard_id>/fork", endpoint="dashboard_fork")

api.add_org_resource(MyDashboardsResource, "/api/dashboards/my", endpoint="my_dashboards")

Expand Down
13 changes: 13 additions & 0 deletions redash/handlers/dashboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
24 changes: 24 additions & 0 deletions redash/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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):
Expand Down
9 changes: 9 additions & 0 deletions tests/handlers/test_dashboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down

0 comments on commit 2d87951

Please sign in to comment.