From 7de0faa63585e333449a99b35a3a9d52b471caa2 Mon Sep 17 00:00:00 2001 From: Oisin Coveney <oisin@metabase.com> Date: Wed, 19 Jun 2024 11:16:03 +0300 Subject: [PATCH] Split View Header components into separate files (#44347) --- .../components/view/ViewHeader/ViewHeader.jsx | 441 +----------------- .../AdHocQuestionLeftSide.jsx | 72 +++ .../components/AdHocQuestionLeftSide/index.ts | 1 + .../DashboardBackButton.tsx | 42 ++ .../components/DashboardBackButton/index.ts | 1 + .../HeaderCollectionBadge.jsx | 19 + .../components/HeaderCollectionBadge/index.ts | 1 + .../SavedQuestionLeftSide.jsx | 112 +++++ .../components/SavedQuestionLeftSide/index.ts | 1 + .../ViewTitleHeaderRightSide.jsx | 219 +++++++++ .../ViewTitleHeaderRightSide/index.ts | 1 + .../view/ViewHeader/components/index.ts | 5 + 12 files changed, 481 insertions(+), 434 deletions(-) create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/AdHocQuestionLeftSide/AdHocQuestionLeftSide.jsx create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/AdHocQuestionLeftSide/index.ts create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/DashboardBackButton/DashboardBackButton.tsx create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/DashboardBackButton/index.ts create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/HeaderCollectionBadge/HeaderCollectionBadge.jsx create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/HeaderCollectionBadge/index.ts create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.jsx create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/index.ts create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/ViewTitleHeaderRightSide.jsx create mode 100644 frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/index.ts diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/ViewHeader.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/ViewHeader.jsx index 9c41a6054c2..4c30abe60f7 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader/ViewHeader.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/ViewHeader.jsx @@ -1,55 +1,18 @@ -import cx from "classnames"; import PropTypes from "prop-types"; -import { useEffect, useCallback, useState } from "react"; +import { useCallback, useEffect } from "react"; import { usePrevious } from "react-use"; -import { t } from "ttag"; -import Link from "metabase/core/components/Link"; -import Tooltip from "metabase/core/components/Tooltip"; -import CS from "metabase/css/core/index.css"; import { useToggle } from "metabase/hooks/use-toggle"; -import { SERVER_ERROR_TYPES } from "metabase/lib/errors"; -import { useDispatch, useSelector } from "metabase/lib/redux"; -import MetabaseSettings from "metabase/lib/settings"; -import * as Urls from "metabase/lib/urls"; -import { navigateBackToDashboard } from "metabase/query_builder/actions"; -import SavedQuestionHeaderButton from "metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton"; -import { MODAL_TYPES } from "metabase/query_builder/constants"; -import { getDashboard } from "metabase/query_builder/selectors"; import * as Lib from "metabase-lib"; +import { ViewHeaderContainer } from "./ViewHeader.styled"; import { - AdHocViewHeading, - SaveButton, - SavedQuestionHeaderButtonContainer, - ViewHeaderMainLeftContentContainer, - ViewHeaderLeftSubHeading, - ViewHeaderContainer, - StyledLastEditInfoLabel, - StyledQuestionDataSource, - SavedQuestionLeftSideRoot, - AdHocLeftSideRoot, - HeaderDivider, - ViewHeaderActionPanel, - ViewHeaderIconButtonContainer, - BackButton, - BackButtonContainer, - ViewRunButtonWithTooltip, -} from "./ViewHeader.styled"; -import { - ToggleNativeQueryPreview, - HeadBreadcrumbs, - FilterHeaderButton, - FilterHeaderToggle, + AdHocQuestionLeftSide, FilterHeader, - ExploreResultsLink, - QuestionActions, - QuestionNotebookButton, - QuestionDataSource, - QuestionDescription, - QuestionSummarizeWidget, + SavedQuestionLeftSide, + ViewTitleHeaderRightSide, + DashboardBackButton, } from "./components"; -import { canExploreResults } from "./utils"; const viewTitleHeaderPropTypes = { question: PropTypes.object.isRequired, @@ -140,7 +103,7 @@ export function ViewTitleHeader(props) { {isSaved ? ( <SavedQuestionLeftSide {...props} /> ) : ( - <AhHocQuestionLeftSide + <AdHocQuestionLeftSide {...props} isNative={isNative} isSummarized={isSummarized} @@ -170,394 +133,4 @@ export function ViewTitleHeader(props) { ); } -function DashboardBackButton() { - const dashboard = useSelector(getDashboard); - const dispatch = useDispatch(); - - const handleClick = () => { - dispatch(navigateBackToDashboard(dashboard.id)); - }; - - if (!dashboard) { - return null; - } - - const label = t`Back to ${dashboard.name}`; - - return ( - <Tooltip tooltip={label}> - <BackButtonContainer> - <BackButton - as={Link} - to={Urls.dashboard(dashboard)} - round - icon="arrow_left" - aria-label={label} - onClick={handleClick} - /> - </BackButtonContainer> - </Tooltip> - ); -} - -SavedQuestionLeftSide.propTypes = { - question: PropTypes.object.isRequired, - isObjectDetail: PropTypes.bool, - isAdditionalInfoVisible: PropTypes.bool, - isShowingQuestionDetailsSidebar: PropTypes.bool, - onOpenQuestionInfo: PropTypes.func.isRequired, - onSave: PropTypes.func, -}; - -function SavedQuestionLeftSide(props) { - const { - question, - isObjectDetail, - isAdditionalInfoVisible, - onOpenQuestionInfo, - onSave, - } = props; - - const [showSubHeader, setShowSubHeader] = useState(true); - - const hasLastEditInfo = question.lastEditInfo() != null; - const type = question.type(); - const isModelOrMetric = type === "model" || type === "metric"; - - const onHeaderChange = useCallback( - name => { - if (name && name !== question.displayName()) { - onSave(question.setDisplayName(name)); - } - }, - [question, onSave], - ); - - const renderDataSource = - QuestionDataSource.shouldRender(props) && type === "question"; - const renderLastEdit = hasLastEditInfo && isAdditionalInfoVisible; - - useEffect(() => { - const timerId = setTimeout(() => { - if (isAdditionalInfoVisible && (renderDataSource || renderLastEdit)) { - setShowSubHeader(false); - } - }, 4000); - return () => clearTimeout(timerId); - }, [isAdditionalInfoVisible, renderDataSource, renderLastEdit]); - - return ( - <SavedQuestionLeftSideRoot - data-testid="qb-header-left-side" - showSubHeader={showSubHeader} - > - <ViewHeaderMainLeftContentContainer> - <SavedQuestionHeaderButtonContainer isModelOrMetric={isModelOrMetric}> - <HeadBreadcrumbs - divider={<HeaderDivider>/</HeaderDivider>} - parts={[ - ...(isAdditionalInfoVisible && isModelOrMetric - ? [ - <HeaderCollectionBadge - key="collection" - question={question} - />, - ] - : []), - - <SavedQuestionHeaderButton - key={question.displayName()} - question={question} - onSave={onHeaderChange} - />, - ]} - /> - </SavedQuestionHeaderButtonContainer> - </ViewHeaderMainLeftContentContainer> - {isAdditionalInfoVisible && ( - <ViewHeaderLeftSubHeading> - {QuestionDataSource.shouldRender(props) && !isModelOrMetric && ( - <StyledQuestionDataSource - question={question} - isObjectDetail={isObjectDetail} - subHead - /> - )} - {hasLastEditInfo && isAdditionalInfoVisible && ( - <StyledLastEditInfoLabel - item={question.card()} - onClick={onOpenQuestionInfo} - /> - )} - </ViewHeaderLeftSubHeading> - )} - </SavedQuestionLeftSideRoot> - ); -} - -AhHocQuestionLeftSide.propTypes = { - question: PropTypes.object.isRequired, - originalQuestion: PropTypes.object, - isNative: PropTypes.bool, - isObjectDetail: PropTypes.bool, - isSummarized: PropTypes.bool, - onOpenModal: PropTypes.func, -}; - -function AhHocQuestionLeftSide(props) { - const { - question, - originalQuestion, - isNative, - isObjectDetail, - isSummarized, - onOpenModal, - } = props; - - const handleTitleClick = () => { - const { isEditable } = Lib.queryDisplayInfo(question.query()); - - if (isEditable) { - onOpenModal(MODAL_TYPES.SAVE); - } - }; - - return ( - <AdHocLeftSideRoot> - <ViewHeaderMainLeftContentContainer> - <AdHocViewHeading color="medium"> - {isNative ? ( - t`New question` - ) : ( - <QuestionDescription - question={question} - originalQuestion={originalQuestion} - isObjectDetail={isObjectDetail} - onClick={handleTitleClick} - /> - )} - </AdHocViewHeading> - </ViewHeaderMainLeftContentContainer> - <ViewHeaderLeftSubHeading> - {isSummarized && ( - <QuestionDataSource - className={CS.mb1} - question={question} - isObjectDetail={isObjectDetail} - subHead - /> - )} - </ViewHeaderLeftSubHeading> - </AdHocLeftSideRoot> - ); -} - -HeaderCollectionBadge.propTypes = { - question: PropTypes.object.isRequired, -}; - -function HeaderCollectionBadge({ question }) { - const { collection } = question.card(); - const icon = question.type(); - return ( - <HeadBreadcrumbs.Badge to={Urls.collection(collection)} icon={icon}> - {collection?.name || t`Our analytics`} - </HeadBreadcrumbs.Badge> - ); -} - -ViewTitleHeaderRightSide.propTypes = { - question: PropTypes.object.isRequired, - result: PropTypes.object, - queryBuilderMode: PropTypes.oneOf(["view", "notebook"]), - isModelOrMetric: PropTypes.bool, - isSaved: PropTypes.bool, - isNative: PropTypes.bool, - isRunnable: PropTypes.bool, - isRunning: PropTypes.bool, - isNativeEditorOpen: PropTypes.bool, - isShowingSummarySidebar: PropTypes.bool, - isDirty: PropTypes.bool, - isResultDirty: PropTypes.bool, - isActionListVisible: PropTypes.bool, - runQuestionQuery: PropTypes.func, - updateQuestion: PropTypes.func.isRequired, - cancelQuery: PropTypes.func, - onOpenModal: PropTypes.func, - onEditSummary: PropTypes.func, - onCloseSummary: PropTypes.func, - setQueryBuilderMode: PropTypes.func, - turnDatasetIntoQuestion: PropTypes.func, - areFiltersExpanded: PropTypes.bool, - onExpandFilters: PropTypes.func, - onCollapseFilters: PropTypes.func, - isBookmarked: PropTypes.bool, - toggleBookmark: PropTypes.func, - onOpenQuestionInfo: PropTypes.func, - onCloseQuestionInfo: PropTypes.func, - isShowingQuestionInfoSidebar: PropTypes.bool, - onModelPersistenceChange: PropTypes.func, - onQueryChange: PropTypes.func, -}; - -function ViewTitleHeaderRightSide(props) { - const { - question, - result, - queryBuilderMode, - isBookmarked, - toggleBookmark, - isSaved, - isModelOrMetric, - isRunnable, - isRunning, - isNativeEditorOpen, - isShowingSummarySidebar, - isDirty, - isResultDirty, - isActionListVisible, - runQuestionQuery, - cancelQuery, - onOpenModal, - onEditSummary, - onCloseSummary, - setQueryBuilderMode, - turnDatasetIntoQuestion, - areFiltersExpanded, - onExpandFilters, - onCollapseFilters, - isShowingQuestionInfoSidebar, - onCloseQuestionInfo, - onOpenQuestionInfo, - onModelPersistenceChange, - } = props; - const isShowingNotebook = queryBuilderMode === "notebook"; - const { isEditable } = Lib.queryDisplayInfo(question.query()); - - const hasExploreResultsLink = - canExploreResults(question) && - MetabaseSettings.get("enable-nested-queries"); - - // Models and metrics can't be saved. But changing anything about the model/metric will prompt the user - // to save it as a new question (based on that model/metric). In other words, at this point - // the `type` field is set to "question". - const hasSaveButton = - !isModelOrMetric && - !!isDirty && - !question.isArchived() && - isActionListVisible; - const isMissingPermissions = - result?.error_type === SERVER_ERROR_TYPES.missingPermissions; - const hasRunButton = - isRunnable && !isNativeEditorOpen && !isMissingPermissions; - - const handleInfoClick = useCallback(() => { - if (isShowingQuestionInfoSidebar) { - onCloseQuestionInfo(); - } else { - onOpenQuestionInfo(); - } - }, [isShowingQuestionInfoSidebar, onOpenQuestionInfo, onCloseQuestionInfo]); - - const getRunButtonLabel = useCallback( - () => (isRunning ? t`Cancel` : t`Refresh`), - [isRunning], - ); - - const canSave = Lib.canSave(question.query(), question.type()); - const isSaveDisabled = !canSave; - const disabledSaveTooltip = getDisabledSaveTooltip(isEditable); - - return ( - <ViewHeaderActionPanel data-testid="qb-header-action-panel"> - {FilterHeaderToggle.shouldRender(props) && ( - <FilterHeaderToggle - className={cx(CS.ml2, CS.mr1)} - query={question.query()} - isExpanded={areFiltersExpanded} - onExpand={onExpandFilters} - onCollapse={onCollapseFilters} - /> - )} - {FilterHeaderButton.shouldRender(props) && ( - <FilterHeaderButton - className={cx(CS.hide, CS.smShow)} - onOpenModal={onOpenModal} - /> - )} - {QuestionSummarizeWidget.shouldRender(props) && ( - <QuestionSummarizeWidget - className={cx(CS.hide, CS.smShow)} - isShowingSummarySidebar={isShowingSummarySidebar} - onEditSummary={onEditSummary} - onCloseSummary={onCloseSummary} - /> - )} - {QuestionNotebookButton.shouldRender(props) && ( - <ViewHeaderIconButtonContainer> - <QuestionNotebookButton - iconSize={16} - question={question} - isShowingNotebook={isShowingNotebook} - setQueryBuilderMode={setQueryBuilderMode} - /> - </ViewHeaderIconButtonContainer> - )} - {ToggleNativeQueryPreview.shouldRender(props) && ( - <ToggleNativeQueryPreview question={question} /> - )} - {hasExploreResultsLink && <ExploreResultsLink question={question} />} - {hasRunButton && !isShowingNotebook && ( - <ViewHeaderIconButtonContainer> - <ViewRunButtonWithTooltip - iconSize={16} - onlyIcon - medium - compact - result={result} - isRunning={isRunning} - isDirty={isResultDirty} - onRun={() => runQuestionQuery({ ignoreCache: true })} - onCancel={cancelQuery} - getTooltip={getRunButtonLabel} - /> - </ViewHeaderIconButtonContainer> - )} - {isSaved && ( - <QuestionActions - isShowingQuestionInfoSidebar={isShowingQuestionInfoSidebar} - isBookmarked={isBookmarked} - handleBookmark={toggleBookmark} - onOpenModal={onOpenModal} - question={question} - setQueryBuilderMode={setQueryBuilderMode} - turnDatasetIntoQuestion={turnDatasetIntoQuestion} - onInfoClick={handleInfoClick} - onModelPersistenceChange={onModelPersistenceChange} - /> - )} - {hasSaveButton && ( - <SaveButton - role="button" - disabled={isSaveDisabled} - tooltip={{ - tooltip: disabledSaveTooltip, - isEnabled: isSaveDisabled, - placement: "left", - }} - onClick={() => onOpenModal("save")} - > - {t`Save`} - </SaveButton> - )} - </ViewHeaderActionPanel> - ); -} - ViewTitleHeader.propTypes = viewTitleHeaderPropTypes; - -function getDisabledSaveTooltip(isEditable) { - if (!isEditable) { - return t`You don't have permission to save this question.`; - } -} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/AdHocQuestionLeftSide/AdHocQuestionLeftSide.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/AdHocQuestionLeftSide/AdHocQuestionLeftSide.jsx new file mode 100644 index 00000000000..e6a37049622 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/AdHocQuestionLeftSide/AdHocQuestionLeftSide.jsx @@ -0,0 +1,72 @@ +import PropTypes from "prop-types"; +import { t } from "ttag"; + +import CS from "metabase/css/core/index.css"; +import { + AdHocLeftSideRoot, + AdHocViewHeading, + ViewHeaderLeftSubHeading, + ViewHeaderMainLeftContentContainer, +} from "metabase/query_builder/components/view/ViewHeader/ViewHeader.styled"; +import { + QuestionDataSource, + QuestionDescription, +} from "metabase/query_builder/components/view/ViewHeader/components"; +import { MODAL_TYPES } from "metabase/query_builder/constants"; +import * as Lib from "metabase-lib"; + +AdHocQuestionLeftSide.propTypes = { + question: PropTypes.object.isRequired, + originalQuestion: PropTypes.object, + isNative: PropTypes.bool, + isObjectDetail: PropTypes.bool, + isSummarized: PropTypes.bool, + onOpenModal: PropTypes.func, +}; +export function AdHocQuestionLeftSide(props) { + const { + question, + originalQuestion, + isNative, + isObjectDetail, + isSummarized, + onOpenModal, + } = props; + + const handleTitleClick = () => { + const { isEditable } = Lib.queryDisplayInfo(question.query()); + + if (isEditable) { + onOpenModal(MODAL_TYPES.SAVE); + } + }; + + return ( + <AdHocLeftSideRoot> + <ViewHeaderMainLeftContentContainer> + <AdHocViewHeading color="medium"> + {isNative ? ( + t`New question` + ) : ( + <QuestionDescription + question={question} + originalQuestion={originalQuestion} + isObjectDetail={isObjectDetail} + onClick={handleTitleClick} + /> + )} + </AdHocViewHeading> + </ViewHeaderMainLeftContentContainer> + <ViewHeaderLeftSubHeading> + {isSummarized && ( + <QuestionDataSource + className={CS.mb1} + question={question} + isObjectDetail={isObjectDetail} + subHead + /> + )} + </ViewHeaderLeftSubHeading> + </AdHocLeftSideRoot> + ); +} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/AdHocQuestionLeftSide/index.ts b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/AdHocQuestionLeftSide/index.ts new file mode 100644 index 00000000000..d04e1a9ccda --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/AdHocQuestionLeftSide/index.ts @@ -0,0 +1 @@ +export * from "./AdHocQuestionLeftSide"; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/DashboardBackButton/DashboardBackButton.tsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/DashboardBackButton/DashboardBackButton.tsx new file mode 100644 index 00000000000..2624af64af8 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/DashboardBackButton/DashboardBackButton.tsx @@ -0,0 +1,42 @@ +import { t } from "ttag"; + +import Link from "metabase/core/components/Link"; +import Tooltip from "metabase/core/components/Tooltip"; +import { useDispatch, useSelector } from "metabase/lib/redux"; +import * as Urls from "metabase/lib/urls"; +import { navigateBackToDashboard } from "metabase/query_builder/actions"; +import { + BackButton, + BackButtonContainer, +} from "metabase/query_builder/components/view/ViewHeader/ViewHeader.styled"; +import { getDashboard } from "metabase/query_builder/selectors"; + +export function DashboardBackButton() { + const dashboard = useSelector(getDashboard); + const dispatch = useDispatch(); + + const handleClick = () => { + dispatch(navigateBackToDashboard(dashboard.id)); + }; + + if (!dashboard) { + return null; + } + + const label = t`Back to ${dashboard.name}`; + + return ( + <Tooltip tooltip={label}> + <BackButtonContainer> + <BackButton + as={Link} + to={Urls.dashboard(dashboard)} + round + icon="arrow_left" + aria-label={label} + onClick={handleClick} + /> + </BackButtonContainer> + </Tooltip> + ); +} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/DashboardBackButton/index.ts b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/DashboardBackButton/index.ts new file mode 100644 index 00000000000..08325e8af35 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/DashboardBackButton/index.ts @@ -0,0 +1 @@ +export * from "./DashboardBackButton"; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/HeaderCollectionBadge/HeaderCollectionBadge.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/HeaderCollectionBadge/HeaderCollectionBadge.jsx new file mode 100644 index 00000000000..4f3871b004a --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/HeaderCollectionBadge/HeaderCollectionBadge.jsx @@ -0,0 +1,19 @@ +import PropTypes from "prop-types"; +import { t } from "ttag"; + +import * as Urls from "metabase/lib/urls"; +import { HeadBreadcrumbs } from "metabase/query_builder/components/view/ViewHeader/components"; + +HeaderCollectionBadge.propTypes = { + question: PropTypes.object.isRequired, +}; + +export function HeaderCollectionBadge({ question }) { + const { collection } = question.card(); + const icon = question.type(); + return ( + <HeadBreadcrumbs.Badge to={Urls.collection(collection)} icon={icon}> + {collection?.name || t`Our analytics`} + </HeadBreadcrumbs.Badge> + ); +} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/HeaderCollectionBadge/index.ts b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/HeaderCollectionBadge/index.ts new file mode 100644 index 00000000000..edec7552da2 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/HeaderCollectionBadge/index.ts @@ -0,0 +1 @@ +export * from "./HeaderCollectionBadge"; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.jsx new file mode 100644 index 00000000000..9c10c47202f --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.jsx @@ -0,0 +1,112 @@ +import PropTypes from "prop-types"; +import { useCallback, useEffect, useState } from "react"; + +import SavedQuestionHeaderButton from "metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton"; +import { + HeaderDivider, + SavedQuestionHeaderButtonContainer, + SavedQuestionLeftSideRoot, + StyledLastEditInfoLabel, + StyledQuestionDataSource, + ViewHeaderLeftSubHeading, + ViewHeaderMainLeftContentContainer, +} from "metabase/query_builder/components/view/ViewHeader/ViewHeader.styled"; +import { + HeadBreadcrumbs, + QuestionDataSource, +} from "metabase/query_builder/components/view/ViewHeader/components"; +import { HeaderCollectionBadge } from "metabase/query_builder/components/view/ViewHeader/components/HeaderCollectionBadge/HeaderCollectionBadge"; + +SavedQuestionLeftSide.propTypes = { + question: PropTypes.object.isRequired, + isObjectDetail: PropTypes.bool, + isAdditionalInfoVisible: PropTypes.bool, + isShowingQuestionDetailsSidebar: PropTypes.bool, + onOpenQuestionInfo: PropTypes.func.isRequired, + onSave: PropTypes.func, +}; +export function SavedQuestionLeftSide(props) { + const { + question, + isObjectDetail, + isAdditionalInfoVisible, + onOpenQuestionInfo, + onSave, + } = props; + + const [showSubHeader, setShowSubHeader] = useState(true); + + const hasLastEditInfo = question.lastEditInfo() != null; + const type = question.type(); + const isModelOrMetric = type === "model" || type === "metric"; + + const onHeaderChange = useCallback( + name => { + if (name && name !== question.displayName()) { + onSave(question.setDisplayName(name)); + } + }, + [question, onSave], + ); + + const renderDataSource = + QuestionDataSource.shouldRender(props) && type === "question"; + const renderLastEdit = hasLastEditInfo && isAdditionalInfoVisible; + + useEffect(() => { + const timerId = setTimeout(() => { + if (isAdditionalInfoVisible && (renderDataSource || renderLastEdit)) { + setShowSubHeader(false); + } + }, 4000); + return () => clearTimeout(timerId); + }, [isAdditionalInfoVisible, renderDataSource, renderLastEdit]); + + return ( + <SavedQuestionLeftSideRoot + data-testid="qb-header-left-side" + showSubHeader={showSubHeader} + > + <ViewHeaderMainLeftContentContainer> + <SavedQuestionHeaderButtonContainer isModelOrMetric={isModelOrMetric}> + <HeadBreadcrumbs + divider={<HeaderDivider>/</HeaderDivider>} + parts={[ + ...(isAdditionalInfoVisible && isModelOrMetric + ? [ + <HeaderCollectionBadge + key="collection" + question={question} + />, + ] + : []), + + <SavedQuestionHeaderButton + key={question.displayName()} + question={question} + onSave={onHeaderChange} + />, + ]} + /> + </SavedQuestionHeaderButtonContainer> + </ViewHeaderMainLeftContentContainer> + {isAdditionalInfoVisible && ( + <ViewHeaderLeftSubHeading> + {QuestionDataSource.shouldRender(props) && !isModelOrMetric && ( + <StyledQuestionDataSource + question={question} + isObjectDetail={isObjectDetail} + subHead + /> + )} + {hasLastEditInfo && isAdditionalInfoVisible && ( + <StyledLastEditInfoLabel + item={question.card()} + onClick={onOpenQuestionInfo} + /> + )} + </ViewHeaderLeftSubHeading> + )} + </SavedQuestionLeftSideRoot> + ); +} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/index.ts b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/index.ts new file mode 100644 index 00000000000..50bff38e341 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/index.ts @@ -0,0 +1 @@ +export * from "./SavedQuestionLeftSide"; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/ViewTitleHeaderRightSide.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/ViewTitleHeaderRightSide.jsx new file mode 100644 index 00000000000..4ba23c52b8d --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/ViewTitleHeaderRightSide.jsx @@ -0,0 +1,219 @@ +import cx from "classnames"; +import PropTypes from "prop-types"; +import { useCallback } from "react"; +import { t } from "ttag"; + +import CS from "metabase/css/core/index.css"; +import { SERVER_ERROR_TYPES } from "metabase/lib/errors"; +import MetabaseSettings from "metabase/lib/settings"; +import { + SaveButton, + ViewHeaderActionPanel, + ViewHeaderIconButtonContainer, + ViewRunButtonWithTooltip, +} from "metabase/query_builder/components/view/ViewHeader/ViewHeader.styled"; +import { + ExploreResultsLink, + FilterHeaderButton, + FilterHeaderToggle, + QuestionActions, + QuestionNotebookButton, + QuestionSummarizeWidget, + ToggleNativeQueryPreview, +} from "metabase/query_builder/components/view/ViewHeader/components"; +import { canExploreResults } from "metabase/query_builder/components/view/ViewHeader/utils"; +import * as Lib from "metabase-lib"; + +ViewTitleHeaderRightSide.propTypes = { + question: PropTypes.object.isRequired, + result: PropTypes.object, + queryBuilderMode: PropTypes.oneOf(["view", "notebook"]), + isModelOrMetric: PropTypes.bool, + isSaved: PropTypes.bool, + isNative: PropTypes.bool, + isRunnable: PropTypes.bool, + isRunning: PropTypes.bool, + isNativeEditorOpen: PropTypes.bool, + isShowingSummarySidebar: PropTypes.bool, + isDirty: PropTypes.bool, + isResultDirty: PropTypes.bool, + isActionListVisible: PropTypes.bool, + runQuestionQuery: PropTypes.func, + updateQuestion: PropTypes.func.isRequired, + cancelQuery: PropTypes.func, + onOpenModal: PropTypes.func, + onEditSummary: PropTypes.func, + onCloseSummary: PropTypes.func, + setQueryBuilderMode: PropTypes.func, + turnDatasetIntoQuestion: PropTypes.func, + areFiltersExpanded: PropTypes.bool, + onExpandFilters: PropTypes.func, + onCollapseFilters: PropTypes.func, + isBookmarked: PropTypes.bool, + toggleBookmark: PropTypes.func, + onOpenQuestionInfo: PropTypes.func, + onCloseQuestionInfo: PropTypes.func, + isShowingQuestionInfoSidebar: PropTypes.bool, + onModelPersistenceChange: PropTypes.func, + onQueryChange: PropTypes.func, +}; + +export function ViewTitleHeaderRightSide(props) { + const { + question, + result, + queryBuilderMode, + isBookmarked, + toggleBookmark, + isSaved, + isModelOrMetric, + isRunnable, + isRunning, + isNativeEditorOpen, + isShowingSummarySidebar, + isDirty, + isResultDirty, + isActionListVisible, + runQuestionQuery, + cancelQuery, + onOpenModal, + onEditSummary, + onCloseSummary, + setQueryBuilderMode, + turnDatasetIntoQuestion, + areFiltersExpanded, + onExpandFilters, + onCollapseFilters, + isShowingQuestionInfoSidebar, + onCloseQuestionInfo, + onOpenQuestionInfo, + onModelPersistenceChange, + } = props; + const isShowingNotebook = queryBuilderMode === "notebook"; + const { isEditable } = Lib.queryDisplayInfo(question.query()); + + const hasExploreResultsLink = + canExploreResults(question) && + MetabaseSettings.get("enable-nested-queries"); + + // Models and metrics can't be saved. But changing anything about the model/metric will prompt the user + // to save it as a new question (based on that model/metric). In other words, at this point + // the `type` field is set to "question". + const hasSaveButton = + !isModelOrMetric && + !!isDirty && + !question.isArchived() && + isActionListVisible; + const isMissingPermissions = + result?.error_type === SERVER_ERROR_TYPES.missingPermissions; + const hasRunButton = + isRunnable && !isNativeEditorOpen && !isMissingPermissions; + + const handleInfoClick = useCallback(() => { + if (isShowingQuestionInfoSidebar) { + onCloseQuestionInfo(); + } else { + onOpenQuestionInfo(); + } + }, [isShowingQuestionInfoSidebar, onOpenQuestionInfo, onCloseQuestionInfo]); + + const getRunButtonLabel = useCallback( + () => (isRunning ? t`Cancel` : t`Refresh`), + [isRunning], + ); + + const canSave = Lib.canSave(question.query(), question.type()); + const isSaveDisabled = !canSave; + const disabledSaveTooltip = getDisabledSaveTooltip(isEditable); + + return ( + <ViewHeaderActionPanel data-testid="qb-header-action-panel"> + {FilterHeaderToggle.shouldRender(props) && ( + <FilterHeaderToggle + className={cx(CS.ml2, CS.mr1)} + query={question.query()} + isExpanded={areFiltersExpanded} + onExpand={onExpandFilters} + onCollapse={onCollapseFilters} + /> + )} + {FilterHeaderButton.shouldRender(props) && ( + <FilterHeaderButton + className={cx(CS.hide, CS.smShow)} + onOpenModal={onOpenModal} + /> + )} + {QuestionSummarizeWidget.shouldRender(props) && ( + <QuestionSummarizeWidget + className={cx(CS.hide, CS.smShow)} + isShowingSummarySidebar={isShowingSummarySidebar} + onEditSummary={onEditSummary} + onCloseSummary={onCloseSummary} + /> + )} + {QuestionNotebookButton.shouldRender(props) && ( + <ViewHeaderIconButtonContainer> + <QuestionNotebookButton + iconSize={16} + question={question} + isShowingNotebook={isShowingNotebook} + setQueryBuilderMode={setQueryBuilderMode} + /> + </ViewHeaderIconButtonContainer> + )} + {ToggleNativeQueryPreview.shouldRender(props) && ( + <ToggleNativeQueryPreview question={question} /> + )} + {hasExploreResultsLink && <ExploreResultsLink question={question} />} + {hasRunButton && !isShowingNotebook && ( + <ViewHeaderIconButtonContainer> + <ViewRunButtonWithTooltip + iconSize={16} + onlyIcon + medium + compact + result={result} + isRunning={isRunning} + isDirty={isResultDirty} + onRun={() => runQuestionQuery({ ignoreCache: true })} + onCancel={cancelQuery} + getTooltip={getRunButtonLabel} + /> + </ViewHeaderIconButtonContainer> + )} + {isSaved && ( + <QuestionActions + isShowingQuestionInfoSidebar={isShowingQuestionInfoSidebar} + isBookmarked={isBookmarked} + handleBookmark={toggleBookmark} + onOpenModal={onOpenModal} + question={question} + setQueryBuilderMode={setQueryBuilderMode} + turnDatasetIntoQuestion={turnDatasetIntoQuestion} + onInfoClick={handleInfoClick} + onModelPersistenceChange={onModelPersistenceChange} + /> + )} + {hasSaveButton && ( + <SaveButton + role="button" + disabled={isSaveDisabled} + tooltip={{ + tooltip: disabledSaveTooltip, + isEnabled: isSaveDisabled, + placement: "left", + }} + onClick={() => onOpenModal("save")} + > + {t`Save`} + </SaveButton> + )} + </ViewHeaderActionPanel> + ); +} + +function getDisabledSaveTooltip(isEditable) { + if (!isEditable) { + return t`You don't have permission to save this question.`; + } +} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/index.ts b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/index.ts new file mode 100644 index 00000000000..7d54488ba51 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/index.ts @@ -0,0 +1 @@ +export * from "./ViewTitleHeaderRightSide"; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/index.ts b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/index.ts index 041d9ab751b..a5afae84181 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/index.ts +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/index.ts @@ -8,3 +8,8 @@ export * from "./QuestionNotebookButton"; export * from "./ExploreResultsLink"; export * from "./FilterHeaderButton"; export * from "./QuestionSummarizeWidget"; +export * from "./AdHocQuestionLeftSide"; +export * from "./HeaderCollectionBadge"; +export * from "./SavedQuestionLeftSide"; +export * from "./ViewTitleHeaderRightSide"; +export * from "./DashboardBackButton"; -- GitLab