From 5234c0dfcaf0ec7b3d08b7395e1f1b0fa3721ced Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Tue, 30 Aug 2022 16:13:12 +0100
Subject: [PATCH] Automatically pick data app homepage (#25081)

* Add `createMockDataAppPage` test utility

* Add `getDataAppHomePageId` utility

* Render the homepage when launching a data app

* Break down `DataAppNavbarContainer`

* Highlight the homepage in app nav sidebar
---
 .../src/metabase-types/api/mocks/data-app.ts  |   7 +-
 .../metabase/entities/data-apps/data-apps.ts  |   5 +-
 .../src/metabase/entities/data-apps/utils.ts  |   8 +-
 .../entities/data-apps/utils.unit.spec.ts     |  18 +++
 .../MainNavbar/DataAppNavbarContainer.tsx     | 112 ++++++++++++------
 .../writeback/containers/DataAppLanding.tsx   |  43 ++++++-
 6 files changed, 145 insertions(+), 48 deletions(-)
 create mode 100644 frontend/src/metabase/entities/data-apps/utils.unit.spec.ts

diff --git a/frontend/src/metabase-types/api/mocks/data-app.ts b/frontend/src/metabase-types/api/mocks/data-app.ts
index 1ca5ecc5618..1444acf0a12 100644
--- a/frontend/src/metabase-types/api/mocks/data-app.ts
+++ b/frontend/src/metabase-types/api/mocks/data-app.ts
@@ -1,5 +1,6 @@
-import { DataApp } from "metabase-types/api";
+import { DataApp, Dashboard } from "metabase-types/api";
 import { createMockCollection } from "./collection";
+import { createMockDashboard } from "./dashboard";
 
 export const createMockDataApp = ({
   collection: collectionProps,
@@ -18,3 +19,7 @@ export const createMockDataApp = ({
     collection,
   };
 };
+
+export const createMockDataAppPage = (
+  params: Partial<Omit<Dashboard, "is_app_page">>,
+): Dashboard => createMockDashboard({ ...params, is_app_page: true });
diff --git a/frontend/src/metabase/entities/data-apps/data-apps.ts b/frontend/src/metabase/entities/data-apps/data-apps.ts
index a7ebd209249..9b5bd9ee316 100644
--- a/frontend/src/metabase/entities/data-apps/data-apps.ts
+++ b/frontend/src/metabase/entities/data-apps/data-apps.ts
@@ -9,7 +9,7 @@ import { Collection, DataApp } from "metabase-types/api";
 import { DEFAULT_COLLECTION_COLOR_ALIAS } from "../collections/constants";
 
 import { createNewAppForm, createAppSettingsForm } from "./forms";
-import { getDataAppIcon, isDataAppCollection } from "./utils";
+import { getDataAppIcon } from "./utils";
 
 type EditableDataAppParams = Pick<
   DataApp,
@@ -74,6 +74,5 @@ const DataApps = createEntity({
   },
 });
 
-export { getDataAppIcon, isDataAppCollection };
-
+export * from "./utils";
 export default DataApps;
diff --git a/frontend/src/metabase/entities/data-apps/utils.ts b/frontend/src/metabase/entities/data-apps/utils.ts
index 163a86e93f5..6e91410f0b8 100644
--- a/frontend/src/metabase/entities/data-apps/utils.ts
+++ b/frontend/src/metabase/entities/data-apps/utils.ts
@@ -1,4 +1,5 @@
-import type { Collection, DataApp } from "metabase-types/api";
+import _ from "underscore";
+import type { Collection, DataApp, Dashboard } from "metabase-types/api";
 
 export function getDataAppIcon(app?: DataApp) {
   return { name: "star" };
@@ -7,3 +8,8 @@ export function getDataAppIcon(app?: DataApp) {
 export function isDataAppCollection(collection: Collection) {
   return typeof collection.app_id === "number";
 }
+
+export function getDataAppHomePageId(pages: Dashboard[]) {
+  const [firstPage] = _.sortBy(pages, "name");
+  return firstPage?.id;
+}
diff --git a/frontend/src/metabase/entities/data-apps/utils.unit.spec.ts b/frontend/src/metabase/entities/data-apps/utils.unit.spec.ts
new file mode 100644
index 00000000000..200c38aab46
--- /dev/null
+++ b/frontend/src/metabase/entities/data-apps/utils.unit.spec.ts
@@ -0,0 +1,18 @@
+import { createMockDataAppPage } from "metabase-types/api/mocks";
+import { getDataAppHomePageId } from "./utils";
+
+describe("data app utils", () => {
+  describe("getDataAppHomePageId", () => {
+    it("returns fist page in alphabetical order", () => {
+      const page1 = createMockDataAppPage({ id: 1, name: "A" });
+      const page2 = createMockDataAppPage({ id: 2, name: "B" });
+      const page3 = createMockDataAppPage({ id: 3, name: "C" });
+
+      expect(getDataAppHomePageId([page2, page1, page3])).toEqual(page1.id);
+    });
+
+    it("returns undefined when there're no pages", () => {
+      expect(getDataAppHomePageId([])).toBeUndefined();
+    });
+  });
+});
diff --git a/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarContainer.tsx b/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarContainer.tsx
index 70887881087..f37cb0446dc 100644
--- a/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarContainer.tsx
+++ b/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarContainer.tsx
@@ -6,7 +6,7 @@ import Modal from "metabase/components/Modal";
 
 import * as Urls from "metabase/lib/urls";
 
-import DataApps from "metabase/entities/data-apps";
+import DataApps, { getDataAppHomePageId } from "metabase/entities/data-apps";
 import Dashboards from "metabase/entities/dashboards";
 import Search from "metabase/entities/search";
 
@@ -20,15 +20,24 @@ import DataAppNavbarView from "./DataAppNavbarView";
 const FETCHING_SEARCH_MODELS = ["dashboard", "dataset", "card"];
 const LIMIT = 100;
 
+function isAtDataAppHomePage(selectedItems: SelectedItem[]) {
+  const [selectedItem] = selectedItems;
+  return selectedItems.length === 1 && selectedItem.type === "data-app";
+}
+
 type NavbarModal = "MODAL_APP_SETTINGS" | "MODAL_NEW_PAGE" | null;
 
-interface Props extends MainNavbarProps {
+interface DataAppNavbarContainerProps extends MainNavbarProps {
   dataApp: DataApp;
-  loading: boolean;
+  items: any[];
   selectedItems: SelectedItem[];
   onChangeLocation: (location: LocationDescriptor) => void;
 }
 
+type DataAppNavbarContainerLoaderProps = DataAppNavbarContainerProps & {
+  dataApp?: DataApp;
+};
+
 type SearchRenderProps = {
   list: any[];
   loading: boolean;
@@ -36,22 +45,33 @@ type SearchRenderProps = {
 
 function DataAppNavbarContainer({
   dataApp,
-  loading: loadingDataApp,
+  items,
+  selectedItems,
   onChangeLocation,
   ...props
-}: Props) {
+}: DataAppNavbarContainerProps) {
   const [modal, setModal] = useState<NavbarModal>(null);
 
-  const collectionContentQuery = useMemo(() => {
-    if (!dataApp) {
-      return {};
+  const finalSelectedItems: SelectedItem[] = useMemo(() => {
+    const isHomepage = isAtDataAppHomePage(selectedItems);
+
+    // Once a data app is launched, the first view is going to be the app homepage
+    // Homepage is an app page specified by a user or picked automatically (just the first one)
+    // The homepage doesn't have a regular page path like /a/1/page/1, but an app one like /a/1
+    // So we need to overwrite the selectedItems list here and specify the homepage
+    if (isHomepage) {
+      return [
+        {
+          type: "data-app-page",
+          id: getDataAppHomePageId(
+            items.filter(item => item.model === "dashboard"),
+          ),
+        },
+      ];
     }
-    return {
-      collection: dataApp.collection_id,
-      models: FETCHING_SEARCH_MODELS,
-      limit: LIMIT,
-    };
-  }, [dataApp]);
+
+    return selectedItems;
+  }, [items, selectedItems]);
 
   const onEditAppSettings = useCallback(() => {
     setModal("MODAL_APP_SETTINGS");
@@ -95,38 +115,54 @@ function DataAppNavbarContainer({
     return null;
   }, [dataApp, modal, closeModal, onChangeLocation]);
 
-  if (loadingDataApp) {
-    return <NavbarLoadingView />;
-  }
-
   return (
     <>
-      <Search.ListLoader
-        query={collectionContentQuery}
-        loadingAndErrorWrapper={false}
-      >
-        {({ list = [], loading: loadingAppContent }: SearchRenderProps) => {
-          if (loadingAppContent) {
-            return <NavbarLoadingView />;
-          }
-          return (
-            <DataAppNavbarView
-              {...props}
-              dataApp={dataApp}
-              items={list}
-              onEditAppSettings={onEditAppSettings}
-              onNewPage={onNewPage}
-            />
-          );
-        }}
-      </Search.ListLoader>
+      <DataAppNavbarView
+        {...props}
+        dataApp={dataApp}
+        items={items}
+        selectedItems={finalSelectedItems}
+        onNewPage={onNewPage}
+        onEditAppSettings={onEditAppSettings}
+      />
       {modal && <Modal onClose={closeModal}>{renderModalContent()}</Modal>}
     </>
   );
 }
 
+function DataAppNavbarContainerLoader({
+  dataApp,
+  ...props
+}: DataAppNavbarContainerLoaderProps) {
+  if (!dataApp) {
+    return <NavbarLoadingView />;
+  }
+
+  return (
+    <Search.ListLoader
+      query={{
+        collection: dataApp.collection_id,
+        models: FETCHING_SEARCH_MODELS,
+        limit: LIMIT,
+      }}
+      loadingAndErrorWrapper={false}
+    >
+      {({ list = [], loading: loadingAppContent }: SearchRenderProps) => {
+        if (loadingAppContent) {
+          return <NavbarLoadingView />;
+        }
+        return (
+          <DataAppNavbarContainer {...props} dataApp={dataApp} items={list} />
+        );
+      }}
+    </Search.ListLoader>
+  );
+}
+
 function getDataAppId(state: State, props: MainNavbarOwnProps) {
   return Urls.extractEntityId(props.params.slug);
 }
 
-export default DataApps.load({ id: getDataAppId })(DataAppNavbarContainer);
+export default DataApps.load({ id: getDataAppId })(
+  DataAppNavbarContainerLoader,
+);
diff --git a/frontend/src/metabase/writeback/containers/DataAppLanding.tsx b/frontend/src/metabase/writeback/containers/DataAppLanding.tsx
index 266d13df3ce..38e82c54510 100644
--- a/frontend/src/metabase/writeback/containers/DataAppLanding.tsx
+++ b/frontend/src/metabase/writeback/containers/DataAppLanding.tsx
@@ -1,15 +1,19 @@
 import React, { ReactNode } from "react";
+import { Location } from "history";
 
-import { extractCollectionId } from "metabase/lib/urls";
+import * as Urls from "metabase/lib/urls";
 
-import DataApps from "metabase/entities/data-apps";
+import DataApps, { getDataAppHomePageId } from "metabase/entities/data-apps";
+import Search from "metabase/entities/search";
 
 import CollectionContent from "metabase/collections/containers/CollectionContent";
+import DashboardApp from "metabase/dashboard/containers/DashboardApp";
 
 import { DataApp } from "metabase-types/api";
 import { State } from "metabase-types/store";
 
 interface DataAppLandingOwnProps {
+  location: Location;
   params: {
     slug: string;
   };
@@ -20,10 +24,39 @@ interface DataAppLandingProps extends DataAppLandingOwnProps {
   dataApp: DataApp;
 }
 
-const DataAppLanding = ({ dataApp, children }: DataAppLandingProps) => {
+const DataAppLanding = ({
+  dataApp,
+  location,
+  params,
+  children,
+}: DataAppLandingProps) => {
+  if (Urls.isDataAppPreviewPath(location.pathname)) {
+    return (
+      <CollectionContent collectionId={dataApp.collection_id} isRoot={false} />
+    );
+  }
+
   return (
     <>
-      <CollectionContent collectionId={dataApp.collection_id} isRoot={false} />
+      <Search.ListLoader
+        query={{
+          collection: dataApp.collection_id,
+          models: ["dashboard"],
+          limit: 100,
+        }}
+        loadingAndErrorWrapper={false}
+      >
+        {({ list: pages = [] }: { list: any[] }) => {
+          const homepageId = getDataAppHomePageId(pages);
+          return homepageId ? (
+            <DashboardApp
+              dashboardId={homepageId}
+              location={location}
+              params={params}
+            />
+          ) : null;
+        }}
+      </Search.ListLoader>
       {children}
     </>
   );
@@ -31,5 +64,5 @@ const DataAppLanding = ({ dataApp, children }: DataAppLandingProps) => {
 
 export default DataApps.load({
   id: (state: State, { params }: DataAppLandingOwnProps) =>
-    extractCollectionId(params.slug),
+    Urls.extractCollectionId(params.slug),
 })(DataAppLanding);
-- 
GitLab