diff --git a/frontend/src/metabase-types/store/app.ts b/frontend/src/metabase-types/store/app.ts index 02f379512bb7397f7fb60afc966012afcaeb0b3c..09d29156ed6610d14189a4fe0a6caf490ae9a1eb 100644 --- a/frontend/src/metabase-types/store/app.ts +++ b/frontend/src/metabase-types/store/app.ts @@ -1,3 +1,5 @@ +import { CollectionId } from "metabase-types/api/collection"; + export interface AppErrorDescriptor { status: number; data?: { @@ -7,7 +9,13 @@ export interface AppErrorDescriptor { context?: string; } +export interface AppBreadCrumbs { + collectionId: CollectionId; + show: boolean; +} + export interface AppState { errorPage: AppErrorDescriptor | null; isNavbarOpen: boolean; + breadcrumbs: AppBreadCrumbs; } diff --git a/frontend/src/metabase-types/store/mocks/app.ts b/frontend/src/metabase-types/store/mocks/app.ts index 0c05d7c3fcd357636cdba94eb21b78015d77da0f..c6fe7b23b0b2c15742e175e04a3622ab991b934c 100644 --- a/frontend/src/metabase-types/store/mocks/app.ts +++ b/frontend/src/metabase-types/store/mocks/app.ts @@ -3,5 +3,9 @@ import { AppState } from "metabase-types/store"; export const createMockAppState = (opts?: Partial<AppState>): AppState => ({ isNavbarOpen: true, errorPage: null, + breadcrumbs: { + collectionId: "root", + show: false, + }, ...opts, }); diff --git a/frontend/src/metabase/nav/components/PathBreadcrumbs/PathBreadcrumbs.styled.tsx b/frontend/src/metabase/nav/components/PathBreadcrumbs/PathBreadcrumbs.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d54f1400824bbdff6261b76739c754021666987e --- /dev/null +++ b/frontend/src/metabase/nav/components/PathBreadcrumbs/PathBreadcrumbs.styled.tsx @@ -0,0 +1,39 @@ +import styled from "styled-components"; + +import { color } from "metabase/lib/colors"; +import Button from "metabase/core/components/Button"; +import { space } from "metabase/styled-components/theme"; + +export const FilterHeaderContainer = styled.div` + padding-left: ${space(3)}; + padding-bottom: ${space(2)}; + padding-right: ${space(2)}; +`; + +export const PathContainer = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; +`; + +export const PathSeparator = styled.div` + display: flex; + align-items: center; + color: ${color("text-light")}; + margin-left: ${space(1)}; + margin-right: ${space(1)}; +`; + +export const ExpandButton = styled(Button)` + border: none; + padding: 0 5px; + margin: 0; + background-color: ${color("bg-light")}; + border-radius: 2px; + color: ${color("text-medium")}; + + &:hover { + color: ${color("text-white")}; + background-color: ${color("brand")}; + } +`; diff --git a/frontend/src/metabase/nav/components/PathBreadcrumbs/PathBreadcrumbs.tsx b/frontend/src/metabase/nav/components/PathBreadcrumbs/PathBreadcrumbs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eeabbf33b20cf6e1ea5fbafeb36bd59bccfd598f --- /dev/null +++ b/frontend/src/metabase/nav/components/PathBreadcrumbs/PathBreadcrumbs.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { useToggle } from "metabase/hooks/use-toggle"; + +import Icon from "metabase/components/Icon"; +import Collection from "metabase/entities/collections"; +import CollectionBadge from "metabase/questions/components/CollectionBadge"; +import { + Collection as CollectionType, + CollectionId, +} from "metabase-types/api/collection"; +import { State } from "metabase-types/store"; + +import { + PathSeparator, + PathContainer, + ExpandButton, +} from "./PathBreadcrumbs.styled"; + +interface Props { + collection: CollectionType; + collectionId: CollectionId; +} + +const PathBreadcrumbs = ({ collection }: Props) => { + const [isExpanded, { toggle }] = useToggle(false); + + if (!collection) { + return null; + } + + const ancestors = collection.effective_ancestors || []; + const parts = + ancestors[0]?.id === "root" ? ancestors.splice(0, 1) : ancestors; + + let content; + if (parts.length > 1 && !isExpanded) { + content = ( + <> + <CollectionBadge + collectionId={parts[0].id} + inactiveColor="text-medium" + /> + <Separator onClick={toggle} /> + <ExpandButton + small + borderless + iconSize={10} + icon="ellipsis" + onlyIcon + onClick={toggle} + /> + <Separator onClick={toggle} /> + </> + ); + } else { + content = parts.map(collection => ( + <> + <CollectionBadge + collectionId={collection.id} + inactiveColor="text-medium" + /> + <Separator onClick={toggle} /> + </> + )); + } + return ( + <PathContainer> + {content} + <CollectionBadge + collectionId={collection.id} + inactiveColor="text-medium" + /> + </PathContainer> + ); +}; + +export default Collection.load({ + id: (_state: State, props: Props) => props.collectionId || "root", + wrapped: true, + loadingAndErrorWrapper: false, + properties: ["name", "authority_level"], +})(PathBreadcrumbs); + +interface SeparatorProps { + onClick: () => void; +} + +const Separator = (props: SeparatorProps) => ( + <PathSeparator {...props}> + <Icon name="chevronright" size={8} /> + </PathSeparator> +); diff --git a/frontend/src/metabase/nav/containers/AppBar.styled.tsx b/frontend/src/metabase/nav/containers/AppBar.styled.tsx index 89f51d5743117394b2bbd52417f3d54a6764cb66..7bfd3d7377fd4735481294b19479d093f077b08f 100644 --- a/frontend/src/metabase/nav/containers/AppBar.styled.tsx +++ b/frontend/src/metabase/nav/containers/AppBar.styled.tsx @@ -142,3 +142,24 @@ export const SearchBarContent = styled.div` width: 460px; } `; + +interface PathBreadcrumbsContainerProps { + isVisible: boolean; +} + +export const PathBreadcrumbsContainer = styled.div< + PathBreadcrumbsContainerProps +>` + position: absolute; + top: 0px; + left: 100px; + height: ${APP_BAR_HEIGHT}; + display: flex; + visibility: ${props => (props.isVisible ? "visible" : "hidden")}; + opacity: ${props => (props.isVisible ? 1 : 0)}; + + ${props => + !props.isVisible + ? `transition: opacity 0.5s, visibility 0s 0.5s;` + : `transition: opacity 0.5s;`} +`; diff --git a/frontend/src/metabase/nav/containers/AppBar.tsx b/frontend/src/metabase/nav/containers/AppBar.tsx index 9b1e7a0c5da6659016bca06e401db071955a4b4a..4597f673a585ca073f01dcf4b8861e287bf4542a 100644 --- a/frontend/src/metabase/nav/containers/AppBar.tsx +++ b/frontend/src/metabase/nav/containers/AppBar.tsx @@ -10,6 +10,7 @@ import LogoIcon from "metabase/components/LogoIcon"; import SearchBar from "metabase/nav/components/SearchBar"; import SidebarButton from "metabase/nav/components/SidebarButton"; import NewButton from "metabase/nav/containers/NewButton"; +import PathBreadcrumbs from "../components/PathBreadcrumbs/PathBreadcrumbs"; import { State } from "metabase-types/store"; @@ -17,6 +18,8 @@ import { getIsNavbarOpen, closeNavbar, toggleNavbar } from "metabase/redux/app"; import { getIsNewButtonVisible, getIsSearchVisible, + getBreadcrumbCollectionId, + getShowBreadcumb, } from "metabase/selectors/app"; import { isMac } from "metabase/lib/browser"; import { isSmallScreen } from "metabase/lib/dom"; @@ -30,6 +33,7 @@ import { MiddleContainer, RightContainer, SidebarButtonContainer, + PathBreadcrumbsContainer, } from "./AppBar.styled"; type Props = { @@ -37,6 +41,8 @@ type Props = { isNavBarVisible: boolean; isSearchVisible: boolean; isNewButtonVisible: boolean; + collectionId: string; + showBreadcrumb: boolean; toggleNavbar: () => void; closeNavbar: () => void; }; @@ -46,6 +52,8 @@ function mapStateToProps(state: State) { isNavBarOpen: getIsNavbarOpen(state), isSearchVisible: getIsSearchVisible(state), isNewButtonVisible: getIsNewButtonVisible(state), + collectionId: getBreadcrumbCollectionId(state), + showBreadcrumb: getShowBreadcumb(state), }; } @@ -67,6 +75,8 @@ function AppBar({ isNavBarVisible, isSearchVisible, isNewButtonVisible, + collectionId, + showBreadcrumb, toggleNavbar, closeNavbar, }: Props) { @@ -117,6 +127,11 @@ function AppBar({ </Tooltip> </SidebarButtonContainer> )} + {showBreadcrumb && ( + <PathBreadcrumbsContainer isVisible={!isNavBarOpen}> + <PathBreadcrumbs collectionId={collectionId} /> + </PathBreadcrumbsContainer> + )} </LeftContainer> {!isSearchActive && ( <MiddleContainer> diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx index 9cd37516574bdf7f32915e59e4b996550fa4213f..59aac594946f2075ba6ef3ff62eb1055efb28414 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx @@ -43,7 +43,6 @@ import { ViewHeaderContainer, ViewSubHeaderRoot, StyledLastEditInfoLabel, - StyledCollectionBadge, StyledQuestionDataSource, } from "./ViewHeader.styled"; @@ -217,7 +216,6 @@ function SavedQuestionLeftSide(props) { </ViewHeaderMainLeftContentContainer> {isAdditionalInfoVisible && ( <ViewHeaderLeftSubHeading> - <StyledCollectionBadge collectionId={question.collectionId()} /> {QuestionDataSource.shouldRender(props) && ( <StyledQuestionDataSource question={question} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx index 88cd2c697b5f189bd3922e644925dcf239d29577..6d561e0e646dfd32f0bd2b410c0a94c1e68ba847 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx @@ -2,7 +2,6 @@ import styled from "@emotion/styled"; import Button from "metabase/core/components/Button"; import Link from "metabase/core/components/Link"; -import CollectionBadge from "metabase/questions/components/CollectionBadge"; import LastEditInfoLabel from "metabase/components/LastEditInfoLabel"; import { color, alpha } from "metabase/lib/colors"; @@ -123,17 +122,8 @@ export const StyledLastEditInfoLabel = styled(LastEditInfoLabel)` } `; -export const StyledCollectionBadge = styled(CollectionBadge)` - margin-bottom: 0.5rem; - - ${breakpointMaxSmall} { - padding-right: 1rem; - } -`; - export const StyledQuestionDataSource = styled(QuestionDataSource)` margin-bottom: 0.5rem; - margin-left: 1.5rem; padding-right: 1rem; ${breakpointMaxSmall} { diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.unit.spec.js b/frontend/src/metabase/query_builder/components/view/ViewHeader.unit.spec.js index f5138efd76d4d2a84414254da30cbb3c5ddc80ce..b0c489d5faac1d8659387e9bb8a41054d5a102b9 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.unit.spec.js +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.unit.spec.js @@ -1,11 +1,6 @@ import React from "react"; import xhrMock from "xhr-mock"; -import { - fireEvent, - renderWithProviders, - screen, - waitFor, -} from "__support__/ui"; +import { fireEvent, renderWithProviders, screen } from "__support__/ui"; import { SAMPLE_DATABASE, ORDERS, @@ -362,12 +357,6 @@ describe("ViewHeader", () => { xhrMock.teardown(); }); - it("displays collection where a question is saved to", async () => { - setup({ question }); - await waitFor(() => screen.queryByText("Our analytics")); - expect(screen.queryByText("Our analytics")).toBeInTheDocument(); - }); - it("opens details sidebar on question name click", () => { const { onOpenQuestionDetails } = setup({ question }); fireEvent.click(screen.getByText(question.displayName())); diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index 5873c2a295c995a970e6f864ea9328dd87a2cc2e..d3f15a4772e90a0b1ed1802d5212bb5f48a09d44 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -15,7 +15,11 @@ import Bookmark from "metabase/entities/bookmarks"; import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; -import { closeNavbar } from "metabase/redux/app"; +import { + closeNavbar, + setCollectionId, + clearBreadcrumbs, +} from "metabase/redux/app"; import { MetabaseApi } from "metabase/services"; import { getMetadata } from "metabase/selectors/metadata"; import { @@ -200,6 +204,8 @@ const mapStateToProps = (state, props) => { const mapDispatchToProps = { ...actions, + setCollectionId, + clearBreadcrumbs, closeNavbar, onChangeLocation: push, createBookmark: id => Bookmark.actions.create({ id, type: "card" }), @@ -232,6 +238,8 @@ function QueryBuilder(props) { showTimelinesForCollection, card, isLoadingComplete, + setCollectionId, + clearBreadcrumbs, } = props; const forceUpdate = useForceUpdate(); @@ -246,6 +254,14 @@ function QueryBuilder(props) { const wasNativeEditorOpen = usePrevious(isNativeEditorOpen); const hasQuestion = question != null; const collectionId = question?.collectionId(); + const isSaved = question?.isSaved(); + + useEffect(() => { + if (isSaved) { + setCollectionId(collectionId); + return () => clearBreadcrumbs(); + } + }, [collectionId, isSaved, setCollectionId, clearBreadcrumbs]); const openModal = useCallback( (modal, modalContext) => setUIControls({ modal, modalContext }), diff --git a/frontend/src/metabase/redux/app.js b/frontend/src/metabase/redux/app.js index 831fbea95622f51902fa06b951e8b32d0f27ed68..5c58bdea2738ac5b285e6dc6f8ef8714b777897e 100644 --- a/frontend/src/metabase/redux/app.js +++ b/frontend/src/metabase/redux/app.js @@ -72,7 +72,29 @@ const isNavbarOpen = handleActions( checkIsSidebarInitiallyOpen(), ); +export const SET_COLLECTION_ID = "metabase/app/SET_COLLECTION_ID"; +export const CLEAR_BREADCRUMBS = "metabase/app/CLEAR_BREADCRUMBS"; +export const setCollectionId = createAction(SET_COLLECTION_ID); +export const clearBreadcrumbs = createAction(CLEAR_BREADCRUMBS); +const defaultBreadcumbsState = { + collectionId: "", + show: false, +}; + +const breadcrumbs = handleActions( + { + [SET_COLLECTION_ID]: { + next: (_state, { payload }) => ({ show: true, collectionId: payload }), + }, + [CLEAR_BREADCRUMBS]: { + next: () => ({ show: false, collectionId: undefined }), + }, + }, + defaultBreadcumbsState, +); + export default combineReducers({ errorPage, isNavbarOpen, + breadcrumbs, }); diff --git a/frontend/src/metabase/selectors/app.ts b/frontend/src/metabase/selectors/app.ts index bb48d7eb44a610cc05f4ea39ebb23473ccb0cc9b..e54f03ccea4fdbe75ba61ae3100340d0237afe12 100644 --- a/frontend/src/metabase/selectors/app.ts +++ b/frontend/src/metabase/selectors/app.ts @@ -106,3 +106,8 @@ export const getErrorMessage = (state: State) => { const errorPage = getErrorPage(state); return errorPage?.data?.message || errorPage?.data; }; + +export const getBreadcrumbCollectionId = (state: State) => + state.app.breadcrumbs.collectionId; + +export const getShowBreadcumb = (state: State) => state.app.breadcrumbs.show; diff --git a/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js b/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js index 12fd757f865398df71ff37c88ce5426bc7dcbbe3..6d688d386439be87fd797acb3039ccde777f727e 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js +++ b/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js @@ -56,7 +56,6 @@ describe("scenarios > embedding > full app", () => { cy.findByTestId("qb-header").should("be.visible"); cy.findByText(/Edited/).should("be.visible"); - cy.findByText("Our analytics").should("be.visible"); cy.icon("refresh").should("be.visible"); cy.icon("notebook").should("be.visible"); diff --git a/frontend/test/metabase/scenarios/organization/moderation-collection.cy.spec.js b/frontend/test/metabase/scenarios/organization/moderation-collection.cy.spec.js index e507693a8807dc8cb8c9032ea876c2ec7cbffa1d..bb542d2158c81f333bf9a47144753a564e8826a6 100644 --- a/frontend/test/metabase/scenarios/organization/moderation-collection.cy.spec.js +++ b/frontend/test/metabase/scenarios/organization/moderation-collection.cy.spec.js @@ -207,7 +207,7 @@ function testOfficialBadgePresence(expectBadge = true) { .findByText(COLLECTION_NAME) .click(); cy.findByText("Official Question").click(); - assertHasCollectionBadge(expectBadge); + assertHasCollectionBadgeInNavbar(expectBadge); // Search testOfficialBadgeInSearch({ @@ -354,3 +354,16 @@ function assertHasCollectionBadge(expectBadge = true) { cy.icon("badge").should(expectBadge ? "exist" : "not.exist"); }); } + +const assertHasCollectionBadgeInNavbar = (expectBadge = true) => { + closeNavigationSidebar(); + cy.get("header") + .findByText(COLLECTION_NAME) + .parent() + .within(() => { + cy.icon("badge").should(expectBadge ? "exist" : "not.exist"); + if (expectBadge) { + cy.icon("badge").should("be.visible"); + } + }); +};