From 8f5f36720ab2ee2a70bfed9d150536b6aca9cda8 Mon Sep 17 00:00:00 2001
From: Ngoc Khuat <qn.khuat@gmail.com>
Date: Tue, 16 May 2023 10:19:26 +0700
Subject: [PATCH] Serdes for dashboardtab (#30249)

---
 .../serialization/v2/models.clj               |  1 +
 .../serialization/v2/e2e_test.clj             | 68 +++++++++++++++++++
 src/metabase/models/dashboard.clj             | 33 +++++++--
 src/metabase/models/dashboard_card.clj        |  9 ++-
 src/metabase/models/dashboard_tab.clj         | 15 ++++
 src/metabase/models/serialization.clj         |  4 +-
 6 files changed, 121 insertions(+), 9 deletions(-)

diff --git a/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj b/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj
index e178f2b8970..7c6bd449003 100644
--- a/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj
+++ b/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj
@@ -29,6 +29,7 @@
   These are not extracted and serialized separately, but they may need some processing done.
   For example, the models should also have their entity_id fields populated (if they have one)."
   ["DashboardCard"
+   "DashboardTab"
    "Dimension"
    "ParameterCard"
    "TimelineEvent"])
diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj
index 45f0433e121..f3c219b4282 100644
--- a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj
@@ -629,3 +629,71 @@
                            (-> (t2/select-one Dashboard :name dashboard-name)
                                (t2/hydrate :ordered_cards)
                                dashboard->link-cards)))))))))))))
+
+(deftest dashboard-with-tabs-test
+  (testing "Dashboard with tabs must be deserialized correctly"
+    (ts/with-random-dump-dir [dump-dir "serdesv2-"]
+     (ts/with-source-and-dest-dbs
+       (ts/with-source-db
+         ;; preparation
+         (t2.with-temp/with-temp
+           [Dashboard           {dashboard-id :id
+                                 dashboard-eid :entity_id} {:name "Dashboard with tab"}
+            Card                {card-id-1 :id
+                                 card-eid-1 :entity_id}    {:name "Card 1"}
+            Card                {card-id-2 :id
+                                 card-eid-2 :entity_id}    {:name "Card 2"}
+            :model/DashboardTab {tab-id-1 :id
+                                 tab-eid-1 :entity_id}     {:name "Tab 1" :position 0 :dashboard_id dashboard-id}
+            :model/DashboardTab {tab-id-2 :id
+                                 tab-eid-2 :entity_id}     {:name "Tab 2" :position 1 :dashboard_id dashboard-id}
+            DashboardCard       _                          {:dashboard_id     dashboard-id
+                                                            :card_id          card-id-1
+                                                            :dashboard_tab_id tab-id-1}
+            DashboardCard       _                          {:dashboard_id     dashboard-id
+                                                            :card_id          card-id-2
+                                                            :dashboard_tab_id tab-id-1}
+            DashboardCard       _                          {:dashboard_id     dashboard-id
+                                                            :card_id          card-id-1
+                                                            :dashboard_tab_id tab-id-2}
+            DashboardCard       _                          {:dashboard_id     dashboard-id
+                                                            :card_id          card-id-2
+                                                            :dashboard_tab_id tab-id-2}]
+           (let [extraction (serdes/with-cache (into [] (extract/extract {})))]
+             (storage/store! (seq extraction) dump-dir))
+
+           (testing "ingest and load"
+             (ts/with-dest-db
+               ;; ingest
+               (testing "doing ingestion"
+                 (is (serdes/with-cache (serdes.load/load-metabase (ingest/ingest-yaml dump-dir)))
+                     "successful"))
+               (let [new-dashboard (-> (t2/select-one Dashboard :entity_id dashboard-eid)
+                                       (t2/hydrate :ordered_tabs :ordered_cards))
+                     new-tab-id-1  (t2/select-one-pk :model/DashboardTab :entity_id tab-eid-1)
+                     new-tab-id-2  (t2/select-one-pk :model/DashboardTab :entity_id tab-eid-2)
+                     new-card-id-1 (t2/select-one-pk Card :entity_id card-eid-1)
+                     new-card-id-2 (t2/select-one-pk Card :entity_id card-eid-2)]
+
+                 (is (=? [{:id           new-tab-id-1
+                           :dashboard_id (:id new-dashboard)
+                           :name         "Tab 1"
+                           :position     0}
+                          {:id           new-tab-id-2
+                           :dashboard_id (:id new-dashboard)
+                           :name         "Tab 2"
+                           :position     1}]
+                         (:ordered_tabs new-dashboard)))
+                 (is (=? [{:card_id          new-card-id-1
+                           :dashboard_id     (:id new-dashboard)
+                           :dashboard_tab_id new-tab-id-1}
+                          {:card_id          new-card-id-2
+                           :dashboard_id     (:id new-dashboard)
+                           :dashboard_tab_id new-tab-id-1}
+                          {:card_id          new-card-id-1
+                           :dashboard_id     (:id new-dashboard)
+                           :dashboard_tab_id new-tab-id-2}
+                          {:card_id          new-card-id-2
+                           :dashboard_id     (:id new-dashboard)
+                           :dashboard_tab_id new-tab-id-2}]
+                         (:ordered_cards new-dashboard))))))))))))
diff --git a/src/metabase/models/dashboard.clj b/src/metabase/models/dashboard.clj
index 76f33a4b7e1..4143faf2aec 100644
--- a/src/metabase/models/dashboard.clj
+++ b/src/metabase/models/dashboard.clj
@@ -44,6 +44,7 @@
 
 (methodical/defmethod t2/table-name :model/Dashboard [_model] :report_dashboard)
 
+
 (doto :model/Dashboard
   (derive :metabase/model)
   (derive ::perms/use-parent-collection-perms)
@@ -469,16 +470,24 @@
       (dissoc :id :collection_authority_level :dashboard_id :updated_at)
       (update :card_id                serdes/*export-fk* 'Card)
       (update :action_id              serdes/*export-fk* 'Action)
+      (update :dashboard_tab_id       serdes/*export-fk* :model/DashboardTab)
       (update :parameter_mappings     serdes/export-parameter-mappings)
       (update :visualization_settings serdes/export-visualization-settings)))
 
+(defn- extract-dashtab
+  [dashtab]
+  (dissoc dashtab :id :dashboard_id :updated_at))
+
 (defmethod serdes/extract-one "Dashboard"
   [_model-name _opts dash]
-  (let [dash (if (contains? dash :ordered_cards)
-               dash
-               (hydrate dash :ordered_cards))]
+  (let [dash (cond-> dash
+               (nil? (:ordered_cards dash))
+               (hydrate :ordered_cards)
+               (nil? (:ordered_tabs dash))
+               (hydrate :ordered_tabs))]
     (-> (serdes/extract-one-basics "Dashboard" dash)
         (update :ordered_cards     #(mapv extract-dashcard %))
+        (update :ordered_tabs      #(mapv extract-dashtab %))
         (update :parameters        serdes/export-parameters)
         (update :collection_id     serdes/*export-fk* Collection)
         (update :creator_id        serdes/*export-user*)
@@ -497,12 +506,24 @@
 (defn- dashcard-for [dashcard dashboard]
   (assoc dashcard
          :dashboard_id (:entity_id dashboard)
-         :serdes/meta [{:model "Dashboard"     :id (:entity_id dashboard)}
-                       {:model "DashboardCard" :id (:entity_id dashcard)}]))
+         :serdes/meta  (remove nil?
+                               [{:model "Dashboard"     :id (:entity_id dashboard)}
+                                (when-let [dashtab-eeid (last (:dashboard_tab_id dashcard))]
+                                  {:model "DashboardTab" :id dashtab-eeid})
+                                {:model "DashboardCard" :id (:entity_id dashcard)}])))
+
+(defn- dashtab-for [tab dashboard]
+  (assoc tab
+         :dashboard_id (:entity_id dashboard)
+         :serdes/meta  [{:model "Dashboard"    :id (:entity_id dashboard)}
+                        {:model "DashboardTab" :id (:entity_id tab)}]))
 
 ;; Call the default load-one! for the Dashboard, then for each DashboardCard.
 (defmethod serdes/load-one! "Dashboard" [ingested maybe-local]
-  (let [dashboard ((get-method serdes/load-one! :default) (dissoc ingested :ordered_cards) maybe-local)]
+  (let [dashboard ((get-method serdes/load-one! :default) (dissoc ingested :ordered_cards :ordered_tabs) maybe-local)]
+    (doseq [tab (:ordered_tabs ingested)]
+      (serdes/load-one! (dashtab-for tab dashboard)
+                        (t2/select-one :model/DashboardTab :entity_id (:entity_id tab))))
     (doseq [dashcard (:ordered_cards ingested)]
       (serdes/load-one! (dashcard-for dashcard dashboard)
                         (t2/select-one 'DashboardCard :entity_id (:entity_id dashcard))))))
diff --git a/src/metabase/models/dashboard_card.clj b/src/metabase/models/dashboard_card.clj
index f4628884d8b..b3627c5abe3 100644
--- a/src/metabase/models/dashboard_card.clj
+++ b/src/metabase/models/dashboard_card.clj
@@ -354,10 +354,14 @@
 
 ;;; ----------------------------------------------- SERIALIZATION ----------------------------------------------------
 ;; DashboardCards are not serialized as their own, separate entities. They are inlined onto their parent Dashboards.
+;; If the parent dashboard has tabs, the dashcards are inlined under each DashboardTab, which are inlined on the Dashboard.
 ;; However, we can reuse some of the serdes machinery (especially load-one!) by implementing a few serdes methods.
 (defmethod serdes/generate-path "DashboardCard" [_ dashcard]
-  [(serdes/infer-self-path "Dashboard" (t2/select-one 'Dashboard :id (:dashboard_id dashcard)))
-   (serdes/infer-self-path "DashboardCard" dashcard)])
+  (remove nil?
+          [(serdes/infer-self-path "Dashboard" (t2/select-one 'Dashboard :id (:dashboard_id dashcard)))
+           (when (:dashboard_tab_id dashcard)
+             (serdes/infer-self-path "DashboardTab" (t2/select-one :model/DashboardTab :id (:dashboard_tab_id dashcard))))
+           (serdes/infer-self-path "DashboardCard" dashcard)]))
 
 (defmethod serdes/load-xform "DashboardCard"
   [dashcard]
@@ -366,6 +370,7 @@
       (update :card_id                serdes/*import-fk* 'Card)
       (update :action_id              serdes/*import-fk* 'Action)
       (update :dashboard_id           serdes/*import-fk* 'Dashboard)
+      (update :dashboard_tab_id       serdes/*import-fk* :model/DashboardTab)
       (update :created_at             #(if (string? %) (u.date/parse %) %))
       (update :parameter_mappings     serdes/import-parameter-mappings)
       (update :visualization_settings serdes/import-visualization-settings)))
diff --git a/src/metabase/models/dashboard_tab.clj b/src/metabase/models/dashboard_tab.clj
index 0d9502fb76c..3acd25a4f0f 100644
--- a/src/metabase/models/dashboard_tab.clj
+++ b/src/metabase/models/dashboard_tab.clj
@@ -3,6 +3,7 @@
    [metabase.models.dashboard :refer [Dashboard]]
    [metabase.models.interface :as mi]
    [metabase.models.serialization :as serdes]
+   [metabase.util.date-2 :as u.date]
    [methodical.core :as methodical]
    [toucan2.core :as t2]))
 
@@ -21,6 +22,8 @@
                       (t2/select-one Dashboard :id (:dashboard_id dashtab)))]
     (mi/perms-objects-set dashboard read-or-write)))
 
+
+;;; ----------------------------------------------- SERIALIZATION ----------------------------------------------------
 (defmethod serdes/hash-fields :model/DashboardTab
   [_dashboard-tab]
   [:name
@@ -29,3 +32,15 @@
         :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 "DashboardTab" dashcard)])
+
+(defmethod serdes/load-xform "DashboardTab"
+  [dashtab]
+  (-> dashtab
+      (dissoc :serdes/meta)
+      (update :dashboard_id serdes/*import-fk* 'Dashboard)
+      (update :created_at   #(if (string? %) (u.date/parse %) %))))
diff --git a/src/metabase/models/serialization.clj b/src/metabase/models/serialization.clj
index 385aa7a8238..2953c6eee98 100644
--- a/src/metabase/models/serialization.clj
+++ b/src/metabase/models/serialization.clj
@@ -430,7 +430,9 @@
 (defn log-path-str
   "Returns a string for logging from a serdes path sequence (i.e. in :serdes/meta)"
   [elements]
-  (->> elements (map #(str (:model %) " " (:id %))) (str/join " > ")))
+  (->> elements
+       (map #(str (:model %) " " (:id %)))
+       (str/join " > ")))
 
 
 ;; utils
-- 
GitLab