mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
add fork dashboard function (#6588)
* add fork dashboard function * add test * fix --------- Co-authored-by: guyu <guyu@fordeal.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
40
client/app/pages/dashboards/hooks/useDuplicateDashboard.js
Normal file
40
client/app/pages/dashboards/hooks/useDuplicateDashboard.js
Normal 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];
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user