diff --git a/frontend/src/metabase-types/api/mocks/data-app.ts b/frontend/src/metabase-types/api/mocks/data-app.ts index 1ca5ecc5618f2a8845415e0b16120f0b1cacb0e3..1444acf0a124344de0fb12d9ce3f34be05793fe6 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 a7ebd209249d114b5844188a65b2631556aeabfd..9b5bd9ee31663eb1d4f2bd5d5b266daf537de24b 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 163a86e93f5f175c3f667871f199a3be7c128740..6e91410f0b88f3d9065106f66ba8e0d6450644e0 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 0000000000000000000000000000000000000000..200c38aab463bc0094d3213eef612433038ec552 --- /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 70887881087c5fb6629451a34c8358b74ec45f3d..f37cb0446dcd4f8a1d87d0cceb9450103854acbd 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 266d13df3ce71745407c834b599cd6ab7671060f..38e82c5451066fd139f7bb61c77acadcb2684207 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);