diff --git a/frontend/src/metabase-types/store/embed.ts b/frontend/src/metabase-types/store/embed.ts index 1e5bb3414f82a05fc61dcd8903d054bb7fd0324f..989a67e68e3a8af5a844963b746474aebf3a38da 100644 --- a/frontend/src/metabase-types/store/embed.ts +++ b/frontend/src/metabase-types/store/embed.ts @@ -3,6 +3,9 @@ export interface EmbedOptions { search?: boolean; new_button?: boolean; side_nav?: boolean | "default"; + header?: boolean; + additional_info?: boolean; + action_buttons?: boolean; } export interface EmbedState { diff --git a/frontend/src/metabase/App.styled.tsx b/frontend/src/metabase/App.styled.tsx index b85d2e19b2b1b6c295fcee312961feaf6cf0cadb..df8fb28b6f620e51f1ef2c4def705b00183882ba 100644 --- a/frontend/src/metabase/App.styled.tsx +++ b/frontend/src/metabase/App.styled.tsx @@ -5,14 +5,14 @@ import { APP_BAR_HEIGHT } from "metabase/nav/constants"; export const AppContentContainer = styled.div<{ isAdminApp: boolean; - hasAppBar: boolean; + isAppBarVisible: boolean; }>` display: flex; flex-direction: ${props => (props.isAdminApp ? "column" : "row")}; position: relative; overflow: hidden; height: ${props => - props.hasAppBar ? `calc(100vh - ${APP_BAR_HEIGHT})` : "100vh"}; + props.isAppBarVisible ? `calc(100vh - ${APP_BAR_HEIGHT})` : "100vh"}; background-color: ${props => color(props.isAdminApp ? "bg-white" : "content")}; `; diff --git a/frontend/src/metabase/App.tsx b/frontend/src/metabase/App.tsx index 98e2c390440c1fb40a6126531861421d14ee278c..99284b8467c2328ed88d8d832174dc94b185077a 100644 --- a/frontend/src/metabase/App.tsx +++ b/frontend/src/metabase/App.tsx @@ -1,4 +1,4 @@ -import React, { ErrorInfo, ReactNode, useMemo, useState } from "react"; +import React, { ErrorInfo, ReactNode, useState } from "react"; import { connect } from "react-redux"; import { Location } from "history"; @@ -7,27 +7,28 @@ import AppErrorCard from "metabase/components/AppErrorCard/AppErrorCard"; import ScrollToTop from "metabase/hoc/ScrollToTop"; import { Archived, - NotFound, GenericError, + NotFound, Unauthorized, } from "metabase/containers/ErrorPages"; import UndoListing from "metabase/containers/UndoListing"; -import { getErrorPage } from "metabase/selectors/app"; -import { getEmbedOptions } from "metabase/selectors/embed"; -import { getUser } from "metabase/selectors/user"; -import { getIsEditing as getIsEditingDashboard } from "metabase/dashboard/selectors"; +import { + getErrorPage, + getIsAdminApp, + getIsAppBarVisible, + getIsNavBarVisible, +} from "metabase/selectors/app"; import { useOnMount } from "metabase/hooks/use-on-mount"; -import { IFRAMED, initializeIframeResizer } from "metabase/lib/dom"; +import { initializeIframeResizer } from "metabase/lib/dom"; import AppBar from "metabase/nav/containers/AppBar"; import Navbar from "metabase/nav/containers/Navbar"; import StatusListing from "metabase/status/containers/StatusListing"; -import { User } from "metabase-types/api"; -import { AppErrorDescriptor, EmbedOptions, State } from "metabase-types/store"; +import { AppErrorDescriptor, State } from "metabase-types/store"; -import { AppContentContainer, AppContent } from "./App.styled"; +import { AppContent, AppContentContainer } from "./App.styled"; const getErrorComponent = ({ status, data, context }: AppErrorDescriptor) => { if (status === 403 || data?.error_code === "unauthorized") { @@ -45,21 +46,11 @@ const getErrorComponent = ({ status, data, context }: AppErrorDescriptor) => { return <GenericError details={data?.message} />; }; -const PATHS_WITHOUT_NAVBAR = [/\/model\/.*\/query/, /\/model\/.*\/metadata/]; - -const HOMEPAGE_PATTERN = /^\/$/; -const EMBEDDED_ROUTES_WITH_NAVBAR = [ - HOMEPAGE_PATTERN, - /^\/collection\/.*/, - /^\/archive/, -]; - interface AppStateProps { - currentUser?: User; errorPage: AppErrorDescriptor | null; - isEditingDashboard: boolean; - isEmbedded: boolean; - embedOptions: EmbedOptions; + isAdminApp: boolean; + isAppBarVisible: boolean; + isNavBarVisible: boolean; } interface AppRouterOwnProps { @@ -69,15 +60,15 @@ interface AppRouterOwnProps { type AppProps = AppStateProps & AppRouterOwnProps; -function mapStateToProps(state: State): AppStateProps { - return { - currentUser: getUser(state), - errorPage: getErrorPage(state), - isEditingDashboard: getIsEditingDashboard(state), - isEmbedded: IFRAMED, - embedOptions: getEmbedOptions(state), - }; -} +const mapStateToProps = ( + state: State, + props: AppRouterOwnProps, +): AppStateProps => ({ + errorPage: getErrorPage(state), + isAdminApp: getIsAdminApp(state, props), + isAppBarVisible: getIsAppBarVisible(state, props), + isNavBarVisible: getIsNavBarVisible(state, props), +}); class ErrorBoundary extends React.Component<{ onError: (errorInfo: ErrorInfo) => void; @@ -92,12 +83,10 @@ class ErrorBoundary extends React.Component<{ } function App({ - currentUser, errorPage, - location: { pathname, hash }, - isEditingDashboard, - isEmbedded, - embedOptions, + isAdminApp, + isAppBarVisible, + isNavBarVisible, children, }: AppProps) { const [errorInfo, setErrorInfo] = useState<ErrorInfo | null>(null); @@ -106,52 +95,16 @@ function App({ initializeIframeResizer(); }); - const isAdminApp = useMemo(() => pathname.startsWith("/admin/"), [pathname]); - - const hasNavbar = useMemo(() => { - if (!currentUser || isEditingDashboard) { - return false; - } - if (isEmbedded && !embedOptions.side_nav) { - return false; - } - if (isEmbedded && embedOptions.side_nav === "default") { - return EMBEDDED_ROUTES_WITH_NAVBAR.some(pattern => - pattern.test(pathname), - ); - } - return !PATHS_WITHOUT_NAVBAR.some(pattern => pattern.test(pathname)); - }, [currentUser, pathname, isEditingDashboard, isEmbedded, embedOptions]); - - const hasAppBar = useMemo(() => { - const isFullscreen = hash.includes("fullscreen"); - if ( - !currentUser || - (isEmbedded && !embedOptions.top_nav) || - isAdminApp || - isEditingDashboard || - isFullscreen - ) { - return false; - } - return !PATHS_WITHOUT_NAVBAR.some(pattern => pattern.test(pathname)); - }, [ - currentUser, - pathname, - isEditingDashboard, - isEmbedded, - embedOptions, - isAdminApp, - hash, - ]); - return ( <ErrorBoundary onError={setErrorInfo}> <ScrollToTop> <div className="spread"> - {hasAppBar && <AppBar />} - <AppContentContainer hasAppBar={hasAppBar} isAdminApp={isAdminApp}> - {hasNavbar && <Navbar />} + {isAppBarVisible && <AppBar />} + <AppContentContainer + isAdminApp={isAdminApp} + isAppBarVisible={isAppBarVisible} + > + {isNavBarVisible && <Navbar />} <AppContent> {errorPage ? getErrorComponent(errorPage) : children} </AppContent> diff --git a/frontend/src/metabase/components/Header.jsx b/frontend/src/metabase/components/Header.jsx index ab8d9ff76ab38f2515cda8ad539176e11ba240da..577c2df18bc8f18bcac68940bbeb9875df17b70d 100644 --- a/frontend/src/metabase/components/Header.jsx +++ b/frontend/src/metabase/components/Header.jsx @@ -18,6 +18,7 @@ import { HeaderButtonsContainer, HeaderButtonSection, StyledLastEditInfoLabel, + HeaderCaption, } from "./Header.styled"; const propTypes = { @@ -33,7 +34,8 @@ const propTypes = { isEditingInfo: PropTypes.bool, item: PropTypes.object.isRequired, objectType: PropTypes.string.isRequired, - hasBadge: PropTypes.bool, + isBadgeVisible: PropTypes.bool, + isLastEditInfoVisible: PropTypes.bool, children: PropTypes.node, setItemAttributeFn: PropTypes.func, onHeaderModalDone: PropTypes.func, @@ -117,8 +119,12 @@ class Header extends Component { } render() { - const { item, hasBadge, onLastEditInfoClick } = this.props; - const hasLastEditInfo = !!item["last-edit-info"]; + const { + item, + isBadgeVisible, + isLastEditInfoVisible, + onLastEditInfoClick, + } = this.props; let titleAndDescription; if (this.props.item && this.props.item.id != null) { @@ -172,10 +178,10 @@ class Header extends Component { ref={this.header} > <HeaderContent> - <span className="inline-block mb1">{titleAndDescription}</span> + <HeaderCaption>{titleAndDescription}</HeaderCaption> {attribution} <HeaderBadges> - {hasBadge && ( + {isBadgeVisible && ( <> <CollectionBadge collectionId={item.collection_id} @@ -183,10 +189,10 @@ class Header extends Component { /> </> )} - {hasBadge && hasLastEditInfo && ( + {isBadgeVisible && isLastEditInfoVisible && ( <HeaderBadgesDivider>•</HeaderBadgesDivider> )} - {hasLastEditInfo && ( + {isLastEditInfoVisible && ( <StyledLastEditInfoLabel item={item} onClick={onLastEditInfoClick} diff --git a/frontend/src/metabase/components/Header.styled.tsx b/frontend/src/metabase/components/Header.styled.tsx index 7599ced7e66dc30f4d65bee4bb74cfe696a7da07..ef21de88cf3284425482296d06a8324883dcdd3a 100644 --- a/frontend/src/metabase/components/Header.styled.tsx +++ b/frontend/src/metabase/components/Header.styled.tsx @@ -22,6 +22,14 @@ export const HeaderContent = styled.div` padding: 1rem 0; `; +export const HeaderCaption = styled.span` + display: inline-block; + + &:not(:last-child) { + margin-bottom: 0.5rem; + } +`; + export const HeaderBadges = styled.div` display: flex; align-items: center; diff --git a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx index 5740bcfeed4c1ed175157a1d8385d203bc3fc718..9fbeff62a6117e13cbc050a774a1ebced8920748 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx @@ -46,6 +46,8 @@ class Dashboard extends Component { .isRequired, isEditingParameter: PropTypes.bool.isRequired, isNavbarOpen: PropTypes.bool.isRequired, + isHeaderVisible: PropTypes.bool, + isAdditionalInfoVisible: PropTypes.bool, dashboard: PropTypes.object, dashboardId: PropTypes.number, @@ -220,6 +222,7 @@ class Dashboard extends Component { setParameterValue, setParameterIndex, setEditingParameter, + isHeaderVisible, } = this.props; const { error, isParametersWidgetSticky } = this.state; @@ -261,30 +264,32 @@ class Dashboard extends Component { > {() => ( <DashboardStyled> - <HeaderContainer - isFullscreen={isFullscreen} - isNightMode={shouldRenderAsNightMode} - > - <DashboardHeader - {...this.props} - onEditingChange={this.setEditing} - setDashboardAttribute={this.setDashboardAttribute} - addParameter={addParameter} - parametersWidget={parametersWidget} - onSharingClick={this.onSharingClick} - onToggleAddQuestionSidebar={this.onToggleAddQuestionSidebar} - showAddQuestionSidebar={showAddQuestionSidebar} - /> - - {shouldRenderParametersWidgetInEditMode && ( - <ParametersWidgetContainer - data-testid="edit-dashboard-parameters-widget-container" - isEditing={isEditing} - > - {parametersWidget} - </ParametersWidgetContainer> - )} - </HeaderContainer> + {isHeaderVisible && ( + <HeaderContainer + isFullscreen={isFullscreen} + isNightMode={shouldRenderAsNightMode} + > + <DashboardHeader + {...this.props} + onEditingChange={this.setEditing} + setDashboardAttribute={this.setDashboardAttribute} + addParameter={addParameter} + parametersWidget={parametersWidget} + onSharingClick={this.onSharingClick} + onToggleAddQuestionSidebar={this.onToggleAddQuestionSidebar} + showAddQuestionSidebar={showAddQuestionSidebar} + /> + + {shouldRenderParametersWidgetInEditMode && ( + <ParametersWidgetContainer + data-testid="edit-dashboard-parameters-widget-container" + isEditing={isEditing} + > + {parametersWidget} + </ParametersWidgetContainer> + )} + </HeaderContainer> + )} <DashboardBody isEditingOrSharing={isEditing || isSharing}> <ParametersAndCardsContainer diff --git a/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx b/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx index 70bb063098a90545da23be3a993163e206687375..4fb61eea8d10b6b6963f67ec6642d69a2bb04f13 100644 --- a/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx @@ -22,6 +22,7 @@ import { Dashboard } from "metabase/dashboard/containers/Dashboard"; import SyncedParametersList from "metabase/parameters/components/SyncedParametersList/SyncedParametersList"; import { getMetadata } from "metabase/selectors/metadata"; +import { getIsHeaderVisible } from "metabase/dashboard/selectors"; import Collections from "metabase/entities/collections"; import Dashboards from "metabase/entities/dashboards"; @@ -46,6 +47,7 @@ const getDashboardId = (state, { params: { splat }, location: { hash } }) => const mapStateToProps = (state, props) => ({ metadata: getMetadata(state), dashboardId: getDashboardId(state, props), + isHeaderVisible: getIsHeaderVisible(state), }); const mapDispatchToProps = { @@ -104,6 +106,7 @@ class AutomaticDashboardAppInner extends React.Component { parameters, parameterValues, setParameterValue, + isHeaderVisible, } = this.props; const { savedDashboardId } = this.state; // pull out "more" related items for displaying as a button at the bottom of the dashboard @@ -119,34 +122,36 @@ class AutomaticDashboardAppInner extends React.Component { })} > <div className="" style={{ marginRight: hasSidebar ? 346 : undefined }}> - <div className="bg-white border-bottom py2"> - <div className="wrapper flex align-center"> - <Icon name="bolt" className="text-gold mr2" size={24} /> - <div> - <h2 className="text-wrap mr2"> - {dashboard && <TransientTitle dashboard={dashboard} />} - </h2> - {dashboard && dashboard.transient_filters && ( - <TransientFilters - filter={dashboard.transient_filters} - metadata={this.props.metadata} - /> + {isHeaderVisible && ( + <div className="bg-white border-bottom py2"> + <div className="wrapper flex align-center"> + <Icon name="bolt" className="text-gold mr2" size={24} /> + <div> + <h2 className="text-wrap mr2"> + {dashboard && <TransientTitle dashboard={dashboard} />} + </h2> + {dashboard && dashboard.transient_filters && ( + <TransientFilters + filter={dashboard.transient_filters} + metadata={this.props.metadata} + /> + )} + </div> + {savedDashboardId != null ? ( + <Button className="ml-auto" disabled>{t`Saved`}</Button> + ) : ( + <ActionButton + className="ml-auto text-nowrap" + success + borderless + actionFn={this.save} + > + {t`Save this`} + </ActionButton> )} </div> - {savedDashboardId != null ? ( - <Button className="ml-auto" disabled>{t`Saved`}</Button> - ) : ( - <ActionButton - className="ml-auto text-nowrap" - success - borderless - actionFn={this.save} - > - {t`Save this`} - </ActionButton> - )} </div> - </div> + )} <div className="wrapper pb4"> {parameters && parameters.length > 0 && ( diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx index 6595edce5b37bf2531254a73069bf531e2cf34df..50669405b24f5ac7e283436b36d71f7237fbd625 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx @@ -41,6 +41,8 @@ import { getDocumentTitle, getIsRunning, getIsLoadingComplete, + getIsHeaderVisible, + getIsAdditionalInfoVisible, } from "../selectors"; import { getDatabases, getMetadata } from "metabase/selectors/metadata"; import { @@ -83,6 +85,8 @@ const mapStateToProps = (state, props) => { documentTitle: getDocumentTitle(state), isRunning: getIsRunning(state), isLoadingComplete: getIsLoadingComplete(state), + isHeaderVisible: getIsHeaderVisible(state), + isAdditionalInfoVisible: getIsAdditionalInfoVisible(state), }; }; diff --git a/frontend/src/metabase/dashboard/containers/DashboardHeader.jsx b/frontend/src/metabase/dashboard/containers/DashboardHeader.jsx index 75099018b61b302c072ff740058d6df1762a7ddd..d4ea0805ddf9bf8cc59ab3550f190ad4815cf17a 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardHeader.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardHeader.jsx @@ -56,6 +56,7 @@ class DashboardHeader extends Component { .isRequired, isFullscreen: PropTypes.bool.isRequired, isNightMode: PropTypes.bool.isRequired, + isAdditionalInfoVisible: PropTypes.bool, refreshPeriod: PropTypes.number, setRefreshElapsedHook: PropTypes.func.isRequired, @@ -373,7 +374,16 @@ class DashboardHeader extends Component { } render() { - const { dashboard, location, onChangeLocation } = this.props; + const { + dashboard, + location, + isEditing, + isFullscreen, + isAdditionalInfoVisible, + onChangeLocation, + } = this.props; + + const hasLastEditInfo = dashboard["last-edit-info"] != null; return ( <Header @@ -381,9 +391,10 @@ class DashboardHeader extends Component { objectType="dashboard" analyticsContext="Dashboard" item={dashboard} - isEditing={this.props.isEditing} - hasBadge={!this.props.isEditing && !this.props.isFullscreen} - isEditingInfo={this.props.isEditing} + isEditing={isEditing} + isBadgeVisible={!isEditing && !isFullscreen && isAdditionalInfoVisible} + isLastEditInfoVisible={hasLastEditInfo && isAdditionalInfoVisible} + isEditingInfo={isEditing} headerButtons={this.getHeaderButtons()} editWarning={this.getEditWarning(dashboard)} editingTitle={t`You're editing this dashboard.`} diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js index dd9c3cbe4ed6880a5edf4ca3e3c28415ce2c32ad..2b16b060ae9a61e01d782132f874c7352eb0d1c9 100644 --- a/frontend/src/metabase/dashboard/selectors.js +++ b/frontend/src/metabase/dashboard/selectors.js @@ -13,6 +13,7 @@ import { import { getParameterMappingOptions as _getParameterMappingOptions } from "metabase/parameters/utils/mapping-options"; import { SIDEBAR_NAME } from "metabase/dashboard/constants"; +import { getEmbedOptions, getIsEmbedded } from "metabase/selectors/embed"; export const getDashboardId = state => state.dashboard.dashboardId; export const getIsEditing = state => !!state.dashboard.isEditing; @@ -194,3 +195,13 @@ export const getDashboardParameterValuesCache = state => { }, }; }; + +export const getIsHeaderVisible = createSelector( + [getIsEmbedded, getEmbedOptions], + (isEmbedded, embedOptions) => !isEmbedded || embedOptions.header, +); + +export const getIsAdditionalInfoVisible = createSelector( + [getIsEmbedded, getEmbedOptions], + (isEmbedded, embedOptions) => !isEmbedded || embedOptions.additional_info, +); diff --git a/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx b/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx index 1c5b3bfeb6ed0cce3b2c7e39c140f6ca9a9d4b7d..2fd2d67dfc3ba319b3ddd96c62115bfd8770dc06 100644 --- a/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx +++ b/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx @@ -205,8 +205,10 @@ QuestionFilterWidget.shouldRender = ({ question, queryBuilderMode, isObjectDetail, + isActionListVisible, }) => queryBuilderMode === "view" && question.isStructured() && question.query().isEditable() && - !isObjectDetail; + !isObjectDetail && + isActionListVisible; diff --git a/frontend/src/metabase/query_builder/components/view/QuestionNotebookButton.jsx b/frontend/src/metabase/query_builder/components/view/QuestionNotebookButton.jsx index 7597670445af2b8b89aff27ada4dc3c9ddbf2253..729b3ba601cf09822ac9db20750e7fbf3a0ca44e 100644 --- a/frontend/src/metabase/query_builder/components/view/QuestionNotebookButton.jsx +++ b/frontend/src/metabase/query_builder/components/view/QuestionNotebookButton.jsx @@ -14,7 +14,7 @@ export default function QuestionNotebookButton({ setQueryBuilderMode, ...props }) { - return QuestionNotebookButton.shouldRender({ question }) ? ( + return ( <Tooltip tooltip={isShowingNotebook ? t`Hide editor` : t`Show editor`} placement="bottom" @@ -33,8 +33,10 @@ export default function QuestionNotebookButton({ {...props} /> </Tooltip> - ) : null; + ); } -QuestionNotebookButton.shouldRender = ({ question }) => - question.isStructured() && question.query().isEditable(); +QuestionNotebookButton.shouldRender = ({ question, isActionListVisible }) => + question.isStructured() && + question.query().isEditable() && + isActionListVisible; diff --git a/frontend/src/metabase/query_builder/components/view/QuestionSummaries.jsx b/frontend/src/metabase/query_builder/components/view/QuestionSummaries.jsx index 44149f89158c772bb5ff03961437bb5bc0318508..464a7551f021c3d75e29e6d217d3121aec74980e 100644 --- a/frontend/src/metabase/query_builder/components/view/QuestionSummaries.jsx +++ b/frontend/src/metabase/query_builder/components/view/QuestionSummaries.jsx @@ -104,10 +104,12 @@ QuestionSummarizeWidget.shouldRender = ({ question, queryBuilderMode, isObjectDetail, + isActionListVisible, }) => queryBuilderMode === "view" && question && question.isStructured() && question.query().isEditable() && question.query().table() && - !isObjectDetail; + !isObjectDetail && + isActionListVisible; diff --git a/frontend/src/metabase/query_builder/components/view/View.jsx b/frontend/src/metabase/query_builder/components/view/View.jsx index 4fe23e07e9f1be2b62e882d526a7a38f3cb8a84b..7e222bf644272fc8fa91db8f45c180fba243f342 100644 --- a/frontend/src/metabase/query_builder/components/view/View.jsx +++ b/frontend/src/metabase/query_builder/components/view/View.jsx @@ -40,12 +40,12 @@ import NewQuestionView from "./View/NewQuestionView"; import QueryViewNotebook from "./View/QueryViewNotebook"; import { - QueryBuilderViewRoot, + BorderedViewTitleHeader, + NativeQueryEditorContainer, QueryBuilderContentContainer, QueryBuilderMain, QueryBuilderViewHeaderContainer, - BorderedViewTitleHeader, - NativeQueryEditorContainer, + QueryBuilderViewRoot, StyledDebouncedFrame, StyledSyncedParametersList, } from "./View.styled"; @@ -454,6 +454,7 @@ class View extends React.Component { onDismissToast, onConfirmToast, isShowingToaster, + isHeaderVisible, } = this.props; // if we don't have a card at all or no databases then we are initializing, so keep it simple @@ -486,7 +487,7 @@ class View extends React.Component { return ( <div className="full-height"> <QueryBuilderViewRoot className="QueryBuilder"> - {this.renderHeader()} + {isHeaderVisible && this.renderHeader()} <QueryBuilderContentContainer> {isStructured && ( <QueryViewNotebook diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx index 1e761eaaafc74f2a955b0e89ecdc0773329a893e..c1b04127f2972fd9351be4fd88ca0c3670dc87aa 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx @@ -63,6 +63,7 @@ const viewTitleHeaderPropTypes = { isShowingSummarySidebar: PropTypes.bool, isShowingQuestionDetailsSidebar: PropTypes.bool, isObjectDetail: PropTypes.bool, + isAdditionalInfoVisible: PropTypes.bool, runQuestionQuery: PropTypes.func, cancelQuery: PropTypes.func, @@ -107,8 +108,6 @@ export function ViewTitleHeader(props) { } }, [previousQuestion, question, expandFilters]); - const lastEditInfo = question.lastEditInfo(); - const isStructured = question.isStructured(); const isNative = question.isNative(); const isSaved = question.isSaved(); @@ -131,7 +130,7 @@ export function ViewTitleHeader(props) { {isDataset ? ( <DatasetLeftSide {...props} /> ) : isSaved ? ( - <SavedQuestionLeftSide {...props} lastEditInfo={lastEditInfo} /> + <SavedQuestionLeftSide {...props} /> ) : ( <AhHocQuestionLeftSide {...props} @@ -163,9 +162,9 @@ export function ViewTitleHeader(props) { SavedQuestionLeftSide.propTypes = { question: PropTypes.object.isRequired, - lastEditInfo: PropTypes.object, - isShowingQuestionDetailsSidebar: PropTypes.bool, isObjectDetail: PropTypes.bool, + isAdditionalInfoVisible: PropTypes.bool, + isShowingQuestionDetailsSidebar: PropTypes.bool, onOpenQuestionDetails: PropTypes.func.isRequired, onCloseQuestionDetails: PropTypes.func.isRequired, onOpenQuestionHistory: PropTypes.func.isRequired, @@ -175,13 +174,15 @@ function SavedQuestionLeftSide(props) { const { question, isObjectDetail, + isAdditionalInfoVisible, isShowingQuestionDetailsSidebar, onOpenQuestionDetails, onCloseQuestionDetails, - lastEditInfo, onOpenQuestionHistory, } = props; + const hasLastEditInfo = question.lastEditInfo() != null; + const onHeaderClick = useCallback(() => { if (isShowingQuestionDetailsSidebar) { onCloseQuestionDetails(); @@ -204,23 +205,25 @@ function SavedQuestionLeftSide(props) { onClick={onHeaderClick} /> </SavedQuestionHeaderButtonContainer> - {lastEditInfo && ( + {hasLastEditInfo && isAdditionalInfoVisible && ( <StyledLastEditInfoLabel item={question.card()} onClick={onOpenQuestionHistory} /> )} </ViewHeaderMainLeftContentContainer> - <ViewHeaderLeftSubHeading> - <StyledCollectionBadge collectionId={question.collectionId()} /> - {QuestionDataSource.shouldRender(props) && ( - <StyledQuestionDataSource - question={question} - isObjectDetail={isObjectDetail} - subHead - /> - )} - </ViewHeaderLeftSubHeading> + {isAdditionalInfoVisible && ( + <ViewHeaderLeftSubHeading> + <StyledCollectionBadge collectionId={question.collectionId()} /> + {QuestionDataSource.shouldRender(props) && ( + <StyledQuestionDataSource + question={question} + isObjectDetail={isObjectDetail} + subHead + /> + )} + </ViewHeaderLeftSubHeading> + )} </div> ); } @@ -279,6 +282,7 @@ function AhHocQuestionLeftSide(props) { DatasetLeftSide.propTypes = { question: PropTypes.object.isRequired, + isAdditionalInfoVisible: PropTypes.bool, isShowingQuestionDetailsSidebar: PropTypes.bool, onOpenQuestionDetails: PropTypes.func.isRequired, onCloseQuestionDetails: PropTypes.func.isRequired, @@ -287,6 +291,7 @@ DatasetLeftSide.propTypes = { function DatasetLeftSide(props) { const { question, + isAdditionalInfoVisible, isShowingQuestionDetailsSidebar, onOpenQuestionDetails, onCloseQuestionDetails, @@ -311,7 +316,14 @@ function DatasetLeftSide(props) { <HeadBreadcrumbs divider="/" parts={[ - <DatasetCollectionBadge key="collection" dataset={question} />, + ...(isAdditionalInfoVisible + ? [ + <DatasetCollectionBadge + key="collection" + dataset={question} + />, + ] + : []), <DatasetHeaderButtonContainer key="dataset-header-button"> <SavedQuestionHeaderButton question={question} @@ -355,6 +367,7 @@ ViewTitleHeaderRightSide.propTypes = { isShowingSummarySidebar: PropTypes.bool, isDirty: PropTypes.bool, isResultDirty: PropTypes.bool, + isActionListVisible: PropTypes.bool, runQuestionQuery: PropTypes.func, cancelQuery: PropTypes.func, onOpenModal: PropTypes.func, @@ -384,6 +397,7 @@ function ViewTitleHeaderRightSide(props) { isShowingSummarySidebar, isDirty, isResultDirty, + isActionListVisible, runQuestionQuery, cancelQuery, onOpenModal, @@ -408,7 +422,11 @@ function ViewTitleHeaderRightSide(props) { MetabaseSettings.get("enable-nested-queries"); const isNewQuery = !query.hasData(); - const hasSaveButton = !isDataset && !!isDirty && (isNewQuery || canEditQuery); + const hasSaveButton = + !isDataset && + !!isDirty && + (isNewQuery || canEditQuery) && + isActionListVisible; const isMissingPermissions = result?.error_type === SERVER_ERROR_TYPES.missingPermissions; const hasRunButton = @@ -466,7 +484,7 @@ function ViewTitleHeaderRightSide(props) { data-metabase-event={`View Mode; Open Summary Widget`} /> )} - {QuestionNotebookButton.shouldRender({ question }) && ( + {QuestionNotebookButton.shouldRender(props) && ( <QuestionNotebookButton className="hide sm-show" ml={2} 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 acecc634dfb44337dee98da0d4bb5e0fc643e462..189709804c346a98ac50ddb281443c04489b46d7 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 @@ -94,7 +94,14 @@ function mockSettings({ enableNestedQueries = true } = {}) { }); } -function setup({ question, isRunnable = true, settings, ...props } = {}) { +function setup({ + question, + settings, + isRunnable = true, + isActionListVisible = true, + isAdditionalInfoVisible = true, + ...props +} = {}) { mockSettings(settings); const callbacks = { @@ -113,8 +120,10 @@ function setup({ question, isRunnable = true, settings, ...props } = {}) { <ViewTitleHeader {...callbacks} {...props} - isRunnable={isRunnable} question={question} + isRunnable={isRunnable} + isActionListVisible={isActionListVisible} + isAdditionalInfoVisible={isAdditionalInfoVisible} />, { withRouter: true, diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index 5d7b99d8bcdfbdd5210cd895e4162b1f7b0114dd..e1f610aba3ba4e73881e21500a03ae08bfbf6402 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -87,6 +87,9 @@ import { getPageFavicon, getIsTimeseries, getIsLoadingComplete, + getIsHeaderVisible, + getIsActionListVisible, + getIsAdditionalInfoVisible, } from "../selectors"; import * as actions from "../actions"; @@ -162,6 +165,9 @@ const mapStateToProps = (state, props) => { isVisualized: getIsVisualized(state), isLiveResizable: getIsLiveResizable(state), isTimeseries: getIsTimeseries(state), + isHeaderVisible: getIsHeaderVisible(state), + isActionListVisible: getIsActionListVisible(state), + isAdditionalInfoVisible: getIsAdditionalInfoVisible(state), parameters: getParameters(state), databaseFields: getDatabaseFields(state), diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index 3bedb3439d01d9a0088e4f37f28e556ed81a62c2..f2610c131563aa1a8e86c23ab846f5f53c14e225 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -26,6 +26,7 @@ import Timelines from "metabase/entities/timelines"; import { getMetadata } from "metabase/selectors/metadata"; import { getAlerts } from "metabase/alert/selectors"; +import { getEmbedOptions, getIsEmbedded } from "metabase/selectors/embed"; import { parseTimestamp } from "metabase/lib/time"; import { getSortedTimelines } from "metabase/lib/timelines"; import { @@ -828,3 +829,18 @@ export const getTimeoutId = createSelector( [getLoadingControls], loadingControls => loadingControls.timeoutId, ); + +export const getIsHeaderVisible = createSelector( + [getIsEmbedded, getEmbedOptions], + (isEmbedded, embedOptions) => !isEmbedded || embedOptions.header, +); + +export const getIsActionListVisible = createSelector( + [getIsEmbedded, getEmbedOptions], + (isEmbedded, embedOptions) => !isEmbedded || embedOptions.action_buttons, +); + +export const getIsAdditionalInfoVisible = createSelector( + [getIsEmbedded, getEmbedOptions], + (isEmbedded, embedOptions) => !isEmbedded || embedOptions.additional_info, +); diff --git a/frontend/src/metabase/redux/embed.js b/frontend/src/metabase/redux/embed.js index 48cd5024d4b66f9cf7ea78016d4ac6e4c6a87be1..38f219f4008e0e027d47b59cf922237ccadeb398 100644 --- a/frontend/src/metabase/redux/embed.js +++ b/frontend/src/metabase/redux/embed.js @@ -9,6 +9,9 @@ const DEFAULT_OPTIONS = { side_nav: "default", search: false, new_button: false, + header: true, + additional_info: true, + action_buttons: true, }; export const SET_OPTIONS = "metabase/embed/SET_OPTIONS"; diff --git a/frontend/src/metabase/selectors/app.ts b/frontend/src/metabase/selectors/app.ts index 2ff6dee29fad54908faab9efbf188f38a596f469..5b74ba662d1d86b8648d18df7040ad2bc9e004bc 100644 --- a/frontend/src/metabase/selectors/app.ts +++ b/frontend/src/metabase/selectors/app.ts @@ -1,6 +1,92 @@ +import { Location } from "history"; +import { createSelector } from "reselect"; +import { getUser } from "metabase/selectors/user"; +import { getIsEditing as getIsEditingDashboard } from "metabase/dashboard/selectors"; +import { getEmbedOptions, getIsEmbedded } from "metabase/selectors/embed"; import { State } from "metabase-types/store"; -export const getErrorPage = (state: State) => state.app.errorPage; +interface RouterProps { + location: Location; +} + +const HOMEPAGE_PATH = /^\/$/; +const PATHS_WITHOUT_NAVBAR = [/\/model\/.*\/query/, /\/model\/.*\/metadata/]; +const EMBEDDED_PATHS_WITH_NAVBAR = [ + HOMEPAGE_PATH, + /^\/collection\/.*/, + /^\/archive/, +]; + +export const getRouterPath = (state: State, props: RouterProps) => { + return props.location.pathname; +}; + +export const getRouterHash = (state: State, props: RouterProps) => { + return props.location.hash; +}; + +export const getIsAdminApp = createSelector([getRouterPath], path => { + return path.startsWith("/admin/"); +}); + +export const getIsAppBarVisible = createSelector( + [ + getUser, + getRouterPath, + getRouterHash, + getIsAdminApp, + getIsEditingDashboard, + getIsEmbedded, + getEmbedOptions, + ], + ( + currentUser, + path, + hash, + isAdminApp, + isEditingDashboard, + isEmbedded, + embedOptions, + ) => { + const isFullscreen = hash.includes("fullscreen"); + if ( + !currentUser || + (isEmbedded && !embedOptions.top_nav) || + isAdminApp || + isEditingDashboard || + isFullscreen + ) { + return false; + } + return !PATHS_WITHOUT_NAVBAR.some(pattern => pattern.test(path)); + }, +); + +export const getIsNavBarVisible = createSelector( + [ + getUser, + getRouterPath, + getIsEditingDashboard, + getIsEmbedded, + getEmbedOptions, + ], + (currentUser, path, isEditingDashboard, isEmbedded, embedOptions) => { + if (!currentUser || isEditingDashboard) { + return false; + } + if (isEmbedded && !embedOptions.side_nav) { + return false; + } + if (isEmbedded && embedOptions.side_nav === "default") { + return EMBEDDED_PATHS_WITH_NAVBAR.some(pattern => pattern.test(path)); + } + return !PATHS_WITHOUT_NAVBAR.some(pattern => pattern.test(path)); + }, +); + +export const getErrorPage = (state: State) => { + return state.app.errorPage; +}; export const getErrorMessage = (state: State) => { const errorPage = getErrorPage(state); diff --git a/frontend/src/metabase/selectors/embed.ts b/frontend/src/metabase/selectors/embed.ts index a5e6ef8763eae5e95d6191a1965acc15dd0e0e48..0f5238bf6f4a049503774b71e621c1e98c803714 100644 --- a/frontend/src/metabase/selectors/embed.ts +++ b/frontend/src/metabase/selectors/embed.ts @@ -1,5 +1,10 @@ +import { IFRAMED } from "metabase/lib/dom"; import { State } from "metabase-types/store"; +export const getIsEmbedded = () => { + return IFRAMED; +}; + export const getEmbedOptions = (state: State) => { return state.embed.options; }; 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 new file mode 100644 index 0000000000000000000000000000000000000000..12fd757f865398df71ff37c88ce5426bc7dcbbe3 --- /dev/null +++ b/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js @@ -0,0 +1,162 @@ +import { restore } from "__support__/e2e/cypress"; + +describe("scenarios > embedding > full app", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + cy.intercept("POST", `/api/card/*/query`).as("getCardQuery"); + cy.intercept("POST", "/api/dashboard/**/query").as("getDashCardQuery"); + cy.intercept("GET", `/api/dashboard/*`).as("getDashboard"); + cy.intercept("GET", "/api/automagic-dashboards/**").as("getXrayDashboard"); + }); + + describe("navigation", () => { + it("should hide the top nav by default", () => { + visitUrl({ url: "/" }); + cy.findByText("Our analytics").should("be.visible"); + cy.findByTestId("main-logo").should("not.exist"); + }); + + it("should show the top nav by a param", () => { + visitUrl({ url: "/", qs: { top_nav: true } }); + cy.findAllByTestId("main-logo").should("be.visible"); + cy.button(/New/).should("not.exist"); + cy.findByPlaceholderText("Search").should("not.exist"); + }); + + it("should hide the side nav by a param", () => { + visitUrl({ url: "/", qs: { top_nav: true, side_nav: false } }); + cy.findAllByTestId("main-logo").should("be.visible"); + cy.findByText("Our analytics").should("not.exist"); + }); + + it("should show question creation controls by a param", () => { + visitUrl({ url: "/", qs: { top_nav: true, new_button: true } }); + cy.button(/New/).should("be.visible"); + }); + + it("should show search controls by a param", () => { + visitUrl({ url: "/", qs: { top_nav: true, search: true } }); + cy.findByPlaceholderText("Search…").should("be.visible"); + }); + + it("should preserve params when navigating", () => { + visitUrl({ url: "/", qs: { top_nav: true } }); + cy.findAllByTestId("main-logo").should("be.visible"); + + cy.findByText("Our analytics").click(); + cy.findByText("Orders in a dashboard").should("be.visible"); + cy.findAllByTestId("main-logo").should("be.visible"); + }); + }); + + describe("questions", () => { + it("should show the question header by default", () => { + visitQuestionUrl({ url: "/question/1" }); + + 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"); + cy.button("Summarize").should("be.visible"); + cy.button("Filter").should("be.visible"); + }); + + it("should hide the question header by a param", () => { + visitQuestionUrl({ url: "/question/1", qs: { header: false } }); + + cy.findByTestId("qb-header").should("not.exist"); + }); + + it("should hide the question's additional info by a param", () => { + visitQuestionUrl({ url: "/question/1", qs: { additional_info: false } }); + + cy.findByText("Our analytics").should("not.exist"); + cy.findByText(/Edited/).should("not.exist"); + }); + + it("should hide the question's action buttons by a param", () => { + visitQuestionUrl({ url: "/question/1", qs: { action_buttons: false } }); + + cy.icon("refresh").should("be.visible"); + cy.icon("notebook").should("not.exist"); + cy.button("Summarize").should("not.exist"); + cy.button("Filter").should("not.exist"); + }); + }); + + describe("dashboards", () => { + it("should show the dashboard header by default", () => { + visitDashboardUrl({ url: "/dashboard/1" }); + + cy.findByText("Orders in a dashboard").should("be.visible"); + cy.findByText(/Edited/).should("be.visible"); + cy.findByText("Our analytics").should("be.visible"); + }); + + it("should hide the dashboard header by a param", () => { + visitDashboardUrl({ url: "/dashboard/1", qs: { header: false } }); + + cy.findByText("Orders in a dashboard").should("not.exist"); + }); + + it("should hide the dashboard's additional info by a param", () => { + visitDashboardUrl({ + url: "/dashboard/1", + qs: { additional_info: false }, + }); + + cy.findByText("Orders in a dashboard").should("be.visible"); + cy.findByText(/Edited/).should("not.exist"); + cy.findByText("Our analytics").should("not.exist"); + }); + }); + + describe("x-ray dashboards", () => { + it("should show the dashboard header by default", () => { + visitXrayDashboardUrl({ url: "/auto/dashboard/table/1" }); + + cy.findByText("More X-rays").should("be.visible"); + cy.button("Save this").should("be.visible"); + }); + + it("should hide the dashboard header by a param", () => { + visitXrayDashboardUrl({ + url: "/auto/dashboard/table/1", + qs: { header: false }, + }); + + cy.findByText("More X-rays").should("be.visible"); + cy.button("Save this").should("not.exist"); + }); + }); +}); + +const visitUrl = url => { + cy.visit({ + ...url, + onBeforeLoad(window) { + // cypress runs all tests in an iframe and the app uses this property to avoid embedding mode for all tests + // by removing the property the app would work in embedding mode + window.Cypress = undefined; + }, + }); +}; + +const visitQuestionUrl = url => { + visitUrl(url); + cy.wait("@getCardQuery"); +}; + +const visitDashboardUrl = url => { + visitUrl(url); + cy.wait("@getDashboard"); + cy.wait("@getDashCardQuery"); +}; + +const visitXrayDashboardUrl = url => { + visitUrl(url); + cy.wait("@getXrayDashboard"); +};