diff --git a/frontend/src/metabase-lib/lib/metadata/Field.ts b/frontend/src/metabase-lib/lib/metadata/Field.ts index 7bb47cc2e120675d3678bb6b1158c594ce3814d7..67a939edfd7ffaa54cb8c54b1c439fab7bdc9ba5 100644 --- a/frontend/src/metabase-lib/lib/metadata/Field.ts +++ b/frontend/src/metabase-lib/lib/metadata/Field.ts @@ -50,6 +50,8 @@ class FieldInner extends Base { base_type: string | null; table?: Table; target?: Field; + has_field_values?: "list" | "search" | "none"; + values: any[]; getId() { if (Array.isArray(this.id)) { diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.tsx b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.tsx index b924c73e3f4d4ac10a04b9d4af75619ac40daf74..ee0fe5b1499038c26e90aea740015fa332a92fe5 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.tsx +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.tsx @@ -7,6 +7,8 @@ import { isBoolean } from "metabase/lib/schema_metadata"; import { BooleanPickerCheckbox } from "metabase/query_builder/components/filters/pickers/BooleanPicker"; import { BulkFilterSelect } from "../BulkFilterSelect"; +import { InlineCategoryPicker } from "../InlineCategoryPicker"; +import { SEMANTIC_FIELD_FILTERS, BASE_FIELD_FILTERS } from "./constants"; export interface BulkFilterItemProps { query: StructuredQuery; @@ -25,9 +27,21 @@ export const BulkFilterItem = ({ onChangeFilter, onRemoveFilter, }: BulkFilterItemProps): JSX.Element => { - const fieldType = useMemo(() => dimension.field().base_type ?? "", [ - dimension, - ]); + const fieldType = useMemo(() => { + const field = dimension.field(); + + if (BASE_FIELD_FILTERS.includes(field.base_type ?? "")) { + return field.base_type; + } + + if (field.has_field_values === "list") { + return "type/Category"; + } + + if (SEMANTIC_FIELD_FILTERS.includes(field.semantic_type ?? "")) { + return field.semantic_type; + } + }, [dimension]); const newFilter = useMemo(() => getNewFilter(query, dimension), [ query, @@ -55,6 +69,17 @@ export const BulkFilterItem = ({ onFilterChange={handleChange} /> ); + case "type/Category": + return ( + <InlineCategoryPicker + query={query} + filter={filter} + newFilter={newFilter} + dimension={dimension} + onChange={handleChange} + onClear={handleClear} + /> + ); default: return ( <BulkFilterSelect diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.unit.spec.tsx b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.unit.spec.tsx index 9cca900f763fb0c785342ac6776c7d9c6403936e..82d58534b38b9a4d661ba89f05850821905df01e 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.unit.spec.tsx +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.unit.spec.tsx @@ -4,6 +4,8 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { metadata } from "__support__/sample_database_fixture"; +import { getStore } from "__support__/entities-store"; +import { Provider } from "react-redux"; import Field from "metabase-lib/lib/metadata/Field"; import Filter from "metabase-lib/lib/queries/structured/Filter"; @@ -16,7 +18,7 @@ const booleanField = new Field({ semantic_type: "", table_id: 8, name: "bool", - has_field_values: "list", + has_field_values: "none", dimensions: {}, dimension_options: [], effective_type: "type/Boolean", @@ -30,7 +32,7 @@ const intField = new Field({ semantic_type: "", table_id: 8, name: "int_num", - has_field_values: "list", + has_field_values: "none", dimensions: {}, dimension_options: [], effective_type: "type/Integer", @@ -44,7 +46,7 @@ const floatField = new Field({ semantic_type: "", table_id: 8, name: "float_num", - has_field_values: "list", + has_field_values: "none", dimensions: {}, dimension_options: [], effective_type: "type/Float", @@ -53,9 +55,25 @@ const floatField = new Field({ metadata, }); +const categoryField = new Field({ + database_type: "test", + semantic_type: "", + table_id: 8, + name: "category_string", + has_field_values: "list", + values: ["Michaelangelo", "Donatello", "Raphael", "Leonardo"], + dimensions: {}, + dimension_options: [], + effective_type: "type/Float", + id: 137, + base_type: "type/Float", + metadata, +}); + metadata.fields[booleanField.id] = booleanField; metadata.fields[intField.id] = intField; metadata.fields[floatField.id] = floatField; +metadata.fields[categoryField.id] = categoryField; const card = { dataset_query: { @@ -74,6 +92,7 @@ const query = question.query(); const booleanDimension = booleanField.dimension(); const floatDimension = floatField.dimension(); const intDimension = intField.dimension(); +const categoryDimension = categoryField.dimension(); describe("BulkFilterItem", () => { it("renders a boolean picker for a boolean filter", () => { @@ -149,4 +168,28 @@ describe("BulkFilterItem", () => { "float_num", ); }); + + it("renders a category picker for category type", () => { + const testFilter = new Filter( + ["=", ["field", categoryField.id, null], "Donatello"], + null, + query, + ); + const changeSpy = jest.fn(); + const store = getStore(); + + render( + <Provider store={store}> + <BulkFilterItem + query={query} + filter={testFilter} + dimension={categoryDimension} + onAddFilter={changeSpy} + onChangeFilter={changeSpy} + onRemoveFilter={changeSpy} + /> + </Provider>, + ); + screen.getByTestId("category-picker"); + }); }); diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/constants.ts b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/constants.ts index a91a55c88c8f0a6678c4ea67c1bfee82da706db1..e72728176bac5cb2af363d7dc4bd66f7e35f259e 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/constants.ts +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/constants.ts @@ -1 +1,3 @@ -export const INLINE_FIELD_TYPES = ["type/Boolean"]; +export const BASE_FIELD_FILTERS = ["type/Boolean"]; + +export const SEMANTIC_FIELD_FILTERS = ["type/FK", "type/PK", "type/Category"]; diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterList/BulkFilterList.styled.tsx b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterList/BulkFilterList.styled.tsx index 0dfe24a276316279d455a85d75d8c34909d27311..eae1d6b487000c211741057c0f291bf81cb2e84f 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterList/BulkFilterList.styled.tsx +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterList/BulkFilterList.styled.tsx @@ -8,18 +8,14 @@ export const ListRoot = styled.div` `; export const ListRow = styled.div` - display: flex; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 0.375rem 0; `; export const ListRowLabel = styled(Ellipsified)` - flex: 1 1 0; - margin: 0.625rem 1rem 0.625rem 0; + padding: 0.625rem 1rem 0.625rem 0; color: ${color("black")}; line-height: 1rem; font-weight: bold; `; - -export const ListRowContent = styled.div` - flex: 1 1 0; -`; diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterList/BulkFilterList.tsx b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterList/BulkFilterList.tsx index 34c8c898d90883229a2a8d924f9fc3558408ce3e..8c195a13ab85d1e4a1a93f1fc8abf524007e6e69 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterList/BulkFilterList.tsx +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterList/BulkFilterList.tsx @@ -12,12 +12,7 @@ import { ModalDivider } from "../BulkFilterModal/BulkFilterModal.styled"; import Filter from "metabase-lib/lib/queries/structured/Filter"; import { BulkFilterItem } from "../BulkFilterItem"; import { SegmentFilterSelect } from "../BulkFilterSelect"; -import { - ListRoot, - ListRow, - ListRowContent, - ListRowLabel, -} from "./BulkFilterList.styled"; +import { ListRoot, ListRow, ListRowLabel } from "./BulkFilterList.styled"; import { sortDimensions } from "./utils"; export interface BulkFilterListProps { @@ -91,7 +86,12 @@ const BulkFilterListItem = ({ onRemoveFilter, }: BulkFilterListItemProps): JSX.Element => { const options = useMemo(() => { - return filters.filter(f => f.dimension()?.isSameBaseDimension(dimension)); + const filtersForThisDimension = filters.filter(f => + f.dimension()?.isSameBaseDimension(dimension), + ); + return filtersForThisDimension.length + ? filtersForThisDimension + : [undefined]; }, [filters, dimension]); return ( @@ -99,28 +99,17 @@ const BulkFilterListItem = ({ <ListRowLabel data-testid="dimension-filter-label"> {dimension.displayName()} </ListRowLabel> - <ListRowContent> - {options.map((filter, index) => ( - <BulkFilterItem - key={index} - query={query} - filter={filter} - dimension={dimension} - onAddFilter={onAddFilter} - onChangeFilter={onChangeFilter} - onRemoveFilter={onRemoveFilter} - /> - ))} - {!options.length && ( - <BulkFilterItem - query={query} - dimension={dimension} - onAddFilter={onAddFilter} - onChangeFilter={onChangeFilter} - onRemoveFilter={onRemoveFilter} - /> - )} - </ListRowContent> + {options.map((filter, index) => ( + <BulkFilterItem + key={index} + query={query} + filter={filter} + dimension={dimension} + onAddFilter={onAddFilter} + onChangeFilter={onChangeFilter} + onRemoveFilter={onRemoveFilter} + /> + ))} </ListRow> ); }; @@ -143,7 +132,7 @@ const SegmentListItem = ({ <> <ListRow> <ListRowLabel>{t`Segments`}</ListRowLabel> - <ListRowContent> + <> <SegmentFilterSelect query={query} segments={segments} @@ -151,7 +140,7 @@ const SegmentListItem = ({ onRemoveFilter={onRemoveFilter} onClearSegments={onClearSegments} /> - </ListRowContent> + </> </ListRow> <ModalDivider marginY="0.5rem" /> </> diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterSelect/BulkFilterSelect.styled.tsx b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterSelect/BulkFilterSelect.styled.tsx index a670e83e8cc25e3b0f8f4ca841b4934c6ade6d1b..c49cf9a5de0800662518bcc0248c707f928d335b 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterSelect/BulkFilterSelect.styled.tsx +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterSelect/BulkFilterSelect.styled.tsx @@ -4,6 +4,7 @@ import FilterPopover from "../../FilterPopover"; import Select from "metabase/core/components/Select"; export const SelectFilterButton = styled(SelectButton)` + grid-column: 2; min-height: 2.25rem; &:not(:first-of-type) { diff --git a/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/InlineCategoryPicker.styled.tsx b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/InlineCategoryPicker.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ed4242330182ebb35b4f0e9459a89f5516caa347 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/InlineCategoryPicker.styled.tsx @@ -0,0 +1,27 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import { space } from "metabase/styled-components/theme"; + +import LoadingSpinner from "metabase/components/LoadingSpinner"; + +export const Loading = styled(LoadingSpinner)` + margin: ${space(1)} 0; + color: ${color("brand")}; +`; + +export const PickerContainer = styled.div` + grid-column: span 2; + margin: ${space(2)} 0; + padding-bottom: ${space(2)}; + font-weight: bold; + border-bottom: 1px solid ${color("border")}; +`; + +export const PickerGrid = styled.div` + width: 100%; + display: grid; + columns: 2; + align-items: center; + grid-template-columns: repeat(3, 1fr); + gap: ${space(2)}; +`; diff --git a/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/InlineCategoryPicker.tsx b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/InlineCategoryPicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..793f4c123b0c69a5812fd88191f6535e375e920d --- /dev/null +++ b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/InlineCategoryPicker.tsx @@ -0,0 +1,154 @@ +import React, { useState, useEffect } from "react"; +import { connect } from "react-redux"; +import { t } from "ttag"; + +import Filter from "metabase-lib/lib/queries/structured/Filter"; +import Fields from "metabase/entities/fields"; +import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; +import Dimension from "metabase-lib/lib/Dimension"; +import { useSafeAsyncFunction } from "metabase/hooks/use-safe-async-function"; + +import Warnings from "metabase/query_builder/components/Warnings"; +import Checkbox from "metabase/core/components/CheckBox"; + +import { MAX_INLINE_CATEGORIES } from "./constants"; +import { + PickerContainer, + PickerGrid, + Loading, +} from "./InlineCategoryPicker.styled"; +import { BulkFilterSelect } from "../BulkFilterSelect"; + +const mapStateToProps = (state: any, props: any) => { + const fieldId = props.dimension?.field?.()?.id; + const fieldValues = + fieldId != null + ? Fields.selectors.getFieldValues(state, { + entityId: fieldId, + }) + : []; + return { fieldValues }; +}; + +const mapDispatchToProps = { + fetchFieldValues: Fields.actions.fetchFieldValues, +}; + +interface InlineCategoryPickerProps { + query: StructuredQuery; + filter?: Filter; + newFilter: Filter; + dimension: Dimension; + fieldValues: any[]; + fetchFieldValues: ({ id }: { id: number }) => Promise<any>; + onChange: (newFilter: Filter) => void; + onClear: () => void; +} + +export function InlineCategoryPickerComponent({ + query, + filter, + newFilter, + dimension, + fieldValues, + fetchFieldValues, + onChange, + onClear, +}: InlineCategoryPickerProps) { + const safeFetchFieldValues = useSafeAsyncFunction(fetchFieldValues); + const shouldFetchFieldValues = !dimension?.field()?.hasFieldValues(); + const [isLoading, setIsLoading] = useState(shouldFetchFieldValues); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + if (!shouldFetchFieldValues) { + setIsLoading(false); + return; + } + const field = dimension.field(); + safeFetchFieldValues({ id: field.id }) + .then(() => { + setIsLoading(false); + }) + .catch(() => { + setHasError(true); + }); + }, [dimension, safeFetchFieldValues, shouldFetchFieldValues]); + + if (hasError) { + return ( + <Warnings + warnings={[ + t`There was an error loading the field values for this field`, + ]} + /> + ); + } + + if (isLoading) { + return <Loading size={20} />; + } + + if (fieldValues.length <= MAX_INLINE_CATEGORIES) { + return ( + <SimpleCategoryFilterPicker + filter={filter ?? newFilter} + onChange={onChange} + options={fieldValues.flat()} + /> + ); + } + + return ( + <BulkFilterSelect + query={query} + filter={filter} + dimension={dimension} + handleChange={onChange} + handleClear={onClear} + /> + ); +} + +interface SimpleCategoryFilterPickerProps { + filter: Filter; + options: (string | number)[]; + onChange: (newFilter: Filter) => void; +} + +export function SimpleCategoryFilterPicker({ + filter, + options, + onChange, +}: SimpleCategoryFilterPickerProps) { + const filterValues = filter.arguments().filter(Boolean); + + const handleChange = (option: string | number, checked: boolean) => { + const newArgs = checked + ? [...filterValues, option] + : filterValues.filter(filterValue => filterValue !== option); + + onChange(filter.setArguments(newArgs)); + }; + + return ( + <PickerContainer data-testid="category-picker"> + <PickerGrid> + {options.map((option: string | number) => ( + <Checkbox + key={option.toString()} + checked={filterValues.includes(option)} + onChange={e => handleChange(option, e.target.checked)} + checkedColor="accent2" + label={option.toString()} + /> + ))} + </PickerGrid> + </PickerContainer> + ); +} + +export const InlineCategoryPicker = connect( + mapStateToProps, + mapDispatchToProps, +)(InlineCategoryPickerComponent); diff --git a/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/InlineCategoryPicker.unit.spec.tsx b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/InlineCategoryPicker.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..89ebac2e8649fede6c3a084ec355d54f4ccabddc --- /dev/null +++ b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/InlineCategoryPicker.unit.spec.tsx @@ -0,0 +1,341 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { metadata } from "__support__/sample_database_fixture"; + +import Field from "metabase-lib/lib/metadata/Field"; +import Filter from "metabase-lib/lib/queries/structured/Filter"; +import Question from "metabase-lib/lib/Question"; +import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; + +import { InlineCategoryPickerComponent } from "./InlineCategoryPicker"; +import { MAX_INLINE_CATEGORIES } from "./constants"; + +const smallCategoryField = new Field({ + database_type: "test", + semantic_type: "type/Category", + effective_type: "type/Text", + base_type: "type/Text", + table_id: 8, + name: "small_category_field", + has_field_values: "list", + values: [["Michaelangelo"], ["Donatello"], ["Raphael"], ["Leonardo"]], + dimensions: {}, + dimension_options: [], + id: 137, + metadata, +}); + +// we want to make sure we always get enough unique field values +// even if we change MAX_INLINE_CATEGORIES +const turtleFactory = () => { + const name = ["Michaelangelo", "Donatello", "Raphael", "Leonardo"][ + Math.floor(Math.random() * 4) + ]; + return [`${name}_${Math.round(Math.random() * 100000)}`]; +}; + +const largeCategoryField = new Field({ + database_type: "test", + semantic_type: "type/Category", + effective_type: "type/Text", + base_type: "type/Text", + table_id: 8, + name: "large_category_field", + has_field_values: "list", + values: new Array(MAX_INLINE_CATEGORIES + 1).fill(null).map(turtleFactory), + dimensions: {}, + dimension_options: [], + id: 138, + metadata, +}); + +const emptyCategoryField = new Field({ + database_type: "test", + semantic_type: "type/Category", + effective_type: "type/Text", + base_type: "type/Text", + table_id: 8, + name: "empty_category_field", + has_field_values: "list", + values: [], + dimensions: {}, + dimension_options: [], + id: 139, + metadata, +}); + +// @ts-ignore +metadata.fields[smallCategoryField.id] = smallCategoryField; +// @ts-ignore +metadata.fields[largeCategoryField.id] = largeCategoryField; +// @ts-ignore +metadata.fields[emptyCategoryField.id] = emptyCategoryField; + +const card = { + dataset_query: { + database: 5, + query: { + "source-table": 8, + }, + type: "query", + }, + display: "table", + visualization_settings: {}, +}; + +const question = new Question(card, metadata); +const query = question.query() as StructuredQuery; +const smallDimension = smallCategoryField.dimension(); +const largeDimension = largeCategoryField.dimension(); +const emptyDimension = emptyCategoryField.dimension(); + +describe("InlineCategoryPicker", () => { + it("should render an inline category picker", () => { + const testFilter = new Filter( + ["=", ["field", smallCategoryField.id, null], undefined], + null, + query, + ); + const changeSpy = jest.fn(); + const fetchSpy = jest.fn(); + + render( + <InlineCategoryPickerComponent + query={query} + filter={testFilter} + newFilter={testFilter} + onChange={changeSpy} + fieldValues={smallCategoryField.values} + fetchFieldValues={fetchSpy} + dimension={smallDimension} + onClear={changeSpy} + />, + ); + + screen.getByTestId("category-picker"); + smallCategoryField.values.forEach(([value]) => { + screen.getByText(value); + }); + }); + + it("should render a loading spinner while loading", async () => { + const testFilter = new Filter( + ["=", ["field", emptyCategoryField.id, null], undefined], + null, + query, + ); + const changeSpy = jest.fn(); + const fetchSpy = jest.fn(); + + render( + <InlineCategoryPickerComponent + query={query} + filter={testFilter} + newFilter={testFilter} + onChange={changeSpy} + fieldValues={emptyCategoryField.values} + fetchFieldValues={fetchSpy} + dimension={emptyDimension} + onClear={changeSpy} + />, + ); + screen.getByTestId("loading-spinner"); + await waitFor(() => expect(fetchSpy).toHaveBeenCalled()); + }); + + it("should render a warning message on api failure", async () => { + const testFilter = new Filter( + ["=", ["field", emptyCategoryField.id, null], undefined], + null, + query, + ); + const changeSpy = jest.fn(); + const fetchSpy = jest.fn(); + + render( + <InlineCategoryPickerComponent + query={query} + filter={testFilter} + newFilter={testFilter} + onChange={changeSpy} + fieldValues={emptyCategoryField.values} + fetchFieldValues={fetchSpy} + dimension={emptyDimension} + onClear={changeSpy} + />, + ); + await waitFor(() => expect(fetchSpy).toHaveBeenCalled()); + screen.getByLabelText("warning icon"); + }); + + it(`should render up to ${MAX_INLINE_CATEGORIES} checkboxes`, () => { + const testFilter = new Filter( + ["=", ["field", smallCategoryField.id, null], undefined], + null, + query, + ); + const changeSpy = jest.fn(); + const fetchSpy = jest.fn(); + + render( + <InlineCategoryPickerComponent + query={query} + filter={testFilter} + newFilter={testFilter} + onChange={changeSpy} + fieldValues={smallCategoryField.values} + fetchFieldValues={fetchSpy} + dimension={smallDimension} + onClear={changeSpy} + />, + ); + + screen.getByTestId("category-picker"); + smallCategoryField.values.forEach(([value]) => { + screen.getByText(value); + }); + }); + + it(`should not render more than ${MAX_INLINE_CATEGORIES} checkboxes`, () => { + const testFilter = new Filter( + ["=", ["field", largeCategoryField.id, null], undefined], + null, + query, + ); + const changeSpy = jest.fn(); + const fetchSpy = jest.fn(); + + render( + <InlineCategoryPickerComponent + query={query} + filter={testFilter} + newFilter={testFilter} + onChange={changeSpy} + fieldValues={largeCategoryField.values} + fetchFieldValues={fetchSpy} + dimension={largeDimension} + onClear={changeSpy} + />, + ); + + expect(screen.queryByTestId("category-picker")).not.toBeInTheDocument(); + // should render general purpose picker instead + screen.getByTestId("select-button"); + }); + + it("should load existing filter selections", () => { + const testFilter = new Filter( + ["=", ["field", smallCategoryField.id, null], "Donatello", "Leonardo"], + null, + query, + ); + const changeSpy = jest.fn(); + const fetchSpy = jest.fn(); + + render( + <InlineCategoryPickerComponent + query={query} + filter={testFilter} + newFilter={testFilter} + onChange={changeSpy} + fieldValues={smallCategoryField.values} + fetchFieldValues={fetchSpy} + dimension={smallDimension} + onClear={changeSpy} + />, + ); + + screen.getByTestId("category-picker"); + expect(screen.getByLabelText("Donatello")).toBeChecked(); + expect(screen.getByLabelText("Leonardo")).toBeChecked(); + expect(screen.getByLabelText("Raphael")).not.toBeChecked(); + expect(screen.getByLabelText("Michaelangelo")).not.toBeChecked(); + }); + + it("should save a filter based on selection", () => { + const testFilter = new Filter( + ["=", ["field", smallCategoryField.id, null], undefined], + null, + query, + ); + const changeSpy = jest.fn(); + const fetchSpy = jest.fn(); + + render( + <InlineCategoryPickerComponent + query={query} + filter={testFilter} + newFilter={testFilter} + onChange={changeSpy} + fieldValues={smallCategoryField.values} + fetchFieldValues={fetchSpy} + dimension={smallDimension} + onClear={changeSpy} + />, + ); + + screen.getByTestId("category-picker"); + userEvent.click(screen.getByLabelText("Raphael")); + expect(changeSpy.mock.calls.length).toBe(1); + expect(changeSpy.mock.calls[0][0]).toEqual([ + "=", + ["field", 137, null], + "Raphael", + ]); + }); + + it("should fetch field values data if its not already loaded", async () => { + const testFilter = new Filter( + ["=", ["field", emptyCategoryField.id, null], undefined], + null, + query, + ); + const changeSpy = jest.fn(); + const fetchSpy = jest.fn(); + + render( + <InlineCategoryPickerComponent + query={query} + filter={testFilter} + newFilter={testFilter} + onChange={changeSpy} + fieldValues={emptyCategoryField.values} + fetchFieldValues={fetchSpy} + dimension={emptyDimension} + onClear={changeSpy} + />, + ); + await waitFor(() => expect(fetchSpy).toHaveBeenCalled()); + + expect(fetchSpy.mock.calls[0][0]).toEqual({ id: emptyCategoryField.id }); + }); + + it("should not fetch field values data if it is already present", async () => { + const testFilter = new Filter( + ["=", ["field", largeCategoryField.id, null], undefined], + null, + query, + ); + const changeSpy = jest.fn(); + const fetchSpy = jest.fn(); + + render( + <InlineCategoryPickerComponent + query={query} + filter={testFilter} + newFilter={testFilter} + onChange={changeSpy} + fieldValues={largeCategoryField.values} + fetchFieldValues={fetchSpy} + dimension={largeDimension} + onClear={changeSpy} + />, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/constants.ts b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..79cbbff23ce9b7ad79fdcdac423560553fc10168 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/constants.ts @@ -0,0 +1 @@ +export const MAX_INLINE_CATEGORIES = 12; diff --git a/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/index.ts b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6c85ed4e85e0e425127b55708c1471a7d983eeb --- /dev/null +++ b/frontend/src/metabase/query_builder/components/filters/modals/InlineCategoryPicker/index.ts @@ -0,0 +1 @@ +export * from "./InlineCategoryPicker"; diff --git a/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js b/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js index 3e74148596bf475fc5c6ff8d999e7f80dc13bc89..b41150fe3c72b7a1efb1ba796f6772716cf89366 100644 --- a/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js +++ b/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js @@ -8,7 +8,7 @@ import { import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; +const { ORDERS_ID, ORDERS, PEOPLE_ID } = SAMPLE_DATABASE; const rawQuestionDetails = { dataset_query: { @@ -20,6 +20,16 @@ const rawQuestionDetails = { }, }; +const peopleQuestion = { + dataset_query: { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": PEOPLE_ID, + }, + }, +}; + const filteredQuestionDetails = { dataset_query: { database: SAMPLE_DB_ID, @@ -136,15 +146,7 @@ describe("scenarios > filters > bulk filtering", () => { modal().within(() => { cy.findByText("Product").click(); - cy.findByLabelText("Category").click(); - }); - - popover().within(() => { - cy.findByText("Gadget").click(); - cy.button("Add filter").click(); - }); - - modal().within(() => { + cy.findByLabelText("Gadget").click(); cy.button("Apply").click(); cy.wait("@dataset"); }); @@ -401,6 +403,42 @@ describe("scenarios > filters > bulk filtering", () => { }); }); }); + describe("category filters", () => { + beforeEach(() => { + visitQuestionAdhoc(peopleQuestion); + openFilterModal(); + }); + + it("should show inline category picker for referral source", () => { + modal().within(() => { + cy.findByText("Affiliate").click(); + cy.button("Apply").click(); + cy.wait("@dataset"); + }); + + cy.findByText("Source is Affiliate").should("be.visible"); + cy.findByText("Showing 506 rows").should("be.visible"); + }); + + it("should not show inline category picker for state", () => { + modal().within(() => { + cy.findByLabelText("State").click(); + }); + + popover().within(() => { + cy.findByText("AZ").click(); + cy.button("Add filter").click(); + }); + + modal().within(() => { + cy.button("Apply").click(); + cy.wait("@dataset"); + }); + + cy.findByText("State is AZ").should("be.visible"); + cy.findByText("Showing 20 rows").should("be.visible"); + }); + }); }); const modal = () => {