add fork dashboard function (#6588)

* add fork dashboard function

* add test

* fix

---------

Co-authored-by: guyu <guyu@fordeal.com>
This commit is contained in:
Peter Lee
2023-11-12 04:56:47 +08:00
committed by GitHub
parent 13e61fc3a0
commit 2d879510e4
8 changed files with 108 additions and 0 deletions

View File

@@ -119,6 +119,8 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
managePermissions,
gridDisabled,
isDashboardOwnerOrAdmin,
isDuplicating,
duplicateDashboard,
} = dashboardConfiguration;
const archive = () => {
@@ -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>

View File

@@ -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,
};
}

View File

@@ -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];
}

View File

@@ -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);
};

View File

@@ -12,6 +12,7 @@ from redash.handlers.alerts import (
from redash.handlers.base import org_scoped_rule
from redash.handlers.dashboards import (
DashboardFavoriteListResource,
DashboardForkResource,
DashboardListResource,
DashboardResource,
DashboardShareResource,
@@ -190,6 +191,7 @@ api.add_org_resource(
"/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")

View File

@@ -398,3 +398,16 @@ class DashboardFavoriteListResource(BaseResource):
)
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()

View File

@@ -1131,6 +1131,21 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
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 @@ class Widget(TimestampMixin, BelongsToOrgMixin, db.Model):
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):

View File

@@ -151,6 +151,15 @@ class TestDashboardResourcePost(BaseTestCase):
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()