From 9e21ae22bf5f72b79f4d766952d8fab9905db9ac Mon Sep 17 00:00:00 2001 From: Ngoc Khuat <qn.khuat@gmail.com> Date: Thu, 1 Jun 2023 17:55:52 +0700 Subject: [PATCH] Dashboard tab revision (#30898) --- src/metabase/api/dashboard.clj | 213 ++++----- src/metabase/events/revision.clj | 3 + src/metabase/models/dashboard.clj | 86 ++-- src/metabase/models/dashboard_tab.clj | 81 +++- src/metabase/util.cljc | 30 +- test/metabase/api/dashboard_test.clj | 20 +- test/metabase/api/revision_test.clj | 6 +- test/metabase/events/revision_test.clj | 103 ++++- test/metabase/models/dashboard_test.clj | 564 +++++++++++++++++------- test/metabase/util_test.cljc | 9 + 10 files changed, 755 insertions(+), 360 deletions(-) diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 8610a07408e..7bb90c31537 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -18,6 +18,7 @@ [metabase.models.collection :as collection] [metabase.models.dashboard :as dashboard :refer [Dashboard]] [metabase.models.dashboard-card :as dashboard-card :refer [DashboardCard]] + [metabase.models.dashboard-tab :as dashboard-tab] [metabase.models.field :refer [Field]] [metabase.models.interface :as mi] [metabase.models.params :as params] @@ -541,88 +542,6 @@ (assoc mapping :card-id (get dashcard-id->card-id dashcard-id)))] (check-parameter-mapping-permissions new-mappings))) -(mu/defn ^:private classify-changes :- [:map - [:to-create [:sequential [:map [:id ms/NegativeInt]]]] - [:to-update [:sequential [:map [:id ms/PositiveInt]]]] - [:to-delete [:sequential [:map [:id ms/PositiveInt]]]]] - "Given 2 lists of seq maps of changes, where each map an has an `id` key, - return a map of 3 keys: `:to-create`, `:to-update`, `:to-delete`. - - Where: - :to-create is a list of maps that has negative ids in `new-items` - :to-update is a list of maps that has ids in both `current-items` and `new-items` - :to delete is a list of maps that has ids only in `current-items`" - [current-items :- [:sequential [:map [:id ms/PositiveInt]]] - new-items :- [:sequential [:map [:id ms/Int]]]] - (let [new-change-ids (set (map :id new-items)) - to-create (filter (comp neg? :id) new-items) - ;; to-update items are new items with id in the current items - to-update (filter (comp pos? :id) new-items) - ;; to delete items in current but not new items - to-delete (remove (comp new-change-ids :id) current-items)] - {:to-update to-update - :to-delete to-delete - :to-create to-create})) - -(mu/defn ^:private create-tabs! :- [:map-of neg-int? pos-int?] - "Create the new tabs and returned a mapping from temporary tab ID to the new tab ID." - [dashboard :- map? - new-tabs :- [:sequential [:map [:id neg-int?]]]] - (let [new-tab-ids (t2/insert-returning-pks! :model/DashboardTab (->> new-tabs - (map #(dissoc % :id)) - (map #(assoc % :dashboard_id (:id dashboard)))))] - - (zipmap (map :id new-tabs) new-tab-ids))) - -(mu/defn ^:private update-tabs! :- nil? - [dashboard :- [:map [:id pos-int?] - [:ordered_tabs [:maybe [:sequential map?]]]] - new-tabs :- [:sequential [:map [:id ms/PositiveInt]]]] - (let [current-tabs (:ordered_tabs dashboard) - update-ks [:name :position] - id->current-tab (group-by :id current-tabs) - to-update-tabs (filter - ;; filter out tabs that haven't changed - (fn [new-tab] - (let [current-tab (get id->current-tab (:id new-tab))] - (not= (select-keys current-tab update-ks) - (select-keys new-tab update-ks)))) - - new-tabs)] - (doseq [tab to-update-tabs] - (t2/update! :model/DashboardTab (:id tab) (select-keys tab update-ks))) - nil)) - -(mu/defn ^:private delete-tabs! :- nil? - [tab-ids :- [:sequential {:min 1} ms/PositiveInt]] - (when (seq tab-ids) - (t2/delete! :model/DashboardTab :id [:in tab-ids])) - nil) - -(defn ^:private do-update-tabs! - "Given current tabs and new tabs, do the necessary create/update/delete to apply new tab changes. - Returns: - - a map of temporary tab ID to the new real tab ID - - a list of deleted tab ids" - [dashboard current-tabs new-tabs] - (when-not (= (range (count new-tabs)) - (sort (map :position new-tabs))) - (throw (ex-info (tru "Tab positions must be sequential and start at 0") - {:status-code 400}))) - (let [{:keys [to-create to-update to-delete]} (classify-changes current-tabs new-tabs) - to-delete-ids (set (map :id to-delete)) - _ (when-let [to-delete-ids (seq to-delete-ids)] - (delete-tabs! to-delete-ids)) - temp-tab-id->tab-id (when (seq to-create) - (create-tabs! dashboard to-create)) - _ (when (seq to-update) - (update-tabs! dashboard to-update))] - {:temp->real-tab-ids temp-tab-id->tab-id - :deleted-tab-ids to-delete-ids - :num-tabs-created (count to-create) - :num-tabs-deleted (count to-delete) - :total-num-tabs (+ (count to-create) (count to-update))})) - (defn- create-dashcards! [dashboard dashcards] (doseq [{:keys [card_id]} dashcards @@ -631,37 +550,29 @@ (check-parameter-mapping-permissions (for [{:keys [card_id parameter_mappings]} dashcards mapping parameter_mappings] (assoc mapping :card-id card_id))) - (u/prog1 (api/check-500 (dashboard/add-dashcards! dashboard (map #(assoc % :creator_id @api/*current-user*) dashcards))) - (events/publish-event! :dashboard-add-cards {:id (:id dashboard) :actor_id api/*current-user-id* :dashcards <>}) - (for [{:keys [card_id]} <> - :when (pos-int? card_id)] - (snowplow/track-event! ::snowplow/question-added-to-dashboard - api/*current-user-id* - {:dashboard-id (:id dashboard) :question-id card_id})))) + (api/check-500 (dashboard/add-dashcards! dashboard (map #(assoc % :creator_id @api/*current-user*) dashcards)))) (defn- update-dashcards! [dashboard dashcards] (check-updated-parameter-mapping-permissions (:id dashboard) dashcards) ;; transform the dashcard data to the format of the DashboardCard model ;; so update-dashcards! can compare them with existing dashcards (dashboard/update-dashcards! dashboard (map dashboard-card/from-parsed-json dashcards)) - ;; TODO this is potentially misleading, we don't know for sure here that the dashcards are repositioned - (events/publish-event! :dashboard-reposition-cards {:id (:id dashboard) :actor_id api/*current-user-id* :dashcards dashcards})) + dashcards) -(defn- delete-dashcards! [{dashboard-id :id :as _dashboard} dashcard-ids] - (when (seq dashcard-ids) - (let [dashboard-cards (t2/select DashboardCard :id [:in dashcard-ids])] - (dashboard-card/delete-dashboard-cards! dashcard-ids) - (events/publish-event! :dashboard-remove-cards {:id dashboard-id :actor_id api/*current-user-id* :dashcards dashboard-cards})))) +(defn- delete-dashcards! [dashcard-ids] + (let [dashboard-cards (t2/select DashboardCard :id [:in dashcard-ids])] + (dashboard-card/delete-dashboard-cards! dashcard-ids) + dashboard-cards)) (defn- do-update-dashcards! [dashboard current-cards new-cards] - (let [{:keys [to-create to-update to-delete]} (classify-changes current-cards new-cards)] - (when (seq to-delete) - (delete-dashcards! dashboard (map :id to-delete))) - (when (seq to-create) - (create-dashcards! dashboard to-create)) - (when (seq to-update) - (update-dashcards! dashboard to-update)))) + (let [{:keys [to-create to-update to-delete]} (u/classify-changes current-cards new-cards)] + {:deleted-dashcards (when (seq to-delete) + (delete-dashcards! (map :id to-delete))) + :created-dashcards (when (seq to-create) + (create-dashcards! dashboard to-create)) + :updated-dashcards (when (seq to-update) + (update-dashcards! dashboard to-update))})) (def ^:private UpdatedDashboardCard [:map @@ -682,6 +593,47 @@ [:id ms/Int] [:name ms/NonBlankString]]) +(defn- track-dashcard-and-tab-events! + [dashboard-id {:keys [created-dashcards deleted-dashcards updated-dashcards + created-tab-ids updated-tab-ids deleted-tab-ids total-num-tabs]}] + ;; Dashcard events + (when (seq deleted-dashcards) + (events/publish-event! :dashboard-remove-cards + {:id dashboard-id :actor_id api/*current-user-id* :dashcards deleted-dashcards})) + (when (seq created-dashcards) + (events/publish-event! :dashboard-add-cards + {:id dashboard-id :actor_id api/*current-user-id* :dashcards created-dashcards}) + (for [{:keys [card_id]} created-dashcards + :when (pos-int? card_id)] + (snowplow/track-event! ::snowplow/question-added-to-dashboard + api/*current-user-id* + {:dashboard-id dashboard-id :question-id card_id}))) + ;; TODO this is potentially misleading, we don't know for sure here that the dashcards are repositioned + (when (seq updated-dashcards) + (events/publish-event! :dashboard-reposition-cards + {:id dashboard-id :actor_id api/*current-user-id* :dashcards updated-dashcards})) + + ;; Tabs events + (when (seq deleted-tab-ids) + (snowplow/track-event! ::snowplow/dashboard-tabs-deleted + api/*current-user-id* + {:dashboard-id dashboard-id + :num-tabs (count deleted-tab-ids) + :total-num-tabs total-num-tabs}) + (events/publish-event! :dashboard-remove-tabs + {:id dashboard-id :actor_id api/*current-user-id* :tab-ids deleted-tab-ids})) + (when (seq created-tab-ids) + (snowplow/track-event! ::snowplow/dashboard-tabs-created + api/*current-user-id* + {:dashboard-id dashboard-id + :num-tabs (count created-tab-ids) + :total-num-tabs total-num-tabs}) + (events/publish-event! :dashboard-add-tabs + {:id dashboard-id :actor_id api/*current-user-id* :tab-ids created-tab-ids})) + (when (seq updated-tab-ids) + (events/publish-event! :dashboard-update-tabs + {:id dashboard-id :actor_id api/*current-user-id* :tab-ids updated-tab-ids}))) + (api/defendpoint PUT "/:id/cards" "Update `Cards` and `Tabs` on a Dashboard. Request body should have the form: @@ -711,40 +663,33 @@ (throw (ex-info (tru "This dashboard has tab, makes sure every card has a tab") {:status-code 400}))) (api/check-500 - (let [tabs-stats-atom (atom nil)] + (let [changes-stats (atom nil)] (t2/with-transaction [_conn] - (let [{:keys [temp->real-tab-ids + (let [{:keys [old->new-tab-id deleted-tab-ids] - :as tab-stats} (do-update-tabs! dashboard (:ordered_tabs dashboard) new-tabs) - current-cards (cond->> (:ordered_cards dashboard) - (seq deleted-tab-ids) - (remove (fn [card] - (contains? deleted-tab-ids (:dashboard_tab_id card))))) - new-cards (cond->> cards - ;; fixup the temporary tab ids with the real ones - (seq temp->real-tab-ids) - (map (fn [card] - (if-let [real-tab-id (get temp->real-tab-ids (:dashboard_tab_id card))] - (assoc card :dashboard_tab_id real-tab-id) - card))))] - (do-update-dashcards! dashboard current-cards new-cards) - (reset! tabs-stats-atom (select-keys tab-stats [:num-tabs-created :num-tabs-deleted :total-num-tabs])))) - (let [{:keys [num-tabs-created num-tabs-deleted total-num-tabs]} @tabs-stats-atom] - (when (pos-int? num-tabs-created) - (snowplow/track-event! ::snowplow/dashboard-tabs-created - api/*current-user-id* - {:dashboard-id (:id dashboard) - :num-tabs num-tabs-created - :total-num-tabs total-num-tabs})) - (when (pos-int? num-tabs-deleted) - (snowplow/track-event! ::snowplow/dashboard-tabs-deleted - api/*current-user-id* - {:dashboard-id (:id dashboard) - :num-tabs num-tabs-deleted - :total-num-tabs total-num-tabs}))) + :as tabs-changes-stats} (dashboard-tab/do-update-tabs! (:id dashboard) (:ordered_tabs dashboard) new-tabs) + deleted-tab-ids (set deleted-tab-ids) + current-cards (cond->> (:ordered_cards dashboard) + (seq deleted-tab-ids) + (remove (fn [card] + (contains? deleted-tab-ids (:dashboard_tab_id card))))) + new-cards (cond->> cards + ;; fixup the temporary tab ids with the real ones + (seq old->new-tab-id) + (map (fn [card] + (if-let [real-tab-id (get old->new-tab-id (:dashboard_tab_id card))] + (assoc card :dashboard_tab_id real-tab-id) + card)))) + dashcards-changes-stats (do-update-dashcards! dashboard current-cards new-cards)] + (reset! changes-stats + (merge + (select-keys tabs-changes-stats [:created-tab-ids :updated-tab-ids :deleted-tab-ids :total-num-tabs]) + (select-keys dashcards-changes-stats [:created-dashcards :deleted-dashcards :updated-dashcards]))))) + ;; trigger events out of tx so rows are committed and visible from other threads + (track-dashcard-and-tab-events! id @changes-stats) true)) - {:cards (t2/hydrate (dashboard/ordered-cards id) :series) - :ordered_tabs (dashboard/ordered-tabs id)})) + {:cards (t2/hydrate (dashboard/ordered-cards id) :series) + :ordered_tabs (dashboard/ordered-tabs id)})) (api/defendpoint GET "/:id/revisions" "Fetch `Revisions` for Dashboard with ID." diff --git a/src/metabase/events/revision.clj b/src/metabase/events/revision.clj index 3673a76eb4e..43b2f1810fe 100644 --- a/src/metabase/events/revision.clj +++ b/src/metabase/events/revision.clj @@ -19,6 +19,9 @@ :dashboard-add-cards :dashboard-remove-cards :dashboard-reposition-cards + :dashboard-add-tabs + :dashboard-remove-tabs + :dashboard-update-tabs :metric-create :metric-update :metric-delete diff --git a/src/metabase/models/dashboard.clj b/src/metabase/models/dashboard.clj index 23b1394190d..082d6cb1256 100644 --- a/src/metabase/models/dashboard.clj +++ b/src/metabase/models/dashboard.clj @@ -13,6 +13,7 @@ [metabase.models.dashboard-card :as dashboard-card :refer [DashboardCard]] + [metabase.models.dashboard-tab :as dashboard-tab] [metabase.models.field-values :as field-values] [metabase.models.interface :as mi] [metabase.models.parameter-card :as parameter-card] @@ -35,7 +36,8 @@ [methodical.core :as methodical] [schema.core :as s] [toucan.hydrate :refer [hydrate]] - [toucan2.core :as t2])) + [toucan2.core :as t2] + [toucan2.realize :as t2.realize])) (def Dashboard "Used to be the toucan1 model name defined using [[toucan.models/defmodel]], not it's a reference to the toucan2 model name. @@ -44,7 +46,6 @@ (methodical/defmethod t2/table-name :model/Dashboard [_model] :report_dashboard) - (doto :model/Dashboard (derive :metabase/model) (derive ::perms/use-parent-collection-perms) @@ -183,36 +184,42 @@ (-> dashboard (select-keys [:collection_id :description :name :cache_ttl :auto_apply_filters]) (assoc :cards (vec (for [dashboard-card (ordered-cards dashboard)] - (-> (select-keys dashboard-card [:size_x :size_y :row :col :id :card_id]) - (assoc :series (mapv :id (dashboard-card/series dashboard-card))))))))) + (-> (select-keys dashboard-card [:dashboard_tab_id :size_x :size_y :row :col :id :card_id]) + (assoc :series (mapv :id (dashboard-card/series dashboard-card))))))) + (assoc :tabs (map #(dissoc % :created_at :updated_at :entity_id) (ordered-tabs dashboard))))) + +(defn- revert-dashcards + [dashboard-id serialized-cards] + (let [current-cards (t2/select [DashboardCard :id :size_x :size_y :row :col :card_id :dashboard_id] + :dashboard_id dashboard-id) + id->current-card (zipmap (map :id current-cards) current-cards) + {:keys [to-create to-update to-delete]} (u/classify-changes current-cards serialized-cards)] + (when (seq to-delete) + (dashboard-card/delete-dashboard-cards! (map :id to-delete))) + (when (seq to-create) + (dashboard-card/create-dashboard-cards! (map #(assoc % :dashboard_id dashboard-id) to-create))) + (when (seq to-update) + (doseq [update-card to-update] + (dashboard-card/update-dashboard-card! update-card (id->current-card (:id update-card))))))) (defmethod revision/revert-to-revision! :model/Dashboard - [_model dashboard-id user-id serialized-dashboard] + [_model dashboard-id _user-id serialized-dashboard] ;; Update the dashboard description / name / permissions - (t2/update! :model/Dashboard dashboard-id, (dissoc serialized-dashboard :cards)) - ;; Now update the cards as needed - (let [serialized-cards (:cards serialized-dashboard) - id->serialized-card (zipmap (map :id serialized-cards) serialized-cards) - current-cards (t2/select [DashboardCard :size_x :size_y :row :col :id :card_id :dashboard_id] - :dashboard_id dashboard-id) - id->current-card (zipmap (map :id current-cards) current-cards) - all-dashcard-ids (concat (map :id serialized-cards) - (map :id current-cards))] - (doseq [dashcard-id all-dashcard-ids] - (let [serialized-card (id->serialized-card dashcard-id) - current-card (id->current-card dashcard-id)] - (cond - ;; If card is in current-cards but not serialized-cards then we need to delete it - (not serialized-card) (dashboard-card/delete-dashboard-cards! [(:id current-card)]) - - ;; If card is in serialized-cards but not current-cards we need to add it - (not current-card) (dashboard-card/create-dashboard-cards! [(assoc serialized-card - :dashboard_id dashboard-id - :creator_id user-id)]) - - ;; If card is in both we need to update it to match serialized-card as needed - :else (dashboard-card/update-dashboard-card! serialized-card current-card))))) - + (t2/update! :model/Dashboard dashboard-id (dissoc serialized-dashboard :cards :tabs)) + ;; Now update the tabs and cards as needed + (let [serialized-cards (:cards serialized-dashboard) + current-tabs (t2/select-fn-vec #(dissoc (t2.realize/realize %) :created_at :updated_at :entity_id :dashboard_id) + :model/DashboardTab :dashboard_id dashboard-id) + {:keys [old->new-tab-id]} (dashboard-tab/do-update-tabs! dashboard-id current-tabs (:tabs serialized-dashboard)) + serialized-cards (cond->> serialized-cards + ;; in case reverting result in new tabs being created, + ;; we need to remap the tab-id + (seq old->new-tab-id) + (map (fn [card] + (if-let [new-tab-id (get old->new-tab-id (:dashboard_tab_id card))] + (assoc card :dashboard_tab_id new-tab-id) + card))))] + (revert-dashcards dashboard-id serialized-cards)) serialized-dashboard) (defmethod revision/diff-strings :model/Dashboard @@ -258,6 +265,27 @@ (= prev-card-ids new-card-ids)) (deferred-tru "rearranged the cards") :else (deferred-tru "modified the cards")))) + (when (or (:tabs changes) (:tabs removals)) + (let [prev-tabs (:tabs prev-dashboard) + new-tabs (:tabs dashboard) + prev-tab-ids (set (map :id prev-tabs)) + num-prev-tabs (count prev-tab-ids) + new-tab-ids (set (map :id new-tabs)) + num-new-tabs (count new-tab-ids) + num-tabs-diff (abs (- num-prev-tabs num-new-tabs))] + (cond + (and + (set/subset? prev-tab-ids new-tab-ids) + (< num-prev-tabs num-new-tabs)) (deferred-trun "added a tab" "added {0} tabs" num-tabs-diff) + + (and + (set/subset? new-tab-ids prev-tab-ids) + (> num-prev-tabs num-new-tabs)) (deferred-trun "removed a tab" "removed {0} tabs" num-tabs-diff) + + (= (set (map #(dissoc % :position) prev-tabs)) + (set (map #(dissoc % :position) new-tabs))) (deferred-tru "rearranged the tabs") + + :else (deferred-tru "modified the tabs")))) (let [f (comp boolean :auto_apply_filters)] (when (not= (f prev-dashboard) (f dashboard)) (deferred-tru "set auto apply filters to {0}" (str (f dashboard)))))] diff --git a/src/metabase/models/dashboard_tab.clj b/src/metabase/models/dashboard_tab.clj index 012779578ec..5e8e4903ba9 100644 --- a/src/metabase/models/dashboard_tab.clj +++ b/src/metabase/models/dashboard_tab.clj @@ -1,10 +1,12 @@ (ns metabase.models.dashboard-tab (:require - [metabase.models.dashboard :refer [Dashboard]] - [metabase.models.dashboard-card :refer [DashboardCard]] + [medley.core :as m] [metabase.models.interface :as mi] [metabase.models.serialization :as serdes] + [metabase.util :as u] [metabase.util.date-2 :as u.date] + [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms] [methodical.core :as methodical] [toucan2.core :as t2] [toucan2.tools.hydrate :as t2.hydrate])) @@ -40,7 +42,7 @@ (assert (= 1 (count (set (map :dashboard_id tabs)))), "All tabs must belong to the same dashboard") (let [dashboard-id (:dashboard_id (first tabs)) tab-ids (map :id tabs) - dashcards (t2/select DashboardCard :dashboard_id dashboard-id :dashboard_tab_id [:in tab-ids]) + dashcards (t2/select :model/DashboardCard :dashboard_id dashboard-id :dashboard_tab_id [:in tab-ids]) tab-id->dashcards (-> (group-by :dashboard_tab_id dashcards) (update-vals #(sort dashcard-comparator %))) ordered-tabs (sort-by :position tabs)] @@ -50,7 +52,7 @@ (defmethod mi/perms-objects-set :model/DashboardTab [dashtab read-or-write] (let [dashboard (or (:dashboard dashtab) - (t2/select-one Dashboard :id (:dashboard_id dashtab)))] + (t2/select-one :model/Dashboard :id (:dashboard_id dashtab)))] (mi/perms-objects-set dashboard read-or-write))) @@ -59,19 +61,84 @@ [_dashboard-tab] [:name (comp serdes/identity-hash - #(t2/select-one Dashboard :id %) + #(t2/select-one :model/Dashboard :id %) :dashboard_id) :position :created_at]) ;; DashboardTabs are not serialized as their own, separate entities. They are inlined onto their parent Dashboards. (defmethod serdes/generate-path "DashboardTab" [_ dashcard] - [(serdes/infer-self-path "Dashboard" (t2/select-one 'Dashboard :id (:dashboard_id dashcard))) + [(serdes/infer-self-path "Dashboard" (t2/select-one :model/Dashboard :id (:dashboard_id dashcard))) (serdes/infer-self-path "DashboardTab" dashcard)]) (defmethod serdes/load-xform "DashboardTab" [dashtab] (-> dashtab (dissoc :serdes/meta) - (update :dashboard_id serdes/*import-fk* 'Dashboard) + (update :dashboard_id serdes/*import-fk* :model/Dashboard) (update :created_at #(if (string? %) (u.date/parse %) %)))) + +;;; -------------------------------------------------- CRUD fns ------------------------------------------------------ + +(mu/defn create-tabs! :- [:map-of neg-int? pos-int?] + "Create the new tabs and returned a mapping from temporary tab ID to the new tab ID." + [dashboard-id :- ms/PositiveInt + new-tabs :- [:sequential [:map [:id neg-int?]]]] + (let [new-tab-ids (t2/insert-returning-pks! :model/DashboardTab (->> new-tabs + (map #(dissoc % :id)) + (map #(assoc % :dashboard_id dashboard-id))))] + (zipmap (map :id new-tabs) new-tab-ids))) + +(mu/defn update-tabs! :- nil? + "Updates tabs of a dashboard if changed." + [current-tabs :- [:sequential [:map [:id ms/PositiveInt]]] + new-tabs :- [:sequential [:map [:id ms/PositiveInt]]]] + (let [update-ks [:name :position] + id->current-tab (m/index-by :id current-tabs) + to-update-tabs (filter + ;; filter out tabs that haven't changed + (fn [new-tab] + (let [current-tab (get id->current-tab (:id new-tab))] + (not= (select-keys current-tab update-ks) + (select-keys new-tab update-ks)))) + + new-tabs)] + (doseq [tab to-update-tabs] + (t2/update! :model/DashboardTab (:id tab) (select-keys tab update-ks))) + nil)) + +(mu/defn delete-tabs! :- nil? + "Delete tabs of a Dashboard" + [tab-ids :- [:sequential {:min 1} ms/PositiveInt]] + (when (seq tab-ids) + (t2/delete! :model/DashboardTab :id [:in tab-ids])) + nil) + +(defn do-update-tabs! + "Given current tabs and new tabs, do the necessary create/update/delete to apply new tab changes. + Returns: + - `old->new-tab-id`: a map from tab IDs in `new-tabs` to newly created tab IDs + - `created-tab-ids` + - `updated-tab-ids` + - `deleted-tab-ids` + - `total-num-tabs`: the total number of active tabs after the operation." + [dashboard-id current-tabs new-tabs] + (let [{:keys [to-create + to-update + to-delete]} (u/classify-changes current-tabs new-tabs) + to-delete-ids (map :id to-delete) + _ (when-let [to-delete-ids (seq to-delete-ids)] + (delete-tabs! to-delete-ids)) + old->new-tab-id (when (seq to-create) + (let [new-tab-ids (t2/insert-returning-pks! :model/DashboardTab + (->> to-create + (map #(dissoc % :id)) + (map #(assoc % :dashboard_id dashboard-id))))] + (zipmap (map :id to-create) new-tab-ids)))] + (when (seq to-update) + (update-tabs! current-tabs to-update)) + {:old->new-tab-id old->new-tab-id + :created-tab-ids (vals old->new-tab-id) + :updated-tab-ids (map :id to-update) + :deleted-tab-ids to-delete-ids + :total-num-tabs (reduce + (map count [to-create to-update]))})) diff --git a/src/metabase/util.cljc b/src/metabase/util.cljc index 1a769a45ec7..f2afcdddd50 100644 --- a/src/metabase/util.cljc +++ b/src/metabase/util.cljc @@ -2,6 +2,7 @@ "Common utility functions useful throughout the codebase." (:require [camel-snake-kebab.internals.macros :as csk.macros] + [clojure.data :refer [diff]] [clojure.pprint :as pprint] [clojure.set :as set] [clojure.string :as str] @@ -794,10 +795,25 @@ m (assoc m k v))) ([m k v & kvs] - (let [ret (assoc-default m k v)] - (if kvs - (if (next kvs) - (recur ret (first kvs) (second kvs) (nnext kvs)) - (throw (ex-info "assoc-default expects an even number of key-values" - {:kvs kvs}))) - ret)))) + (let [ret (assoc-default m k v)] + (if kvs + (if (next kvs) + (recur ret (first kvs) (second kvs) (nnext kvs)) + (throw (ex-info "assoc-default expects an even number of key-values" + {:kvs kvs}))) + ret)))) + +(defn classify-changes + "Given 2 lists of seq maps of changes, where each map an has an `id` key, + return a map of 3 keys: `:to-create`, `:to-update`, `:to-delete`. + + Where: + :to-create is a list of maps that ids in `new-items` + :to-update is a list of maps that has ids in both `current-items` and `new-items` + :to delete is a list of maps that has ids only in `current-items`" + [current-items new-items] + (let [[delete-ids create-ids update-ids] (diff (set (map :id current-items)) + (set (map :id new-items)))] + {:to-create (when (seq create-ids) (filter #(create-ids (:id %)) new-items)) + :to-delete (when (seq delete-ids) (filter #(delete-ids (:id %)) current-items)) + :to-update (when (seq update-ids) (filter #(update-ids (:id %)) new-items))})) diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index aa7736fd67e..9cf7b114e35 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -1663,14 +1663,14 @@ :name "New tab 2"}] :cards []}) (is (= [{:data {"dashboard_id" dashboard-id - "num_tabs" 2 + "num_tabs" 1 "total_num_tabs" 4 - "event" "dashboard_tabs_created"} + "event" "dashboard_tabs_deleted"}, :user-id (str (mt/user->id :rasta))} {:data {"dashboard_id" dashboard-id - "num_tabs" 1 + "num_tabs" 2 "total_num_tabs" 4 - "event" "dashboard_tabs_deleted"}, + "event" "dashboard_tabs_created"} :user-id (str (mt/user->id :rasta))}] (take-last 2 (snowplow-test/pop-event-data-and-user-id!)))))) @@ -2052,7 +2052,7 @@ (is (= 1 (t2/count :model/DashboardTab :dashboard_id dashboard-id))))))) (testing "prune" - (with-simple-dashboard-with-tabs [{:keys [dashboard-id dashtab-id-2]}] + (with-simple-dashboard-with-tabs [{:keys [dashboard-id]}] (testing "we have 2 tabs, each has 1 card to begin with" (is (= 2 (t2/count DashboardCard, :dashboard_id dashboard-id))) @@ -2063,7 +2063,7 @@ (mt/user-http-request :rasta :put 200 (format "dashboard/%d/cards" dashboard-id) {:ordered_tabs [] - :cards (remove #(= (:dashboard_tab_id %) dashtab-id-2) (current-cards dashboard-id))}))) + :cards []}))) (testing "dashboard should be empty" (testing "0 card left" (is (= 0 @@ -2072,14 +2072,6 @@ (is (= 0 (t2/count :model/DashboardTab :dashboard_id dashboard-id))))))))) -(deftest classify-changes-test - (testing "classify correctly" - (is (= {:to-update [{:id 2 :name "c3"} {:id 4 :name "c4"}] - :to-delete [{:id 1 :name "c1"} {:id 3 :name "c3"}] - :to-create [{:id -1 :name "-c1"}]} - (#'api.dashboard/classify-changes - [{:id 1 :name "c1"} {:id 2 :name "c2"} {:id 3 :name "c3"} {:id 4 :name "c4"}] - [{:id -1 :name "-c1"} {:id 2 :name "c3"} {:id 4 :name "c4"}]))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | GET /api/dashboard/:id/revisions | diff --git a/test/metabase/api/revision_test.clj b/test/metabase/api/revision_test.clj index f789f820325..155dc746b83 100644 --- a/test/metabase/api/revision_test.clj +++ b/test/metabase/api/revision_test.clj @@ -147,14 +147,14 @@ :message nil :user @rasta-revision-info :diff {:before {:cards nil} - :after {:cards [{:size_x 4, :size_y 4, :row 0, :col 0, :card_id card-id, :series []}]}} + :after {:cards [{:size_x 4 :size_y 4 :row 0 :col 0 :card_id card-id :series [] :dashboard_tab_id nil}]}} :has_multiple_changes false :description "reverted to an earlier version."} {:is_reversion false :is_creation false :message nil :user @rasta-revision-info - :diff {:before {:cards [{:size_x 4, :size_y 4, :row 0, :col 0, :card_id card-id, :series []}]} + :diff {:before {:cards [{:size_x 4 :size_y 4 :row 0 :col 0 :card_id card-id :series [] :dashboard_tab_id nil}]} :after {:cards nil}} :has_multiple_changes false :description "removed a card."} @@ -163,7 +163,7 @@ :message nil :user @rasta-revision-info :diff {:before {:cards nil} - :after {:cards [{:size_x 4, :size_y 4, :row 0, :col 0, :card_id card-id, :series []}]}} + :after {:cards [{:size_x 4 :size_y 4 :row 0 :col 0 :card_id card-id :series [] :dashboard_tab_id nil}]}} :has_multiple_changes false :description "added a card."} {:is_reversion false diff --git a/test/metabase/events/revision_test.clj b/test/metabase/events/revision_test.clj index d7e13e30dcf..c5137d9022f 100644 --- a/test/metabase/events/revision_test.clj +++ b/test/metabase/events/revision_test.clj @@ -6,7 +6,8 @@ :refer [Card Dashboard DashboardCard Database Metric Revision Segment Table]] [metabase.test :as mt] [metabase.util :as u] - [toucan2.core :as t2])) + [toucan2.core :as t2] + [toucan2.tools.with-temp :as t2.with-temp])) (defn- card-properties "Some default properties for `Cards` for use in tests in this namespace." @@ -48,7 +49,9 @@ :description nil :cache_ttl nil :auto_apply_filters true - :name (:name dashboard)}) + :name (:name dashboard) + :tabs [] + :cards []}) (deftest card-create-test (testing ":card-create" @@ -106,7 +109,7 @@ (is (= {:model "Dashboard" :model_id dashboard-id :user_id (mt/user->id :rasta) - :object (assoc (dashboard->revision-object dashboard) :cards []) + :object (dashboard->revision-object dashboard) :is_reversion false :is_creation false} (mt/derecordize @@ -126,7 +129,8 @@ (is (= {:model "Dashboard" :model_id dashboard-id :user_id (mt/user->id :rasta) - :object (assoc (dashboard->revision-object dashboard) :cards [(assoc (select-keys dashcard [:id :card_id :size_x :size_y :row :col]) :series [])]) + :object (assoc (dashboard->revision-object dashboard) + :cards [(assoc (select-keys dashcard [:id :card_id :size_x :size_y :row :col :dashboard_tab_id]) :series [])]) :is_reversion false :is_creation false} (mt/derecordize @@ -168,13 +172,90 @@ (is (= {:model "Dashboard" :model_id dashboard-id :user_id (mt/user->id :crowberto) - :object (assoc (dashboard->revision-object dashboard) :cards [{:id (:id dashcard) - :card_id card-id - :size_x 3 - :size_y 4 - :row 0 - :col 0 - :series []}]) + :object (assoc (dashboard->revision-object dashboard) :cards [{:id (:id dashcard) + :card_id card-id + :size_x 3 + :size_y 4 + :row 0 + :col 0 + :series [] + :dashboard_tab_id nil}]) + :is_reversion false + :is_creation false} + (mt/derecordize + (t2/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Dashboard" + :model_id dashboard-id))))))) + +(deftest dashboard-add-tabs-test + (testing ":dashboard-add-tabs" + (t2.with-temp/with-temp + [:model/Dashboard {dashboard-id :id, :as dashboard} {:name "A dashboard"} + :model/DashboardTab {dashtab-id :id} {:name "First tab" + :position 0 + :dashboard_id dashboard-id}] + (revision/process-revision-event! {:topic :dashboard-add-tabs + :item {:id dashboard-id + :actor_id (mt/user->id :rasta) + :tab_ids [dashtab-id]}}) + (is (= {:model "Dashboard" + :model_id dashboard-id + :user_id (mt/user->id :rasta) + :object (assoc (dashboard->revision-object dashboard) + :tabs [{:id dashtab-id + :name "First tab" + :position 0 + :dashboard_id dashboard-id}]) + :is_reversion false + :is_creation false} + (mt/derecordize + (t2/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Dashboard" + :model_id dashboard-id))))))) + +(deftest dashboard-update-tabs-test + (testing ":dashboard-update-tabs" + (t2.with-temp/with-temp + [:model/Dashboard {dashboard-id :id, :as dashboard} {:name "A dashboard"} + :model/DashboardTab {dashtab-id :id} {:name "First tab" + :position 0 + :dashboard_id dashboard-id}] + (t2/update! :model/DashboardTab dashtab-id {:name "New name"}) + (revision/process-revision-event! {:topic :dashboard-update-tabs + :item {:id dashboard-id + :actor_id (mt/user->id :rasta) + :tab_ids [dashtab-id]}}) + (is (= {:model "Dashboard" + :model_id dashboard-id + :user_id (mt/user->id :rasta) + :object (assoc (dashboard->revision-object dashboard) + :tabs [{:id dashtab-id + :name "New name" + :position 0 + :dashboard_id dashboard-id}]) + :is_reversion false + :is_creation false} + (mt/derecordize + (t2/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Dashboard" + :model_id dashboard-id))))))) + +(deftest dashboard-delete-tabs-test + (testing ":dashboard-delete-tabs" + (t2.with-temp/with-temp + [:model/Dashboard {dashboard-id :id, :as dashboard} {:name "A dashboard"} + :model/DashboardTab {dashtab-id :id} {:name "First tab" + :position 0 + :dashboard_id dashboard-id}] + (t2/delete! :model/DashboardTab dashtab-id) + (revision/process-revision-event! {:topic :dashboard-delete-tabs + :item {:id dashboard-id + :actor_id (mt/user->id :rasta) + :tab_ids [dashtab-id]}}) + (is (= {:model "Dashboard" + :model_id dashboard-id + :user_id (mt/user->id :rasta) + :object (dashboard->revision-object dashboard) :is_reversion false :is_creation false} (mt/derecordize diff --git a/test/metabase/models/dashboard_test.clj b/test/metabase/models/dashboard_test.clj index 6ffe1f91abd..e06f4b0ac43 100644 --- a/test/metabase/models/dashboard_test.clj +++ b/test/metabase/models/dashboard_test.clj @@ -4,7 +4,7 @@ [metabase.api.common :as api] [metabase.automagic-dashboards.core :as magic] [metabase.models :refer [Card Collection Dashboard DashboardCard DashboardCardSeries - Database Field Pulse PulseCard Table]] + Database Field Pulse PulseCard Revision Table]] [metabase.models.collection :as collection] [metabase.models.dashboard :as dashboard] [metabase.models.dashboard-card :as dashboard-card] @@ -18,6 +18,7 @@ [metabase.test.data.users :as test.users] [metabase.test.util :as tu] [metabase.util :as u] + [schema.core :as s] [toucan.util.test :as tt] [toucan2.core :as t2] [toucan2.tools.with-temp :as t2.with-temp]) @@ -29,168 +30,213 @@ ;; ## Dashboard Revisions (deftest serialize-dashboard-test - (tt/with-temp* [Dashboard [{dashboard-id :id :as dashboard} {:name "Test Dashboard"}] - Card [{card-id :id}] - Card [{series-id-1 :id}] - Card [{series-id-2 :id}] - DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id}] - DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-1, :position 0}] - DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-2, :position 1}]] - (is (= {:name "Test Dashboard" - :auto_apply_filters true - :collection_id nil - :description nil - :cache_ttl nil - :cards [{:size_x 4 - :size_y 4 - :row 0 - :col 0 - :id true - :card_id true - :series true}]} - (update (revision/serialize-instance Dashboard (:id dashboard) dashboard) - :cards - (fn [[{:keys [id card_id series], :as card}]] - [(assoc card - :id (= dashcard-id id) - :card_id (= card-id card_id) - :series (= [series-id-1 series-id-2] series))])))))) - + (testing "without tabs" + (tt/with-temp* [Dashboard [{dashboard-id :id :as dashboard} {:name "Test Dashboard"}] + Card [{card-id :id}] + Card [{series-id-1 :id}] + Card [{series-id-2 :id}] + DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id}] + DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-1, :position 0}] + DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-2, :position 1}]] + (is (= {:name "Test Dashboard" + :auto_apply_filters true + :collection_id nil + :description nil + :cache_ttl nil + :cards [{:size_x 4 + :size_y 4 + :row 0 + :col 0 + :id true + :card_id true + :series true + :dashboard_tab_id nil}] + :tabs []} + (update (revision/serialize-instance Dashboard (:id dashboard) dashboard) + :cards + (fn [[{:keys [id card_id series], :as card}]] + [(assoc card + :id (= dashcard-id id) + :card_id (= card-id card_id) + :series (= [series-id-1 series-id-2] series))])))))) + + (testing "with tabs" + (mt/with-temp* [Dashboard [{dashboard-id :id :as dashboard} {:name "Test Dashboard"}] + :model/DashboardTab [{tab-id :id} {:dashboard_id dashboard-id :name "Test Tab" :position 0}] + DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id :dashboard_tab_id tab-id}]] + (is (=? {:name "Test Dashboard" + :auto_apply_filters true + :collection_id nil + :description nil + :cache_ttl nil + :cards [{:size_x 4 + :size_y 4 + :row 0 + :col 0 + :id dashcard-id + :dashboard_tab_id tab-id}] + :tabs [{:id tab-id + :name "Test Tab" + :position 0}]} + (revision/serialize-instance Dashboard (:id dashboard) dashboard)))))) (deftest diff-dashboards-str-test - (is (= "added a description and renamed it from \"Diff Test\" to \"Diff Test Changed\"." + (testing "update general info ---" + (is (= "added a description and renamed it from \"Diff Test\" to \"Diff Test Changed\"." + (build-sentence + (revision/diff-strings + Dashboard + {:name "Diff Test" + :description nil + :cards []} + {:name "Diff Test Changed" + :description "foobar" + :cards []})))) + + (is (= "renamed it from \"Apple\" to \"Next\" and modified the cards." + (build-sentence + (revision/diff-strings + Dashboard + {:name "Apple" + :cards [{:id 1} {:id 2}]} + {:name "Next" + :cards [{:id 1} {:id 3}]})))) + + (is (= "set auto apply filters to false." + (build-sentence + (revision/diff-strings + Dashboard + {:name "Diff Test" + :auto_apply_filters true} + {:name "Diff Test" + :auto_apply_filters false})))) + + ;; multiple changes + (is (= "changed the cache ttl from \"333\" to \"1,227\", rearranged the cards, modified the series on card 1 and added some series to card 2." + (build-sentence + (revision/diff-strings + Dashboard + {:name "Diff Test" + :description nil + :cache_ttl 333 + :cards [{:size_x 4 + :size_y 4 + :row 0 + :col 0 + :id 1 + :card_id 1 + :series [5 6]} + {:size_x 4 + :size_y 4 + :row 0 + :col 0 + :id 2 + :card_id 2 + :series []}]} + {:name "Diff Test" + :description nil + :cache_ttl 1227 + :cards [{:size_x 4 + :size_y 4 + :row 0 + :col 0 + :id 1 + :card_id 1 + :series [4 5]} + {:size_x 4 + :size_y 4 + :row 2 + :col 0 + :id 2 + :card_id 2 + :series [3 4 5]}]}))))) + + (testing "update cards ---" + (is (= "added a card." (build-sentence (revision/diff-strings Dashboard - {:name "Diff Test" - :description nil - :cards []} - {:name "Diff Test Changed" - :description "foobar" - :cards []})))) + {:cards [{:id 1} {:id 2}]} + {:cards [{:id 1} {:id 2} {:id 3}]})))) - (is (= "added a card." + (is (= "removed a card." (build-sentence (revision/diff-strings Dashboard - {:name "Diff Test" - :description nil - :cards []} - {:name "Diff Test" - :description nil - :cards [{:size_x 4 - :size_y 4 - :row 0 - :col 0 - :id 1 - :card_id 1 - :series []}]})))) - - (is (= "set auto apply filters to false." + {:cards [{:id 1} {:id 2}]} + {:cards [{:id 1}]})))) + + (is (= "rearranged the cards." (build-sentence (revision/diff-strings Dashboard - {:name "Diff Test" - :auto_apply_filters true} - {:name "Diff Test" - :auto_apply_filters false})))) + {:cards [{:id 1 :row 0} {:id 2 :row 1}]} + {:cards [{:id 1 :row 1} {:id 2 :row 2}]})))) - (is (= "changed the cache ttl from \"333\" to \"1,227\", rearranged the cards, modified the series on card 1 and added some series to card 2." + (is (= "modified the cards." (build-sentence (revision/diff-strings Dashboard - {:name "Diff Test" - :description nil - :cache_ttl 333 - :cards [{:size_x 4 - :size_y 4 - :row 0 - :col 0 - :id 1 - :card_id 1 - :series [5 6]} - {:size_x 4 - :size_y 4 - :row 0 - :col 0 - :id 2 - :card_id 2 - :series []}]} - {:name "Diff Test" - :description nil - :cache_ttl 1227 - :cards [{:size_x 4 - :size_y 4 - :row 0 - :col 0 - :id 1 - :card_id 1 - :series [4 5]} - {:size_x 4 - :size_y 4 - :row 2 - :col 0 - :id 2 - :card_id 2 - :series [3 4 5]}]})))) - - (is (= "added a card." - (build-sentence - (revision/diff-strings - Dashboard - {:cards [{:id 1} {:id 2}]} - {:cards [{:id 1} {:id 2} {:id 3}]})))) - - (is (= "removed a card." - (build-sentence - (revision/diff-strings - Dashboard - {:cards [{:id 1} {:id 2}]} - {:cards [{:id 1}]})))) - - (is (= "rearranged the cards." - (build-sentence - (revision/diff-strings - Dashboard - {:cards [{:id 1 :row 0} {:id 2 :row 1}]} - {:cards [{:id 1 :row 1} {:id 2 :row 2}]})))) - - (is (= "modified the cards." - (build-sentence - (revision/diff-strings - Dashboard - {:cards [{:id 1} {:id 2}]} - {:cards [{:id 1} {:id 3}]})))) - - (is (= "renamed it from \"Apple\" to \"Next\" and modified the cards." - (build-sentence - (revision/diff-strings - Dashboard - {:name "Apple" - :cards [{:id 1} {:id 2}]} - {:name "Next" - :cards [{:id 1} {:id 3}]})))) - (t2.with-temp/with-temp - [Collection {coll-id :id} {:name "New collection"}] - (is (= "moved this Dashboard to New collection." + {:cards [{:id 1} {:id 2}]} + {:cards [{:id 1} {:id 3}]}))))) + + (testing "update collection ---" + (t2.with-temp/with-temp + [Collection {coll-id :id} {:name "New collection"}] + (is (= "moved this Dashboard to New collection." + (build-sentence + (revision/diff-strings + Dashboard + {:name "Apple"} + {:name "Apple" + :collection_id coll-id}))))) + (t2.with-temp/with-temp + [Collection {coll-id-1 :id} {:name "Old collection"} + Collection {coll-id-2 :id} {:name "New collection"}] + (is (= "moved this Dashboard from Old collection to New collection." + (build-sentence + (revision/diff-strings + Dashboard + {:name "Apple" + :collection_id coll-id-1} + {:name "Apple" + :collection_id coll-id-2})))))) + + (testing "update tabs" + (is (= "added a tab." (build-sentence (revision/diff-strings Dashboard - {:name "Apple"} - {:name "Apple" - :collection_id coll-id}))))) - (t2.with-temp/with-temp - [Collection {coll-id-1 :id} {:name "Old collection"} - Collection {coll-id-2 :id} {:name "New collection"}] - (is (= "moved this Dashboard from Old collection to New collection." + {:tabs [{:id 0 :name "First tab" :position 0}]} + {:tabs [{:id 0 :name "First tab" :position 0} + {:id 1 :name "Second tab" :position 1}]})))) + + (is (= "removed 2 tabs." (build-sentence (revision/diff-strings Dashboard - {:name "Apple" - :collection_id coll-id-1} - {:name "Apple" - :collection_id coll-id-2})))))) + {:tabs [{:id 0 :name "First tab" :position 0} + {:id 1 :name "Second tab" :position 1} + {:id 2 :name "Third tab" :position 2}]} + {:tabs [{:id 0 :name "First tab" :position 0}]})))) + (is (= "rearranged the tabs." + (build-sentence + (revision/diff-strings + Dashboard + {:tabs [{:id 0 :name "First tab" :position 0} + {:id 1 :name "Second tab" :position 1}]} + {:tabs [{:id 1 :name "Second tab" :position 0} + {:id 0 :name "First tab" :position 1}]})))) + + (is (= "modified the tabs." + (build-sentence + (revision/diff-strings + Dashboard + {:tabs [{:id 0 :name "Tab A" :position 0} + {:id 1 :name "Tab B" :position 1}]} + {:tabs [{:id 1 :name "Tab B new name and position" :position 0} + {:id 0 :name "Tab A new name and position" :position 1}]})))))) (deftest revert-dashboard!-test (tt/with-temp* [Dashboard [{dashboard-id :id, :as dashboard} {:name "Test Dashboard"}] @@ -210,7 +256,8 @@ :auto_apply_filters true :collection_id nil :cache_ttl nil - :cards []} + :cards [] + :tabs []} serialized-dashboard (revision/serialize-instance Dashboard (:id dashboard) dashboard)] (testing "original state" (is (= {:name "Test Dashboard" @@ -218,13 +265,15 @@ :cache_ttl nil :auto_apply_filters true :collection_id nil - :cards [{:size_x 4 - :size_y 4 - :row 0 - :col 0 - :id true - :card_id true - :series true}]} + :cards [{:size_x 4 + :size_y 4 + :row 0 + :col 0 + :id true + :card_id true + :series true + :dashboard_tab_id nil}] + :tabs []} (update serialized-dashboard :cards check-ids)))) (testing "delete the dashcard and modify the dash attributes" (dashboard-card/delete-dashboard-cards! [(:id dashboard-card)]) @@ -243,13 +292,15 @@ :cache_ttl nil :auto_apply_filters true :collection_id nil - :cards [{:size_x 4 - :size_y 4 - :row 0 - :col 0 - :id false - :card_id true - :series true}]} + :cards [{:size_x 4 + :size_y 4 + :row 0 + :col 0 + :id false + :card_id true + :series true + :dashboard_tab_id nil}] + :tabs []} (update (revision/serialize-instance Dashboard dashboard-id (t2/select-one Dashboard :id dashboard-id)) :cards check-ids)))) (testing "revert back to the empty state" @@ -257,6 +308,209 @@ (is (= empty-dashboard (revision/serialize-instance Dashboard dashboard-id (t2/select-one Dashboard :id dashboard-id)))))))) +(defn- create-dashboard-revision! + "Fetch the latest version of a Dashboard and save a revision entry for it. Returns the fetched Dashboard." + [dash-id is-creation?] + (revision/push-revision! + :object (t2/select-one Dashboard :id dash-id) + :entity Dashboard + :id dash-id + :user-id (mt/user->id :crowberto) + :is-creation? is-creation?)) + +(defn- revert-to-previous-revision + "Revert to a previous revision for a model. + `n` is the number of revisions to revert back to. + `n=1` means do nothing (i.e. revert to the current revision). + + To revert 1 action, you should have n=2." + [model model-id n] + (assert (> n 1), "n = 1 means revert to the current revision, which is a no-op.") + (let [ids (t2/select-pks-vec Revision :model (name model) :model_id model-id {:order-by [[:timestamp :desc]] + :limit n})] + (assert (= n (count ids)), "There are less revisions than required to revert") + (revision/revert! :entity model :id model-id :user-id (mt/user->id :crowberto) :revision-id (last ids)))) + +(deftest revert-dashboard-with-tabs-basic-test + (testing "revert adding tabs" + (t2.with-temp/with-temp [:model/Dashboard {dashboard-id :id} {:name "A dashboard"}] + ;; 0. create a dashboard + (create-dashboard-revision! dashboard-id true) + + + ;; 1. add 2 tabs + (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" + :position 0 + :dashboard_id dashboard-id} + {:name "Tab 2" + :position 1 + :dashboard_id dashboard-id}]) + _ (create-dashboard-revision! dashboard-id false) + + ;; 2. add another tab for revision + tab-3-id (first (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 3" + :position 2 + :dashboard_id dashboard-id}]))] + (create-dashboard-revision! dashboard-id false) + + ;; check to make sure we have everything setup before testing + (is (=? [{:id tab-1-id :name "Tab 1" :position 0} + {:id tab-2-id :name "Tab 2" :position 1} + {:id tab-3-id :name "Tab 3" :position 2}] + (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]}))) + ;; revert + (revert-to-previous-revision Dashboard dashboard-id 2) + (is (=? [{:id tab-1-id :name "Tab 1" :position 0} + {:id tab-2-id :name "Tab 2" :position 1}] + (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]})))))) + + (testing "revert renaming tabs" + (t2.with-temp/with-temp [:model/Dashboard {dashboard-id :id} {:name "A dashboard"}] + ;; 0. create a dashboard + (create-dashboard-revision! dashboard-id true) + + + ;; 1. add 2 tabs + (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" + :position 0 + :dashboard_id dashboard-id} + {:name "Tab 2" + :position 1 + :dashboard_id dashboard-id}])] + (create-dashboard-revision! dashboard-id false) + + ;; 2. update a tab name + (t2/update! :model/DashboardTab tab-1-id {:name "Tab 1 with new name"}) + (create-dashboard-revision! dashboard-id false) + + ;; check to make sure we have everything setup before testing + (is (=? [{:id tab-1-id :name "Tab 1 with new name" :position 0} + {:id tab-2-id :name "Tab 2" :position 1}] + (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]}))) + ;; revert + (revert-to-previous-revision Dashboard dashboard-id 2) + (is (=? [{:id tab-1-id :name "Tab 1" :position 0} + {:id tab-2-id :name "Tab 2" :position 1}] + (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]})))))) + + (testing "revert deleting tabs" + (t2.with-temp/with-temp [:model/Dashboard {dashboard-id :id} {:name "A dashboard"}] + ;; 0. create a dashboard + (create-dashboard-revision! dashboard-id true) + + + ;; 1. add 2 tabs + (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" + :position 0 + :dashboard_id dashboard-id} + {:name "Tab 2" + :position 1 + :dashboard_id dashboard-id}])] + (create-dashboard-revision! dashboard-id false) + + ;; 2. delete the 1st tab and re-position the second tab + (t2/delete! :model/DashboardTab tab-1-id) + (t2/update! :model/DashboardTab tab-2-id {:position 0}) + (create-dashboard-revision! dashboard-id false) + + ;; check to make sure we have everything setup before testing + (is (=? [{:id tab-2-id :name "Tab 2" :position 0}] + (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]}))) + ;; revert + (revert-to-previous-revision Dashboard dashboard-id 2) + (is (=? [{:id #hawk/schema (s/pred pos-int?) :name "Tab 1" :position 0} + {:id tab-2-id :name "Tab 2" :position 1}] + (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]}))))))) + +(deftest revert-dashboard-with-tabs-advanced-test + (t2.with-temp/with-temp [:model/Dashboard {dashboard-id :id} {:name "A dashboard"}] + ;; 0. create a dashboard + (create-dashboard-revision! dashboard-id true) + + ;; 1. add 2 tabs, each with 2 cards + (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" + :position 0 + :dashboard_id dashboard-id} + {:name "Tab 2" + :position 1 + :dashboard_id dashboard-id}]) + [card-1-tab-1 card-2-tab-1] (t2/insert-returning-pks! :model/DashboardCard + [{:dashboard_id dashboard-id + :dashboard_tab_id tab-1-id + :row 0 + :col 0 + :size_x 4 + :size_y 4} + {:dashboard_id dashboard-id + :dashboard_tab_id tab-1-id + :row 4 + :col 4 + :size_x 4 + :size_y 4} + {:dashboard_id dashboard-id + :dashboard_tab_id tab-2-id + :row 0 + :col 0 + :size_x 4 + :size_y 4} + {:dashboard_id dashboard-id + :dashboard_tab_id tab-2-id + :row 4 + :col 4 + :size_x 4 + :size_y 4}])] + (create-dashboard-revision! dashboard-id false) + + ;; 2.a: tab 1: delete the 2nd card, add 2 cards and update position of one card, + (t2/insert! :model/DashboardCard [{:dashboard_id dashboard-id + :dashboard_tab_id tab-1-id + :row 0 + :col 0 + :size_x 4 + :size_y 4} + {:dashboard_id dashboard-id + :dashboard_tab_id tab-1-id + :row 0 + :col 0 + :size_x 4 + :size_y 4}]) + (t2/delete! :model/DashboardCard card-2-tab-1) + (t2/update! :model/DashboardCard card-1-tab-1 {:row 10 :col 10}) + + ;; 2.b: delete tab 2 + (t2/delete! :model/DashboardTab tab-2-id) + + ;; 2.c: create a new tab with 1 card + (let [new-tab-id (t2/insert-returning-pks! :model/DashboardTab {:name "Tab 3" + :position 1 + :dashboard_id dashboard-id})] + (t2/insert! :model/DashboardCard {:dashboard_id dashboard-id + :dashboard_tab_id new-tab-id + :row 0 + :col 0 + :size_x 4 + :size_y 4})) + (create-dashboard-revision! dashboard-id false) + + ;; revert + (revert-to-previous-revision Dashboard dashboard-id 2) + (testing "tab 1 should have 2 cards" + (is (= 2 (t2/count :model/DashboardCard :dashboard_tab_id tab-1-id))) + (testing "and position of first card is (0,0)" + (is (=? {:row 0 + :col 0} + (t2/select-one :model/DashboardCard card-1-tab-1))))) + + (testing "tab \"Tab 2\" is restored" + (let [new-tab-2 (t2/select-one :model/DashboardTab :dashboard_id dashboard-id :name "Tab 2")] + (is (= 1 (:position new-tab-2))) + + (testing "with its cards" + (is (= 2 (t2/count :model/DashboardCard :dashboard_id dashboard-id :dashboard_tab_id (:id new-tab-2))))))) + + (testing "there are no \"Tab 3\"" + (is (false? (t2/exists? :model/DashboardTab :dashboard_id dashboard-id :name "Tab 3"))))))) + (deftest public-sharing-test (testing "test that a Dashboard's :public_uuid comes back if public sharing is enabled..." (tu/with-temporary-setting-values [enable-public-sharing true] diff --git a/test/metabase/util_test.cljc b/test/metabase/util_test.cljc index 90e32c81898..fce55178ffe 100644 --- a/test/metabase/util_test.cljc +++ b/test/metabase/util_test.cljc @@ -411,3 +411,12 @@ (testing "multiple defaults for the same key" (is (= {:x nil, :y 1, :z 2} (u/assoc-default {:x nil} :x 0, :y nil, :y 1, :z 2, :x 3, :z 4))))) + +(deftest classify-changes-test + (testing "classify correctly" + (is (= {:to-update [{:id 2 :name "c3"} {:id 4 :name "c4"}] + :to-delete [{:id 1 :name "c1"} {:id 3 :name "c3"}] + :to-create [{:id -1 :name "-c1"}]} + (u/classify-changes + [{:id 1 :name "c1"} {:id 2 :name "c2"} {:id 3 :name "c3"} {:id 4 :name "c4"}] + [{:id -1 :name "-c1"} {:id 2 :name "c3"} {:id 4 :name "c4"}]))))) -- GitLab