diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js index 680cd0bea51db64e0b328cc26289f0c3f16d3075..e74666a5f889ddcee7bf93a85c1626857caf8db8 100644 --- a/frontend/src/metabase/dashboard/selectors.js +++ b/frontend/src/metabase/dashboard/selectors.js @@ -161,7 +161,7 @@ export const getParameters = createSelector( .value(); return { ...parameter, - field_id: fieldIds.length === 1 ? fieldIds[0] : null + field_ids: fieldIds } }) ) diff --git a/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx b/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx index 8ab0733679ce63b76cc13566999d748c895947b6..19a9d0efac7a601c15216e4220ec53dcfa9b7415 100644 --- a/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx +++ b/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx @@ -31,17 +31,21 @@ const DATE_WIDGETS = { } import { fetchFieldValues } from "metabase/redux/metadata"; -import { getParameterFieldValues } from "metabase/selectors/metadata"; - -const mapStateToProps = (state, props) => ({ - values: getParameterFieldValues(state, props), -}) +import { makeGetMergedParameterFieldValues } from "metabase/selectors/metadata"; + +const makeMapStateToProps = () => { + const getMergedParameterFieldValues = makeGetMergedParameterFieldValues(); + const mapStateToProps = (state, props) => ({ + values: getMergedParameterFieldValues(state, props), + }) + return mapStateToProps; +} const mapDispatchToProps = { fetchFieldValues } -@connect(mapStateToProps, mapDispatchToProps) +@connect(makeMapStateToProps, mapDispatchToProps) export default class ParameterValueWidget extends Component { static propTypes = { diff --git a/frontend/src/metabase/selectors/metadata.js b/frontend/src/metabase/selectors/metadata.js index 1b6bda217a42d1feccf73be2e8a207faacc9aec5..426d5d5e75ebf26b9661579b7b9bfd4e158765ca 100644 --- a/frontend/src/metabase/selectors/metadata.js +++ b/frontend/src/metabase/selectors/metadata.js @@ -1,6 +1,6 @@ /* @flow weak */ -import { createSelector } from "reselect"; +import { createSelector, createSelectorCreator, defaultMemoize } from "reselect"; import Metadata from "metabase-lib/lib/metadata/Metadata"; import Database from "metabase-lib/lib/metadata/Database"; @@ -9,7 +9,8 @@ import Field from "metabase-lib/lib/metadata/Field"; import Metric from "metabase-lib/lib/metadata/Metric"; import Segment from "metabase-lib/lib/metadata/Segment"; -import { getIn } from "icepick"; +import _ from "underscore"; +import { shallowEqual } from "recompose"; import { getFieldValues } from "metabase/lib/query/field"; import { @@ -17,6 +18,7 @@ import { getBreakouts, getAggregatorsWithFields } from "metabase/lib/schema_metadata"; +import { getIn } from "icepick"; export const getNormalizedMetadata = state => state.metadata; @@ -123,22 +125,75 @@ export const getSegments = createSelector( ({ segments }) => segments ); -// MISC - -export const getParameterFieldValues = (state, props) => { - const fieldValues = getFieldValues(getIn(state, ["metadata", "fields", props.parameter.field_id])); +// FIELD VALUES FOR DASHBOARD FILTERS / SQL QUESTION PARAMETERS + +// Returns a dictionary of field id:s mapped to matching field values +// Currently this assumes that you are passing the props of <ParameterValueWidget> which contain the +// `field_ids` array inside `parameter` prop. +const getParameterFieldValuesByFieldId = (state, props) => { + // NOTE Atte Keinänen 9/14/17: Reading the state directly instead of using `getFields` selector + // because `getMetadata` doesn't currently work with fields of public dashboards + return _.chain(getIn(state, ["metadata", "fields"])) + // SQL template tags provide `field_id` instead of `field_ids` + .pick(...(props.parameter.field_ids || [props.parameter.field_id])) + .mapObject(getFieldValues) + .value() +} - // HACK Atte Keinänen 7/27/17: Currently the field value analysis code only returns a single value for booleans, - // this will be addressed in analysis sync refactor +// Custom equality selector for checking if two field value dictionaries contain same fields and field values +// Currently we simply check if fields match and the lengths of field value arrays are equal which makes the comparison fast +// See https://github.com/reactjs/reselect#customize-equalitycheck-for-defaultmemoize +const createFieldValuesEqualSelector = createSelectorCreator(defaultMemoize, (a, b) => { +// TODO: Why can't we use plain shallowEqual, i.e. why the field value arrays change very often? + return shallowEqual(_.mapObject(a, (values) => values.length), _.mapObject(b, (values) => values.length)); +}) + +// HACK Atte Keinänen 7/27/17: Currently the field value analysis code only returns a single value for booleans, +// this will be addressed in analysis sync refactor +const patchBooleanFieldValues_HACK = (valueArray) => { const isBooleanFieldValues = - fieldValues && fieldValues.length === 1 && fieldValues[0] && typeof(fieldValues[0][0]) === "boolean" + valueArray && valueArray.length === 1 && valueArray[0] && typeof(valueArray[0][0]) === "boolean" + if (isBooleanFieldValues) { return [[true], [false]]; } else { - return fieldValues; + return valueArray; } } +// Merges the field values of fields linked to a parameter and removes duplicates +// We want that we have a distinct selector for each field id combination, and for that reason +// we export a method that creates a new selector; see +// https://github.com/reactjs/reselect#sharing-selectors-with-props-across-multiple-components +// TODO Atte Keinänen 7/20/17: Should we have any thresholds if the count of field values is high or we have many (>2?) fields? +export const makeGetMergedParameterFieldValues = () => { + return createFieldValuesEqualSelector(getParameterFieldValuesByFieldId, (fieldValues) => { + const fieldIds = Object.keys(fieldValues) + + if (fieldIds.length === 0) { + // If we have no fields for the parameter, don't return any field values + return []; + } else if (fieldIds.length === 1) { + // We have just a single field so we can return the field values almost as-is, + // only address the boolean bug for now + const singleFieldValues = fieldValues[fieldIds[0]] + return patchBooleanFieldValues_HACK(singleFieldValues); + } else { + // We have multiple fields, so let's merge their values to a single array + const sortedMergedValues = _.chain(Object.values(fieldValues)) + .flatten(true) + .sortBy(fieldValue => { + const valueIsRemapped = fieldValue.length === 2 + return valueIsRemapped ? fieldValue[1] : fieldValue[0] + }) + .value() + + // run the uniqueness comparision always against a non-remapped value + return _.uniq(sortedMergedValues, false, (fieldValue) => fieldValue[0]); + } + }); +} + // UTILS: // clone each object in the provided mapping of objects diff --git a/frontend/test/dashboard/dashboard.integ.spec.js b/frontend/test/dashboard/dashboard.integ.spec.js index 9b000c24a0d0a999e9c49dc02b6f685ab2badd08..b43f2fc00caa641908dd6a391fb49b738c696e1a 100644 --- a/frontend/test/dashboard/dashboard.integ.spec.js +++ b/frontend/test/dashboard/dashboard.integ.spec.js @@ -9,7 +9,7 @@ import { import { DashboardApi, PublicApi } from "metabase/services"; import * as Urls from "metabase/lib/urls"; -import { getParameterFieldValues } from "metabase/selectors/metadata"; +import { makeGetMergedParameterFieldValues } from "metabase/selectors/metadata"; import { ADD_PARAM_VALUES } from "metabase/redux/metadata"; import { mount } from "enzyme"; import { @@ -87,14 +87,14 @@ describe("Dashboard", () => { await store.dispatch(fetchDashboard('6e59cc97-3b6a-4bb6-9e7a-5efeee27e40f')); await store.waitForActions(ADD_PARAM_VALUES) - const fieldValues = await getParameterFieldValues(store.getState(), { parameter: { field_id: 21 }}); + const getMergedParameterFieldValues = makeGetMergedParameterFieldValues(); + const fieldValues = await getMergedParameterFieldValues(store.getState(), { parameter: { field_ids: [ 21 ] }}); expect(fieldValues).toEqual([["Doohickey"], ["Gadget"], ["Gizmo"], ["Widget"]]); }) }) }) // Converted from Selenium E2E test - describe("dashboard page", () => { let dashboardId = null; diff --git a/frontend/test/dashboard/selectors.unit.spec.js b/frontend/test/dashboard/selectors.unit.spec.js index f82406887e8e5009aae93c81e36b910ed05e6401..4f1b3ba5dc80fcaf1d61553cd31088cce79e0f50 100644 --- a/frontend/test/dashboard/selectors.unit.spec.js +++ b/frontend/test/dashboard/selectors.unit.spec.js @@ -42,7 +42,7 @@ describe("dashboard/selectors", () => { .value(); expect(getParameters(state)).toEqual([{ id: 1, - field_id: null + field_ids: [] }]); }) it("should not include field id with one mapping, no field id", () => { @@ -56,7 +56,7 @@ describe("dashboard/selectors", () => { .value(); expect(getParameters(state)).toEqual([{ id: 1, - field_id: null + field_ids: [] }]); }) it("should include field id with one mappings, with field id", () => { @@ -70,7 +70,7 @@ describe("dashboard/selectors", () => { .value(); expect(getParameters(state)).toEqual([{ id: 1, - field_id: 1 + field_ids: [1] }]); }) it("should include field id with two mappings, with same field id", () => { @@ -89,7 +89,7 @@ describe("dashboard/selectors", () => { .value(); expect(getParameters(state)).toEqual([{ id: 1, - field_id: 1 + field_ids: [1] }]); }) it("should include field id with two mappings, one with field id, one without", () => { @@ -108,10 +108,10 @@ describe("dashboard/selectors", () => { .value(); expect(getParameters(state)).toEqual([{ id: 1, - field_id: 1 + field_ids: [1] }]); }) - it("should not include field id with two mappings, with different field ids", () => { + it("should include all field ids with two mappings, with different field ids", () => { const state = chain(STATE) .assocIn(["dashboard", "dashboards", 0, "parameters", 0], { id: 1 }) .assocIn(["dashboard", "dashcards", 0, "parameter_mappings", 0], { @@ -127,7 +127,7 @@ describe("dashboard/selectors", () => { .value(); expect(getParameters(state)).toEqual([{ id: 1, - field_id: null + field_ids: [1, 2] }]); }) }) diff --git a/frontend/test/selectors/metadata.integ.spec.js b/frontend/test/selectors/metadata.integ.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d2b8c43480ed863615d41c2e8342030b0ae0bdfb --- /dev/null +++ b/frontend/test/selectors/metadata.integ.spec.js @@ -0,0 +1,76 @@ +import { createTestStore, login } from "__support__/integrated_tests"; +import { + deleteFieldDimension, fetchTableMetadata, + updateFieldDimension, + updateFieldValues, +} from "metabase/redux/metadata"; +import { makeGetMergedParameterFieldValues } from "metabase/selectors/metadata"; + +const REVIEW_RATING_ID = 33; +const PRODUCT_CATEGORY_ID = 21; + +// NOTE Atte Keinänen 9/14/17: A hybrid of an integration test and a method unit test +// I wanted to use a real state tree and have a realistic field remapping scenario + +describe('makeGetMergedParameterFieldValues', () => { + beforeAll(async () => { + await login(); + + // add remapping + const store = await createTestStore() + + await store.dispatch(updateFieldDimension(REVIEW_RATING_ID, { + type: "internal", + name: "Rating Description", + human_readable_field_id: null + })); + await store.dispatch(updateFieldValues(REVIEW_RATING_ID, [ + [1, 'Awful'], [2, 'Unpleasant'], [3, 'Meh'], [4, 'Enjoyable'], [5, 'Perfecto'] + ])); + }) + + afterAll(async () => { + const store = await createTestStore() + + await store.dispatch(deleteFieldDimension(REVIEW_RATING_ID)); + await store.dispatch(updateFieldValues(REVIEW_RATING_ID, [ + [1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5'] + ])); + }) + + it("should return empty array if no field ids", async () => { + const store = await createTestStore() + await store.dispatch(fetchTableMetadata(3)) + + const getMergedParameterFieldValues = makeGetMergedParameterFieldValues() + expect( + getMergedParameterFieldValues(store.getState(), { parameter: { field_ids: [] } }) + ).toEqual([]) + + }) + + it("should return the original field values if a single field id", async () => { + const store = await createTestStore() + await store.dispatch(fetchTableMetadata(3)) + + const getMergedParameterFieldValues = makeGetMergedParameterFieldValues() + expect( + getMergedParameterFieldValues(store.getState(), { parameter: { field_ids: [PRODUCT_CATEGORY_ID] } }) + ).toEqual([ [ 'Doohickey' ], [ 'Gadget' ], [ 'Gizmo' ], [ 'Widget' ] ]) + }) + + it("should merge and sort field values if multiple field ids", async () => { + const store = await createTestStore() + await store.dispatch(fetchTableMetadata(3)) + await store.dispatch(fetchTableMetadata(4)) + + const getMergedParameterFieldValues = makeGetMergedParameterFieldValues() + expect( + getMergedParameterFieldValues(store.getState(), { parameter: { field_ids: [PRODUCT_CATEGORY_ID, REVIEW_RATING_ID] } }) + ).toEqual([ + [1, 'Awful'], ['Doohickey'], [4, 'Enjoyable'], ['Gadget'], + ['Gizmo'], [3, 'Meh'], [5, 'Perfecto'], [2, 'Unpleasant'], ['Widget'] + ]) + }) +}) +