diff --git a/frontend/src/metabase/query_builder/components/view/FilterHeaderButton.tsx b/frontend/src/metabase/query_builder/components/view/FilterHeaderButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2869855f0ebe6f42b95bbbbd0735ab9b5851a001 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/FilterHeaderButton.tsx @@ -0,0 +1,50 @@ +import { t } from "ttag"; +import { color } from "metabase/lib/colors"; +import { MODAL_TYPES } from "metabase/query_builder/constants"; +import type { QueryBuilderMode } from "metabase-types/store"; +import type Question from "metabase-lib/Question"; +import { HeaderButton } from "./ViewHeader.styled"; + +interface FilterHeaderButtonProps { + className?: string; + onOpenModal: (modalType: string) => void; +} + +export function FilterHeaderButton({ + className, + onOpenModal, +}: FilterHeaderButtonProps) { + return ( + <HeaderButton + className={className} + active={false} + large + labelBreakpoint="sm" + color={color("filter")} + onClick={() => onOpenModal(MODAL_TYPES.FILTERS)} + data-metabase-event="View Mode; Open Filter Modal" + data-testid="question-filter-header" + > + {t`Filter`} + </HeaderButton> + ); +} + +interface RenderCheckOpts { + question: Question; + queryBuilderMode: QueryBuilderMode; + isObjectDetail: boolean; + isActionListVisible: boolean; +} + +FilterHeaderButton.shouldRender = ({ + question, + queryBuilderMode, + isObjectDetail, + isActionListVisible, +}: RenderCheckOpts) => + queryBuilderMode === "view" && + question.isStructured() && + question.query().isEditable() && + !isObjectDetail && + isActionListVisible; diff --git a/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx b/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx deleted file mode 100644 index fc3d769368419d42eb740d0309ce59d7d85d2540..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx +++ /dev/null @@ -1,190 +0,0 @@ -/* eslint-disable react/prop-types */ -import { t } from "ttag"; - -import Tooltip from "metabase/core/components/Tooltip"; -import PopoverWithTrigger from "metabase/components/PopoverWithTrigger"; - -import { MODAL_TYPES } from "metabase/query_builder/constants"; -import { FilterPopover } from "metabase/query_builder/components/filters/FilterPopover"; -import { color } from "metabase/lib/colors"; -import ViewPill from "./ViewPill"; - -import { - HeaderButton, - FilterHeaderContainer, - FilterHeaderButton, -} from "./ViewHeader.styled"; - -const FilterPill = props => <ViewPill color={color("filter")} {...props} />; - -export default function QuestionFilters({ - className, - question, - expanded, - onExpand, - onCollapse, - onQueryChange, -}) { - const query = question.query(); - const filters = query.topLevelFilters(); - if (filters.length === 0) { - return null; - } - - return ( - <div className={className}> - <div className="flex flex-wrap align-center mbn1 mrn1"> - <Tooltip tooltip={expanded ? t`Hide filters` : t`Show filters`}> - <FilterPill - invert - icon="filter" - className="text-small mr1 mb1 cursor-pointer" - onClick={expanded ? onCollapse : onExpand} - data-metabase-event={ - expanded - ? `View Mode; Header Filters Collapse Click` - : `View Mode; Header Filters Expand Click` - } - data-testid="filters-visibility-control" - > - {expanded ? null : filters.length} - </FilterPill> - </Tooltip> - {expanded && - filters.map((filter, index) => ( - <PopoverWithTrigger - key={index} - triggerElement={ - <FilterPill - onRemove={() => onQueryChange(filter.remove().rootQuery())} - > - {filter.displayName()} - </FilterPill> - } - triggerClasses="flex flex-no-shrink align-center mr1 mb1" - sizeToFit - > - <FilterPopover - isTopLevel - query={query} - filter={filter} - onChangeFilter={newFilter => - onQueryChange(newFilter.replace().rootQuery()) - } - className="scroll-y" - /> - </PopoverWithTrigger> - ))} - </div> - </div> - ); -} - -export function FilterHeaderToggle({ - className, - question, - onExpand, - expanded, - onCollapse, - onQueryChange, -}) { - const query = question.query(); - const filters = query.topLevelFilters(); - if (filters.length === 0) { - return null; - } - return ( - <div className={className}> - <Tooltip tooltip={expanded ? t`Hide filters` : t`Show filters`}> - <FilterHeaderButton - small - rounded - icon="filter" - onClick={expanded ? onCollapse : onExpand} - active={expanded} - data-testid="filters-visibility-control" - > - <span>{filters.length}</span> - </FilterHeaderButton> - </Tooltip> - </div> - ); -} - -export function FilterHeader({ question, expanded, onQueryChange }) { - const query = question.query(); - const filters = query.topLevelFilters(); - if (filters.length === 0 || !expanded) { - return null; - } - return ( - <FilterHeaderContainer data-testid="qb-filters-panel"> - <div className="flex flex-wrap align-center"> - {filters.map((filter, index) => ( - <PopoverWithTrigger - key={index} - triggerElement={ - <FilterPill - onRemove={() => onQueryChange(filter.remove().rootQuery())} - > - {filter.displayName()} - </FilterPill> - } - triggerClasses="flex flex-no-shrink align-center mr1 mb1" - sizeToFit - > - <FilterPopover - isTopLevel - query={query} - filter={filter} - onChangeFilter={newFilter => - onQueryChange(newFilter.replace().rootQuery()) - } - className="scroll-y" - /> - </PopoverWithTrigger> - ))} - </div> - </FilterHeaderContainer> - ); -} - -export function QuestionFilterWidget({ onOpenModal, className }) { - return ( - <HeaderButton - large - labelBreakpoint="sm" - className={className} - color={color("filter")} - onClick={() => onOpenModal(MODAL_TYPES.FILTERS)} - aria-label={t`Show more filters`} - data-metabase-event="View Mode; Open Filter Modal" - data-testid="question-filter-header" - > - {t`Filter`} - </HeaderButton> - ); -} - -QuestionFilters.shouldRender = ({ - question, - queryBuilderMode, - isObjectDetail, -}) => - queryBuilderMode === "view" && - question.isStructured() && - question.query().isEditable() && - question.query().topLevelFilters().length > 0 && - !isObjectDetail; - -QuestionFilterWidget.shouldRender = ({ - question, - queryBuilderMode, - isObjectDetail, - isActionListVisible, -}) => - queryBuilderMode === "view" && - question.isStructured() && - question.query().isEditable() && - !isObjectDetail && - isActionListVisible; diff --git a/frontend/src/metabase/query_builder/components/view/QuestionFilters/QuestionFilters.styled.tsx b/frontend/src/metabase/query_builder/components/view/QuestionFilters/QuestionFilters.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f9cad498279b676e36282cb9ce877b174f286ee --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/QuestionFilters/QuestionFilters.styled.tsx @@ -0,0 +1,27 @@ +import styled from "@emotion/styled"; +import Button from "metabase/core/components/Button"; +import { color, alpha } from "metabase/lib/colors"; +import { space } from "metabase/styled-components/theme"; + +export const FilterHeaderContainer = styled.div` + padding: ${space(1)} ${space(3)} 0 ${space(3)}; + border-bottom: 1px solid ${color("border")}; +`; + +export const FilterHeaderButton = styled(Button)<{ active: boolean }>` + background-color: ${({ active }) => + active ? alpha(color("filter"), 0.8) : alpha(color("filter"), 0.2)}; + color: ${({ active }) => (active ? "white" : color("filter"))}; + border-radius: 99px; + padding-top: ${space(0.5)}; + padding-bottom: ${space(0.5)}; + &:hover { + background-color: ${color("filter")}; + color: white; + } + transition: background 300ms linear, border 300ms linear; + + @media (prefers-reduced-motion) { + transition: none; + } +`; diff --git a/frontend/src/metabase/query_builder/components/view/QuestionFilters/QuestionFilters.tsx b/frontend/src/metabase/query_builder/components/view/QuestionFilters/QuestionFilters.tsx new file mode 100644 index 0000000000000000000000000000000000000000..35a8f91694ce1d919991edbce75229092e865094 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/QuestionFilters/QuestionFilters.tsx @@ -0,0 +1,154 @@ +import { t } from "ttag"; + +import { Flex, Tooltip } from "metabase/ui"; +import PopoverWithTrigger from "metabase/components/PopoverWithTrigger"; +import { FilterPopover } from "metabase/query_builder/components/filters/FilterPopover"; + +import { color } from "metabase/lib/colors"; + +import type { QueryBuilderMode } from "metabase-types/store"; +import type Question from "metabase-lib/Question"; +import type StructuredQuery from "metabase-lib/queries/StructuredQuery"; +import type Filter from "metabase-lib/queries/structured/Filter"; + +import ViewPill from "../ViewPill"; +import type { ViewPillProps } from "../ViewPill"; +import { + FilterHeaderContainer, + FilterHeaderButton, +} from "./QuestionFilters.styled"; + +const FilterPill = (props: ViewPillProps) => ( + <ViewPill color={color("filter")} {...props} /> +); + +interface FilterHeaderToggleProps { + className?: string; + question: Question; + expanded: boolean; + onExpand: () => void; + onCollapse: () => void; +} + +export function FilterHeaderToggle({ + className, + question, + expanded, + onExpand, + onCollapse, +}: FilterHeaderToggleProps) { + const query = question.query() as StructuredQuery; + const filters = query.topLevelFilters(); + return ( + <div className={className}> + <Tooltip label={expanded ? t`Hide filters` : t`Show filters`}> + <FilterHeaderButton + small + icon="filter" + onClick={expanded ? onCollapse : onExpand} + active={expanded} + data-metabase-event={ + expanded + ? `View Mode; Header Filters Collapse Click` + : `View Mode; Header Filters Expand Click` + } + data-testid="filters-visibility-control" + > + <span>{filters.length}</span> + </FilterHeaderButton> + </Tooltip> + </div> + ); +} + +interface FilterHeaderProps { + question: Question; + expanded: boolean; + onQueryChange: (query: StructuredQuery) => void; +} + +export function FilterHeader({ + question, + expanded, + onQueryChange, +}: FilterHeaderProps) { + const query = question.query() as StructuredQuery; + const filters = query.topLevelFilters(); + + if (filters.length === 0 || !expanded) { + return null; + } + + return ( + <FilterHeaderContainer data-testid="qb-filters-panel"> + <Flex align="center" wrap="wrap"> + {filters.map((filter, index) => ( + <FilterHeaderPopover + key={index} + query={query} + filter={filter} + onQueryChange={onQueryChange} + /> + ))} + </Flex> + </FilterHeaderContainer> + ); +} + +interface FilterHeaderPopoverProps { + query: StructuredQuery; + filter: Filter; + onQueryChange: (query: StructuredQuery) => void; +} + +function FilterHeaderPopover({ + query, + filter, + onQueryChange, +}: FilterHeaderPopoverProps) { + const handleChange = (newFilter: Filter) => { + onQueryChange(newFilter.replace().rootQuery()); + }; + + const handleRemove = () => { + onQueryChange(filter.remove().rootQuery()); + }; + + return ( + <PopoverWithTrigger + triggerElement={ + <FilterPill onRemove={handleRemove}>{filter.displayName()}</FilterPill> + } + triggerClasses="flex flex-no-shrink align-center mr1 mb1" + sizeToFit + > + <FilterPopover + className="scroll-y" + query={query} + filter={filter} + isTopLevel + onChangeFilter={handleChange} + /> + </PopoverWithTrigger> + ); +} + +type RenderCheckOpts = { + question: Question; + queryBuilderMode: QueryBuilderMode; + isObjectDetail: boolean; +}; + +const shouldRender = ({ + question, + queryBuilderMode, + isObjectDetail, +}: RenderCheckOpts) => + queryBuilderMode === "view" && + question.isStructured() && + question.query().isEditable() && + (question.query() as StructuredQuery).topLevelFilters().length > 0 && + !isObjectDetail; + +FilterHeader.shouldRender = shouldRender; +FilterHeaderToggle.shouldRender = shouldRender; diff --git a/frontend/src/metabase/query_builder/components/view/QuestionFilters/index.ts b/frontend/src/metabase/query_builder/components/view/QuestionFilters/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..39f5ca9cb2c05c25105e3387477819167dd228f7 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/QuestionFilters/index.ts @@ -0,0 +1 @@ +export * from "./QuestionFilters"; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx index adb7bd4a09e2eeb180b472253adc99bbb78ee412..700b541955472b11cf4bca6f513bb4f9f565a77e 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx @@ -19,16 +19,13 @@ import { MODAL_TYPES } from "metabase/query_builder/constants"; import { getDashboard } from "metabase/query_builder/selectors"; import * as ML_Urls from "metabase-lib/urls"; import QuestionActions from "../QuestionActions"; +import { FilterHeaderButton } from "./FilterHeaderButton"; import { HeadBreadcrumbs } from "./HeaderBreadcrumbs"; import QuestionDataSource from "./QuestionDataSource"; import QuestionDescription from "./QuestionDescription"; import { QuestionNotebookButton } from "./QuestionNotebookButton"; import ConvertQueryButton from "./ConvertQueryButton"; -import QuestionFilters, { - FilterHeaderToggle, - FilterHeader, - QuestionFilterWidget, -} from "./QuestionFilters"; +import { FilterHeaderToggle, FilterHeader } from "./QuestionFilters"; import { QuestionSummarizeWidget } from "./QuestionSummaries"; import { AdHocViewHeading, @@ -150,7 +147,7 @@ export function ViewTitleHeader(props) { onQueryChange={onQueryChange} /> </ViewHeaderContainer> - {QuestionFilters.shouldRender(props) && ( + {FilterHeader.shouldRender(props) && ( <FilterHeader {...props} expanded={areFiltersExpanded} @@ -415,7 +412,6 @@ function ViewTitleHeaderRightSide(props) { onCloseQuestionInfo, onOpenQuestionInfo, onModelPersistenceChange, - onQueryChange, } = props; const isShowingNotebook = queryBuilderMode === "notebook"; const query = question.query(); @@ -456,18 +452,17 @@ function ViewTitleHeaderRightSide(props) { return ( <ViewHeaderActionPanel data-testid="qb-header-action-panel"> - {QuestionFilters.shouldRender(props) && ( + {FilterHeaderToggle.shouldRender(props) && ( <FilterHeaderToggle className="ml2 mr1" question={question} expanded={areFiltersExpanded} onExpand={onExpandFilters} onCollapse={onCollapseFilters} - onQueryChange={onQueryChange} /> )} - {QuestionFilterWidget.shouldRender(props) && ( - <QuestionFilterWidget + {FilterHeaderButton.shouldRender(props) && ( + <FilterHeaderButton className="hide sm-show" onOpenModal={onOpenModal} /> diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.tsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.tsx index 3ef9d25231d80cf556f4da6dba3651be10124d4b..2e4c4456282160b13a58ef13bfc3cb233d835820 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.tsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.tsx @@ -102,31 +102,7 @@ export const IconHeaderButton = styled(HeaderButton)` padding-right: 0.75rem; `; -export const FilterHeaderButton = styled(Button)<{ active: boolean }>` - background-color: ${({ active }) => - active ? alpha(color("filter"), 0.8) : alpha(color("filter"), 0.2)}; - color: ${({ active }) => (active ? "white" : color("filter"))}; - border-radius: 99px; - padding-top: ${space(0.5)}; - padding-bottom: ${space(0.5)}; - &:hover { - background-color: ${color("filter")}; - color: white; - } - transition: background 300ms linear, border 300ms linear; - - @media (prefers-reduced-motion) { - transition: none; - } -`; - const getDefaultColor = () => color("brand"); - -export const FilterHeaderContainer = styled.div` - padding: ${space(1)} ${space(3)} 0 ${space(3)}; - border-bottom: 1px solid ${color("border")}; -`; - export const StyledLastEditInfoLabel = styled(LastEditInfoLabel)` color: ${color("text-light")}; //margin-left: 4px; 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 1c2a6f20aaf945e3fec156b0c8c0f58033cad6bc..bfe576ab37b074674b7e945296fed9f1a0759be2 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 @@ -252,7 +252,7 @@ describe("ViewHeader", () => { setup({ card, database: null }); expect(screen.queryByText("Filter")).not.toBeInTheDocument(); expect( - screen.queryByLabelText("Show more filters"), + screen.queryByTestId("filters-visibility-control"), ).not.toBeInTheDocument(); expect(screen.queryByText("Summarize")).not.toBeInTheDocument(); expect( diff --git a/frontend/src/metabase/query_builder/components/view/ViewPill.jsx b/frontend/src/metabase/query_builder/components/view/ViewPill.tsx similarity index 71% rename from frontend/src/metabase/query_builder/components/view/ViewPill.jsx rename to frontend/src/metabase/query_builder/components/view/ViewPill.tsx index f647e4ebfcebf1bf3d82872879ae6c440ef15f47..62dad94b60b474cbfad9124bb6e9a959cd3b64b7 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewPill.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewPill.tsx @@ -1,9 +1,20 @@ -/* eslint-disable react/prop-types */ import cx from "classnames"; import { Icon } from "metabase/core/components/Icon"; +import type { IconName } from "metabase/core/components/Icon"; import { color as c, alpha } from "metabase/lib/colors"; -export default function ViewPill({ +export interface ViewPillProps { + className?: string; + color?: string; + invert?: boolean; + icon?: IconName; + children?: React.ReactNode; + style?: React.CSSProperties; + onClick?: React.MouseEventHandler<HTMLSpanElement>; + onRemove?: () => void; +} + +function ViewPill({ className, style = {}, color = c("brand"), @@ -13,7 +24,7 @@ export default function ViewPill({ onRemove, icon, ...props -}) { +}: ViewPillProps) { return ( <span {...props} @@ -49,3 +60,6 @@ export default function ViewPill({ </span> ); } + +// eslint-disable-next-line import/no-default-export +export default ViewPill;