diff --git a/superset/assets/spec/javascripts/components/AnchorLink_spec.jsx b/superset/assets/spec/javascripts/components/AnchorLink_spec.jsx index 42811094592d..8a5e803ac32a 100644 --- a/superset/assets/spec/javascripts/components/AnchorLink_spec.jsx +++ b/superset/assets/spec/javascripts/components/AnchorLink_spec.jsx @@ -28,6 +28,19 @@ describe('AnchorLink', () => { anchorLinkId: 'CHART-123', }; + beforeEach(() => { + global.window = Object.create(window); + Object.defineProperty(window, 'location', { + value: { + hash: '#' + props.anchorLinkId, + }, + }); + }); + + afterEach(() => { + delete global.window.location.value; + }); + it('should scroll the AnchorLink into view upon mount', () => { const callback = sinon.spy(); const clock = sinon.useFakeTimers(); @@ -35,11 +48,7 @@ describe('AnchorLink', () => { scrollIntoView: callback, }); - const wrapper = shallow(); - wrapper.instance().getLocationHash = () => (props.anchorLinkId); - wrapper.update(); - - wrapper.instance().componentDidMount(); + shallow(); clock.tick(2000); expect(callback.callCount).toEqual(1); stub.restore(); diff --git a/superset/assets/spec/javascripts/dashboard/actions/dashboardState_spec.js b/superset/assets/spec/javascripts/dashboard/actions/dashboardState_spec.js index 8ba499e32107..c2263c2df321 100644 --- a/superset/assets/spec/javascripts/dashboard/actions/dashboardState_spec.js +++ b/superset/assets/spec/javascripts/dashboard/actions/dashboardState_spec.js @@ -19,17 +19,29 @@ import sinon from 'sinon'; import { SupersetClient } from '@superset-ui/connection'; -import { saveDashboardRequest } from '../../../../src/dashboard/actions/dashboardState'; +import { + removeSliceFromDashboard, + saveDashboardRequest, +} from '../../../../src/dashboard/actions/dashboardState'; +import { REMOVE_FILTER } from '../../../../src/dashboard/actions/dashboardFilters'; import { UPDATE_COMPONENTS_PARENTS_LIST } from '../../../../src/dashboard/actions/dashboardLayout'; +import { + filterId, + sliceEntitiesForDashboard as sliceEntities, +} from '../fixtures/mockSliceEntities'; +import { emptyFilters } from '../fixtures/mockDashboardFilters'; import mockDashboardData from '../fixtures/mockDashboardData'; import { DASHBOARD_GRID_ID } from '../../../../src/dashboard/util/constants'; describe('dashboardState actions', () => { const mockState = { dashboardState: { + sliceIds: [filterId], hasUnsavedChanges: true, }, dashboardInfo: {}, + sliceEntities, + dashboardFilters: emptyFilters, dashboardLayout: { past: [], present: mockDashboardData.positions, @@ -97,4 +109,14 @@ describe('dashboardState actions', () => { ); }); }); + + it('should dispatch removeFilter if a removed slice is a filter_box', () => { + const { getState, dispatch } = setup(mockState); + const thunk = removeSliceFromDashboard(filterId); + thunk(dispatch, getState); + + const removeFilter = dispatch.getCall(0).args[0]; + removeFilter(dispatch, getState); + expect(dispatch.getCall(3).args[0].type).toBe(REMOVE_FILTER); + }); }); diff --git a/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx index 16dc33dea052..ef81bd47c70c 100644 --- a/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx @@ -67,7 +67,7 @@ describe('DashboardBuilder', () => { setColorSchemeAndUnsavedChanges() {}, colorScheme: undefined, handleComponentDrop() {}, - toggleBuilderPane() {}, + setDirectPathToChild: sinon.spy(), }; function setup(overrideProps, useProvider = false, store = mockStore) { @@ -171,7 +171,7 @@ describe('DashboardBuilder', () => { expect(wrapper.find(BuilderComponentPane)).toHaveLength(1); }); - it('should change tabs if a top-level Tab is clicked', () => { + it('should change redux state if a top-level Tab is clicked', () => { const wrapper = setup( { dashboardLayout: layoutWithTabs }, true, @@ -185,6 +185,6 @@ describe('DashboardBuilder', () => { .at(1) .simulate('click'); - expect(wrapper.find(TabContainer).prop('activeKey')).toBe(1); + expect(props.setDirectPathToChild.callCount).toBe(1); }); }); diff --git a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx index 1f6fa6d9ae64..22ed6d6169a7 100644 --- a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx @@ -46,6 +46,7 @@ describe('Dashboard', () => { dashboardState, dashboardInfo, charts: chartQueries, + filters: {}, slices: sliceEntities.slices, datasources, layout: dashboardLayout.present, @@ -60,27 +61,23 @@ describe('Dashboard', () => { return wrapper; } + const OVERRIDE_FILTERS = { + 1: { region: [] }, + 2: { country_name: ['USA'] }, + 3: { region: [], country_name: ['USA'] }, + }; + it('should render a DashboardBuilder', () => { const wrapper = setup(); expect(wrapper.find(DashboardBuilder)).toHaveLength(1); }); describe('refreshExcept', () => { - const overrideDashboardState = { - ...dashboardState, - filters: { - 1: { region: [] }, - 2: { country_name: ['USA'] }, - 3: { region: [], country_name: ['USA'] }, - }, - refresh: true, - }; - const overrideDashboardInfo = { ...dashboardInfo, metadata: { ...dashboardInfo.metadata, - filter_immune_slice_fields: { [chartQueries[chartId].id]: ['region'] }, + filterImmuneSliceFields: { [chartQueries[chartId].id]: ['region'] }, }, }; @@ -108,14 +105,14 @@ describe('Dashboard', () => { expect(spy.callCount).toBe(Object.keys(overrideCharts).length - 1); }); - it('should not call triggerQuery for filter_immune_slices', () => { + it('should not call triggerQuery for filterImmuneSlices', () => { const wrapper = setup({ charts: overrideCharts, dashboardInfo: { ...dashboardInfo, metadata: { ...dashboardInfo.metadata, - filter_immune_slices: Object.keys(overrideCharts).map(id => + filterImmuneSlices: Object.keys(overrideCharts).map(id => Number(id), ), }, @@ -127,9 +124,9 @@ describe('Dashboard', () => { expect(spy.callCount).toBe(0); }); - it('should not call triggerQuery for filter_immune_slice_fields', () => { + it('should not call triggerQuery for filterImmuneSliceFields', () => { const wrapper = setup({ - dashboardState: overrideDashboardState, + filters: OVERRIDE_FILTERS, dashboardInfo: overrideDashboardInfo, }); const spy = sinon.spy(props.actions, 'triggerQuery'); @@ -140,7 +137,7 @@ describe('Dashboard', () => { it('should call triggerQuery if filter has more filter-able fields', () => { const wrapper = setup({ - dashboardState: overrideDashboardState, + filters: OVERRIDE_FILTERS, dashboardInfo: overrideDashboardInfo, }); const spy = sinon.spy(props.actions, 'triggerQuery'); @@ -187,117 +184,78 @@ describe('Dashboard', () => { }); describe('componentDidUpdate', () => { - const overrideDashboardState = { - ...dashboardState, - filters: { - 1: { region: [] }, - 2: { country_name: ['USA'] }, - }, - refresh: true, - }; + let wrapper; + let prevProps; + let refreshExceptSpy; - it('should not call refresh when there is no change', () => { - const wrapper = setup({ dashboardState: overrideDashboardState }); - const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept'); - const prevProps = wrapper.instance().props; + beforeEach(() => { + wrapper = setup({ filters: OVERRIDE_FILTERS }); + wrapper.instance().appliedFilters = OVERRIDE_FILTERS; + prevProps = wrapper.instance().props; + refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept'); + }); + + afterEach(() => { + refreshExceptSpy.restore(); + }); + + it('should not call refresh when is editMode', () => { wrapper.setProps({ dashboardState: { - ...overrideDashboardState, + ...dashboardState, + editMode: true, }, }); wrapper.instance().componentDidUpdate(prevProps); - refreshExceptSpy.restore(); expect(refreshExceptSpy.callCount).toBe(0); }); - it('should call refresh if a filter is added', () => { - const wrapper = setup({ dashboardState: overrideDashboardState }); - const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept'); + it('should not call refresh when there is no change', () => { wrapper.setProps({ - dashboardState: { - ...overrideDashboardState, - filters: { - ...overrideDashboardState.filters, - 3: { another_filter: ['please'] }, - }, - }, + filters: OVERRIDE_FILTERS, }); - refreshExceptSpy.restore(); - expect(refreshExceptSpy.callCount).toBe(1); + wrapper.instance().componentDidUpdate(prevProps); + expect(refreshExceptSpy.callCount).toBe(0); + expect(wrapper.instance().appliedFilters).toBe(OVERRIDE_FILTERS); }); - it('should call refresh if a filter is removed', () => { - const wrapper = setup({ dashboardState: overrideDashboardState }); - const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept'); + it('should call refresh if a filter is added', () => { + const newFilter = { + gender: ['boy', 'girl'], + }; wrapper.setProps({ - dashboardState: { - ...overrideDashboardState, - filters: {}, + filters: { + ...OVERRIDE_FILTERS, + ...newFilter, }, }); - refreshExceptSpy.restore(); expect(refreshExceptSpy.callCount).toBe(1); + expect(wrapper.instance().appliedFilters).toEqual({ + ...OVERRIDE_FILTERS, + ...newFilter, + }); }); - it('should call refresh if a filter is changed', () => { - const wrapper = setup({ dashboardState: overrideDashboardState }); - const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept'); + it('should call refresh if a filter is removed', () => { wrapper.setProps({ - dashboardState: { - ...overrideDashboardState, - filters: { - ...overrideDashboardState.filters, - 2: { country_name: ['Canada'] }, - }, - }, + filters: {}, }); - refreshExceptSpy.restore(); expect(refreshExceptSpy.callCount).toBe(1); + expect(wrapper.instance().appliedFilters).toEqual({}); }); - it('should not call refresh if filters change and refresh is false', () => { - const wrapper = setup({ dashboardState: overrideDashboardState }); - const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept'); + it('should call refresh if a filter is changed', () => { wrapper.setProps({ - dashboardState: { - ...overrideDashboardState, - filters: { - ...overrideDashboardState.filters, - 2: { country_name: ['Canada'] }, - }, - refresh: false, - }, - }); - refreshExceptSpy.restore(); - expect(refreshExceptSpy.callCount).toBe(0); - }); - - it('should not refresh filter_immune_slices', () => { - const wrapper = setup({ - dashboardState: overrideDashboardState, - dashboardInfo: { - ...dashboardInfo, - metadata: { - ...dashboardInfo.metadata, - filter_immune_slices: [chartId], - }, + filters: { + ...OVERRIDE_FILTERS, + region: ['Canada'], }, }); - const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept'); - const prevProps = wrapper.instance().props; - wrapper.setProps({ - dashboardState: { - ...overrideDashboardState, - filters: { - ...overrideDashboardState.filters, - 2: { country_name: ['Canada'] }, - }, - refresh: false, - }, + expect(refreshExceptSpy.callCount).toBe(1); + expect(wrapper.instance().appliedFilters).toEqual({ + ...OVERRIDE_FILTERS, + region: ['Canada'], }); - wrapper.instance().componentDidUpdate(prevProps); - refreshExceptSpy.restore(); - expect(refreshExceptSpy.callCount).toBe(0); }); }); }); diff --git a/superset/assets/spec/javascripts/dashboard/components/FilterIndicatorGroup_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/FilterIndicatorGroup_spec.jsx new file mode 100644 index 000000000000..e54a698cd1b7 --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/components/FilterIndicatorGroup_spec.jsx @@ -0,0 +1,50 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { dashboardFilters } from '../fixtures/mockDashboardFilters'; +import { filterId, column } from '../fixtures/mockSliceEntities'; +import FilterIndicatorGroup from '../../../../src/dashboard/components/FilterIndicatorGroup'; +import FilterBadgeIcon from '../../../../src/components/FilterBadgeIcon'; + +describe('FilterIndicatorGroup', () => { + const mockedProps = { + indicators: [ + { + ...dashboardFilters[filterId], + colorCode: 'badge-1', + name: column, + values: ['a', 'b', 'c'], + }, + ], + setDirectPathToChild: () => {}, + }; + + function setup(overrideProps) { + return shallow( + , + ); + } + + it('should show indicator group with badge', () => { + const wrapper = setup(); + expect(wrapper.find(FilterBadgeIcon)).toHaveLength(1); + }); +}); diff --git a/superset/assets/spec/javascripts/dashboard/components/FilterIndicatorTooltip_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/FilterIndicatorTooltip_spec.jsx new file mode 100644 index 000000000000..546a324619ae --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/components/FilterIndicatorTooltip_spec.jsx @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; + +import FilterIndicatorTooltip from '../../../../src/dashboard/components/FilterIndicatorTooltip'; + +describe('FilterIndicatorTooltip', () => { + const label = 'region'; + const mockedProps = { + colorCode: 'badge-1', + label, + values: [], + clickIconHandler: jest.fn(), + }; + + function setup(overrideProps) { + return shallow( + , + ); + } + + it('should show label', () => { + const wrapper = setup(); + expect(wrapper.find(`[htmlFor="filter-tooltip-${label}"]`)).toHaveLength(1); + }); +}); diff --git a/superset/assets/spec/javascripts/dashboard/components/FilterIndicator_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/FilterIndicator_spec.jsx new file mode 100644 index 000000000000..629828347e47 --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/components/FilterIndicator_spec.jsx @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { dashboardFilters } from '../fixtures/mockDashboardFilters'; +import { filterId, column } from '../fixtures/mockSliceEntities'; +import FilterIndicator from '../../../../src/dashboard/components/FilterIndicator'; +import FilterBadgeIcon from '../../../../src/components/FilterBadgeIcon'; + +describe('FilterIndicator', () => { + const mockedProps = { + indicator: { + ...dashboardFilters[filterId], + colorCode: 'badge-1', + name: column, + label: column, + values: ['a', 'b', 'c'], + }, + setDirectPathToChild: jest.fn(), + }; + + function setup(overrideProps) { + return shallow(); + } + + it('should show indicator with badge', () => { + const wrapper = setup(); + expect(wrapper.find(FilterBadgeIcon)).toHaveLength(1); + }); + + it('should call setDirectPathToChild prop', () => { + const wrapper = setup(); + const badge = wrapper.find('.filter-indicator'); + expect(badge).toHaveLength(1); + + badge.simulate('click'); + expect(mockedProps.setDirectPathToChild).toHaveBeenCalledWith( + dashboardFilters[filterId].directPathToFilter, + ); + }); +}); diff --git a/superset/assets/spec/javascripts/dashboard/components/FilterIndicatorsContainer_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/FilterIndicatorsContainer_spec.jsx new file mode 100644 index 000000000000..0fa06abbdf5f --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/components/FilterIndicatorsContainer_spec.jsx @@ -0,0 +1,74 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { dashboardFilters } from '../fixtures/mockDashboardFilters'; +import { filterId, column } from '../fixtures/mockSliceEntities'; +import FilterIndicatorsContainer from '../../../../src/dashboard/components/FilterIndicatorsContainer'; +import FilterIndicator from '../../../../src/dashboard/components/FilterIndicator'; +import * as colorMap from '../../../../src/dashboard/util/dashboardFiltersColorMap'; + +describe('FilterIndicatorsContainer', () => { + const chartId = 1; + const mockedProps = { + dashboardFilters, + chartId, + chartStatus: 'success', + filterImmuneSlices: [], + filterImmuneSliceFields: {}, + setDirectPathToChild: () => {}, + }; + + colorMap.getFilterColorKey = jest.fn(() => 'id_column'); + colorMap.getFilterColorMap = jest.fn(() => ({ + id_column: 'badge-1', + })); + + function setup(overrideProps) { + return shallow( + , + ); + } + + it('should not show indicator when chart is loading', () => { + const wrapper = setup({ chartStatus: 'loading' }); + expect(wrapper.find(FilterIndicator)).toHaveLength(0); + }); + + it('should not show indicator for filter_box itself', () => { + const wrapper = setup({ chartId: filterId }); + expect(wrapper.find(FilterIndicator)).toHaveLength(0); + }); + + it('should not show indicator when chart is immune', () => { + const wrapper = setup({ filterImmuneSlices: [chartId] }); + expect(wrapper.find(FilterIndicator)).toHaveLength(0); + }); + + it('should not show indicator when chart field is immune', () => { + const wrapper = setup({ filterImmuneSliceFields: { [chartId]: [column] } }); + expect(wrapper.find(FilterIndicator)).toHaveLength(0); + }); + + it('should show indicator', () => { + const wrapper = setup(); + expect(wrapper.find(FilterIndicator)).toHaveLength(1); + }); +}); diff --git a/superset/assets/spec/javascripts/dashboard/components/FilterTooltipWrapper_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/FilterTooltipWrapper_spec.jsx new file mode 100644 index 000000000000..ab5170030b09 --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/components/FilterTooltipWrapper_spec.jsx @@ -0,0 +1,70 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Overlay, Tooltip } from 'react-bootstrap'; + +import FilterTooltipWrapper from '../../../../src/dashboard/components/FilterTooltipWrapper'; +import FilterIndicatorTooltip from '../../../../src/dashboard/components/FilterIndicatorTooltip'; + +describe('FilterTooltipWrapper', () => { + const mockedProps = { + tooltip: ( + + ), + }; + + function setup() { + return shallow( + +
+ , + ); + } + + it('should contain Overlay and Tooltip', () => { + const wrapper = setup(); + expect(wrapper.find(Overlay)).toHaveLength(1); + expect(wrapper.find(Tooltip)).toHaveLength(1); + }); + + it('should show tooltip on hover', () => { + const wrapper = setup(); + wrapper.instance().isHover = true; + + jest.useFakeTimers(); + wrapper.find('.indicator-container').simulate('mouseover'); + jest.runAllTimers(); + expect(wrapper.state('show')).toBe(true); + }); + + it('should hide tooltip on hover', () => { + const wrapper = setup(); + wrapper.instance().isHover = false; + + jest.useFakeTimers(); + wrapper.find('.indicator-container').simulate('mouseout'); + jest.runAllTimers(); + expect(wrapper.state('show')).toBe(false); + }); +}); diff --git a/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx index 45f987414a0d..dd53e74f43fe 100644 --- a/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx @@ -52,7 +52,6 @@ describe('Header', () => { setEditMode: () => {}, showBuilderPane: () => {}, builderPaneType: BUILDER_PANE_TYPE.NONE, - toggleBuilderPane: () => {}, updateCss: () => {}, hasUnsavedChanges: false, maxUndoHistoryExceeded: false, diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx index a96a44caa73f..f097c2d78176 100644 --- a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx @@ -25,17 +25,14 @@ import SliceHeader from '../../../../../src/dashboard/components/SliceHeader'; import ChartContainer from '../../../../../src/chart/ChartContainer'; import mockDatasource from '../../../../fixtures/mockDatasource'; -import { - sliceEntitiesForChart as sliceEntities, - sliceId, -} from '../../fixtures/mockSliceEntities'; +import { sliceEntitiesForChart as sliceEntities } from '../../fixtures/mockSliceEntities'; import chartQueries, { sliceId as queryId, } from '../../fixtures/mockChartQueries'; describe('Chart', () => { const props = { - id: sliceId, + id: queryId, width: 100, height: 100, updateSliceName() {}, @@ -43,12 +40,12 @@ describe('Chart', () => { // from redux chart: chartQueries[queryId], formData: chartQueries[queryId].formData, - datasource: mockDatasource[sliceEntities.slices[sliceId].datasource], + datasource: mockDatasource[sliceEntities.slices[queryId].datasource], slice: { - ...sliceEntities.slices[sliceId], + ...sliceEntities.slices[queryId], description_markeddown: 'markdown', }, - sliceName: sliceEntities.slices[sliceId].slice_name, + sliceName: sliceEntities.slices[queryId].slice_name, timeout: 60, filters: {}, refreshChart() {}, @@ -92,10 +89,10 @@ describe('Chart', () => { expect(refreshChart.callCount).toBe(1); }); - it('should call addFilter when ChartContainer calls addFilter', () => { - const addFilter = sinon.spy(); - const wrapper = setup({ addFilter }); - wrapper.instance().addFilter(); - expect(addFilter.callCount).toBe(1); + it('should call changeFilter when ChartContainer calls changeFilter', () => { + const changeFilter = sinon.spy(); + const wrapper = setup({ changeFilter }); + wrapper.instance().changeFilter(); + expect(changeFilter.callCount).toBe(1); }); }); diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js new file mode 100644 index 000000000000..e72e13e09abf --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { filterId } from './mockSliceEntities'; + +export const emptyFilters = {}; + +export const dashboardFilters = { + [filterId]: { + chartId: filterId, + componentId: 'CHART-rwDfbGqeEn', + directPathToFilter: [ + 'ROOT_ID', + 'TABS-VPEX_c476g', + 'TAB-PMJyKM1yB', + 'TABS-YdylzDMTMQ', + 'TAB-O9AaU9FT0', + 'ROW-l6PrlhwSjh', + 'CHART-rwDfbGqeEn', + ], + scope: 'ROOT_ID', + isDateFilter: false, + isInstantFilter: true, + columns: { + region: ['a', 'b'], + }, + labels: { + region: 'region', + }, + }, +}; diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js index 46d128122506..426a05e977ba 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js @@ -20,8 +20,8 @@ export default { id: 1234, slug: 'dashboardSlug', metadata: { - filter_immune_slices: [], - filter_immune_slice_fields: {}, + filterImmuneSlices: [], + filterImmuneSliceFields: {}, }, userId: 'mock_user_id', dash_edit_perm: true, diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js index f2f965ca085b..aad44452ff84 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js @@ -37,6 +37,7 @@ import { import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory'; import { sliceId as chartId } from './mockChartQueries'; +import { filterId } from './mockDashboardFilters'; export const sliceId = chartId; @@ -187,3 +188,22 @@ export const dashboardLayoutWithTabs = { }, future: [], }; + +export const filterComponent = { + ...newComponentFactory(CHART_TYPE), + id: 'CHART-rwDfbGqeEn', + parents: [ + 'ROOT_ID', + 'TABS-VPEX_c476g', + 'TAB-PMJyKM1yB', + 'TABS-YdylzDMTMQ', + 'TAB-O9AaU9FT0', + 'ROW-l6PrlhwSjh', + ], + meta: { + chartId: filterId, + width: 3, + height: 10, + chartName: 'Filter', + }, +}; diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js index d3887110a587..22d7bfc60a9a 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js @@ -21,13 +21,12 @@ import { BUILDER_PANE_TYPE } from '../../../../src/dashboard/util/constants'; export default { sliceIds: [sliceId], - refresh: false, - filters: {}, expandedSlices: {}, editMode: false, builderPaneType: BUILDER_PANE_TYPE.NONE, hasUnsavedChanges: false, maxUndoHistoryExceeded: false, isStarred: true, + isPublished: true, css: '', }; diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js index f69968d5e604..9f33c2963ec6 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js @@ -16,10 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { sliceId as id } from './mockChartQueries'; import { datasourceId } from '../../../fixtures/mockDatasource'; +import { sliceId } from './mockChartQueries'; -export const sliceId = id; +export const filterId = 127; +export const column = 'region'; export const sliceEntitiesForChart = { slices: { @@ -49,6 +50,8 @@ export const sliceEntitiesForChart = { datasource: datasourceId, description: null, description_markeddown: '', + modified: '23 hours ago', + changed_on: 1529453332615, }, }, isLoading: false, @@ -58,11 +61,23 @@ export const sliceEntitiesForChart = { export const sliceEntitiesForDashboard = { slices: { - 127: { - slice_id: 127, + [filterId]: { + slice_id: filterId, slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20127%7D', slice_name: 'Region Filter', - form_data: {}, + form_data: { + instant_filtering: true, + filter_configs: [ + { + asc: true, + clearable: true, + column, + key: 'JknLrSlNL', + multiple: true, + label: column, + }, + ], + }, edit_url: '/chart/edit/127', viz_type: 'filter_box', datasource: '2__table', diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js index b42b960bd8c9..35eeb8be86a7 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js @@ -19,6 +19,7 @@ import chartQueries from './mockChartQueries'; import { dashboardLayout } from './mockDashboardLayout'; import dashboardInfo from './mockDashboardInfo'; +import { emptyFilters } from './mockDashboardFilters'; import dashboardState from './mockDashboardState'; import messageToasts from '../../messageToasts/mockMessageToasts'; import datasources from '../../../fixtures/mockDatasource'; @@ -29,6 +30,7 @@ export default { sliceEntities, charts: chartQueries, dashboardInfo, + dashboardFilters: emptyFilters, dashboardState, dashboardLayout, messageToasts, diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js index 63148bf696fd..5d877101b3f3 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js @@ -35,6 +35,7 @@ export const mockStoreWithTabs = createStore( { ...mockState, dashboardLayout: dashboardLayoutWithTabs, + dashboardFilters: {}, }, compose(applyMiddleware(thunk)), ); diff --git a/superset/assets/spec/javascripts/dashboard/reducers/dashboardFilters_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/dashboardFilters_spec.js new file mode 100644 index 000000000000..88c67140a51d --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/reducers/dashboardFilters_spec.js @@ -0,0 +1,138 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* eslint-disable camelcase */ +import { + ADD_FILTER, + REMOVE_FILTER, + CHANGE_FILTER, +} from '../../../../src/dashboard/actions/dashboardFilters'; +import dashboardFiltersReducer from '../../../../src/dashboard/reducers/dashboardFilters'; +import { + emptyFilters, + dashboardFilters, +} from '../fixtures/mockDashboardFilters'; +import { + sliceEntitiesForDashboard, + filterId, + column, +} from '../fixtures/mockSliceEntities'; +import { filterComponent } from '../fixtures/mockDashboardLayout'; +import { DASHBOARD_ROOT_ID } from '../../../../src/dashboard/util/constants'; + +describe('dashboardFilters reducer', () => { + const form_data = sliceEntitiesForDashboard.slices[filterId].form_data; + const component = filterComponent; + const directPathToFilter = (component.parents || []).slice(); + directPathToFilter.push(component.id); + + it('should add a new filter if it does not exist', () => { + expect( + dashboardFiltersReducer(emptyFilters, { + type: ADD_FILTER, + chartId: filterId, + component, + form_data, + }), + ).toEqual({ + [filterId]: { + chartId: filterId, + componentId: component.id, + directPathToFilter, + scope: DASHBOARD_ROOT_ID, + isDateFilter: false, + isInstantFilter: !!form_data.instant_filtering, + columns: { + [column]: undefined, + }, + labels: { + [column]: column, + }, + }, + }); + }); + + it('should overwrite a filter if merge is false', () => { + expect( + dashboardFiltersReducer(dashboardFilters, { + type: CHANGE_FILTER, + chartId: filterId, + newSelectedValues: { + region: ['c'], + gender: ['body', 'girl'], + }, + merge: false, + }), + ).toEqual({ + [filterId]: { + chartId: filterId, + componentId: component.id, + directPathToFilter, + scope: DASHBOARD_ROOT_ID, + isDateFilter: false, + isInstantFilter: !!form_data.instant_filtering, + columns: { + region: ['c'], + gender: ['body', 'girl'], + }, + labels: { + [column]: column, + }, + }, + }); + }); + + it('should merge a filter if merge is true', () => { + expect( + dashboardFiltersReducer(dashboardFilters, { + type: CHANGE_FILTER, + chartId: filterId, + newSelectedValues: { + region: ['c'], + gender: ['body', 'girl'], + }, + merge: true, + }), + ).toEqual({ + [filterId]: { + chartId: filterId, + componentId: component.id, + directPathToFilter, + scope: DASHBOARD_ROOT_ID, + isDateFilter: false, + isInstantFilter: !!form_data.instant_filtering, + columns: { + region: ['a', 'b', 'c'], + gender: ['body', 'girl'], + }, + labels: { + [column]: column, + }, + }, + }); + }); + + it('should remove the filter if values are empty', () => { + expect( + dashboardFiltersReducer(dashboardFilters, { + type: REMOVE_FILTER, + chartId: filterId, + }), + ).toEqual({}); + }); +}); diff --git a/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js index dadcf06c8bf1..e6117819fd6c 100644 --- a/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js +++ b/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js @@ -18,7 +18,6 @@ */ import { ADD_SLICE, - CHANGE_FILTER, ON_CHANGE, ON_SAVE, REMOVE_SLICE, @@ -52,16 +51,7 @@ describe('dashboardState reducer', () => { { sliceIds: [1, 2], filters: {} }, { type: REMOVE_SLICE, sliceId: 2 }, ), - ).toEqual({ sliceIds: [1], refresh: false, filters: {} }); - }); - - it('should reset filters if a removed slice is a filter', () => { - expect( - dashboardStateReducer( - { sliceIds: [1, 2], filters: { 2: {}, 1: {} } }, - { type: REMOVE_SLICE, sliceId: 2 }, - ), - ).toEqual({ sliceIds: [1], filters: { 1: {} }, refresh: true }); + ).toEqual({ sliceIds: [1], filters: {} }); }); it('should toggle fav star', () => { @@ -141,104 +131,4 @@ describe('dashboardState reducer', () => { updatedColorScheme: false, }); }); - - describe('change filter', () => { - it('should add a new filter if it does not exist', () => { - expect( - dashboardStateReducer( - { - filters: {}, - sliceIds: [1], - }, - { - type: CHANGE_FILTER, - chart: { id: 1, formData: { groupby: 'column' } }, - col: 'column', - vals: ['b', 'a'], - refresh: true, - merge: true, - }, - ), - ).toEqual({ - filters: { 1: { column: ['b', 'a'] } }, - refresh: true, - sliceIds: [1], - }); - }); - - it('should overwrite a filter if merge is false', () => { - expect( - dashboardStateReducer( - { - filters: { - 1: { column: ['z'] }, - }, - sliceIds: [1], - }, - { - type: CHANGE_FILTER, - chart: { id: 1, formData: { groupby: 'column' } }, - col: 'column', - vals: ['b', 'a'], - refresh: true, - merge: false, - }, - ), - ).toEqual({ - filters: { 1: { column: ['b', 'a'] } }, - refresh: true, - sliceIds: [1], - }); - }); - - it('should merge a filter if merge is true', () => { - expect( - dashboardStateReducer( - { - filters: { - 1: { column: ['z'] }, - }, - sliceIds: [1], - }, - { - type: CHANGE_FILTER, - chart: { id: 1, formData: { groupby: 'column' } }, - col: 'column', - vals: ['b', 'a'], - refresh: true, - merge: true, - }, - ), - ).toEqual({ - filters: { 1: { column: ['z', 'b', 'a'] } }, - refresh: true, - sliceIds: [1], - }); - }); - - it('should remove the filter if values are empty', () => { - expect( - dashboardStateReducer( - { - filters: { - 1: { column: ['z'] }, - }, - sliceIds: [1], - }, - { - type: CHANGE_FILTER, - chart: { id: 1, formData: { groupby: 'column' } }, - col: 'column', - vals: [], - refresh: true, - merge: false, - }, - ), - ).toEqual({ - filters: {}, - refresh: true, - sliceIds: [1], - }); - }); - }); }); diff --git a/superset/assets/spec/javascripts/dashboard/util/findTabIndexByComponentId_spec.js b/superset/assets/spec/javascripts/dashboard/util/findTabIndexByComponentId_spec.js index 3e3d0f7b9d64..fcaec0100579 100644 --- a/superset/assets/spec/javascripts/dashboard/util/findTabIndexByComponentId_spec.js +++ b/superset/assets/spec/javascripts/dashboard/util/findTabIndexByComponentId_spec.js @@ -49,22 +49,22 @@ describe('findTabIndexByComponentId', () => { ]; const badPath = ['ROOT_ID', 'TABS-MNQQSW-kyd', 'TAB-ABC', 'TABS-Oduxop1L7I']; - it('should return 0 if no directPathToChild', () => { + it('should return -1 if no directPathToChild', () => { expect( findTabIndexByComponentId({ currentComponent: topLevelTabsComponent, directPathToChild: [], }), - ).toBe(0); + ).toBe(-1); }); - it('should return 0 if not found tab id', () => { + it('should return -1 if not found tab id', () => { expect( findTabIndexByComponentId({ currentComponent: topLevelTabsComponent, directPathToChild: badPath, }), - ).toBe(0); + ).toBe(-1); }); it('should return children index if matched an id in the path', () => { diff --git a/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js b/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js index e0c16a5312cf..544b2d484f5e 100644 --- a/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js +++ b/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js @@ -34,8 +34,8 @@ describe('getFormDataWithExtraFilters', () => { }, }, dashboardMetadata: { - filter_immune_slices: [], - filter_immune_slice_fields: {}, + filterImmuneSlices: [], + filterImmuneSliceFields: {}, }, filters: { filterId: { @@ -65,7 +65,7 @@ describe('getFormDataWithExtraFilters', () => { const result = getFormDataWithExtraFilters({ ...mockArgs, dashboardMetadata: { - filter_immune_slices: [chartId], + filterImmuneSlices: [chartId], }, }); expect(result.extra_filters).toHaveLength(0); @@ -75,7 +75,7 @@ describe('getFormDataWithExtraFilters', () => { const result = getFormDataWithExtraFilters({ ...mockArgs, dashboardMetadata: { - filter_immune_slice_fields: { + filterImmuneSliceFields: { [chartId]: ['region'], }, }, diff --git a/superset/assets/spec/javascripts/dashboard/util/getLeafComponentIdFromPath_spec.js b/superset/assets/spec/javascripts/dashboard/util/getLeafComponentIdFromPath_spec.js new file mode 100644 index 000000000000..cb6331f942d0 --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/util/getLeafComponentIdFromPath_spec.js @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import getLeafComponentIdFromPath from '../../../../src/dashboard/util/getLeafComponentIdFromPath'; +import { filterId } from '../fixtures/mockSliceEntities'; +import { dashboardFilters } from '../fixtures/mockDashboardFilters'; + +describe('getLeafComponentIdFromPath', () => { + const path = dashboardFilters[filterId].directPathToFilter; + const leaf = path.slice().pop(); + + it('should return component id', () => { + expect(getLeafComponentIdFromPath(path)).toBe(leaf); + }); + + it('should not return label component', () => { + const updatedPath = dashboardFilters[filterId].directPathToFilter.concat( + 'LABEL-test123', + ); + expect(getLeafComponentIdFromPath(updatedPath)).toBe(leaf); + }); +}); diff --git a/superset/assets/src/chart/chartReducer.js b/superset/assets/src/chart/chartReducer.js index e7ad6c6a5661..02df8cb28d15 100644 --- a/superset/assets/src/chart/chartReducer.js +++ b/superset/assets/src/chart/chartReducer.js @@ -182,7 +182,7 @@ export default function chartReducer(charts = {}, action) { if (action.type in actionHandlers) { return { ...charts, - [action.key]: actionHandlers[action.type](charts[action.key], action), + [action.key]: actionHandlers[action.type](charts[action.key]), }; } diff --git a/superset/assets/src/components/AnchorLink.jsx b/superset/assets/src/components/AnchorLink.jsx index c21bb4bb7228..e703013f4cf9 100644 --- a/superset/assets/src/components/AnchorLink.jsx +++ b/superset/assets/src/components/AnchorLink.jsx @@ -22,42 +22,52 @@ import { t } from '@superset-ui/translation'; import URLShortLinkButton from './URLShortLinkButton'; import getDashboardUrl from '../dashboard/util/getDashboardUrl'; +import getLocationHash from '../dashboard/util/getLocationHash'; const propTypes = { anchorLinkId: PropTypes.string.isRequired, filters: PropTypes.object, showShortLinkButton: PropTypes.bool, + inFocus: PropTypes.bool, placement: PropTypes.oneOf(['right', 'left', 'top', 'bottom']), }; const defaultProps = { + inFocus: false, showShortLinkButton: false, placement: 'right', filters: {}, }; - class AnchorLink extends React.PureComponent { componentDidMount() { - const hash = this.getLocationHash(); + const hash = getLocationHash(); const { anchorLinkId } = this.props; if (hash && anchorLinkId === hash) { - const directLinkComponent = document.getElementById(anchorLinkId); - if (directLinkComponent) { - setTimeout(() => { - directLinkComponent.scrollIntoView({ - block: 'center', - behavior: 'smooth', - }); - }, 1000); - } + this.scrollToView(); + } + } + + componentWillReceiveProps(nextProps) { + const { inFocus = false } = nextProps; + if (inFocus) { + this.scrollToView(); } } - getLocationHash() { - return (window.location.hash || '').substring(1); + scrollToView(delay = 0) { + const { anchorLinkId } = this.props; + const directLinkComponent = document.getElementById(anchorLinkId); + if (directLinkComponent) { + setTimeout(() => { + directLinkComponent.scrollIntoView({ + block: 'center', + behavior: 'smooth', + }); + }, delay); + } } render() { diff --git a/superset/assets/src/components/FilterBadgeIcon.css b/superset/assets/src/components/FilterBadgeIcon.css new file mode 100644 index 000000000000..4cb6a713e56c --- /dev/null +++ b/superset/assets/src/components/FilterBadgeIcon.css @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +.filter-badge { + width: 20px; + height: 20px; + background-color: #bababa; + border-radius: 2px; + z-index: 10; +} +.filter-badge:hover { + cursor: pointer; + background-color: #9e9e9e; +} diff --git a/superset/assets/src/components/FilterBadgeIcon.jsx b/superset/assets/src/components/FilterBadgeIcon.jsx new file mode 100644 index 000000000000..e523aa28df7e --- /dev/null +++ b/superset/assets/src/components/FilterBadgeIcon.jsx @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; + +import './FilterBadgeIcon.css'; + +const propTypes = { + colorCode: PropTypes.string, +}; + +export default function FilterBadgeIcon({ colorCode = '' }) { + return ( + + + + ); +} + +FilterBadgeIcon.propTypes = propTypes; diff --git a/superset/assets/src/components/FilterEditIcon.css b/superset/assets/src/components/FilterEditIcon.css new file mode 100644 index 000000000000..d3eef85466a8 --- /dev/null +++ b/superset/assets/src/components/FilterEditIcon.css @@ -0,0 +1,21 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +.filter-edit { + cursor: pointer; +} diff --git a/superset/assets/src/components/FilterEditIcon.jsx b/superset/assets/src/components/FilterEditIcon.jsx new file mode 100644 index 000000000000..91ac04ccca72 --- /dev/null +++ b/superset/assets/src/components/FilterEditIcon.jsx @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; + +import './FilterEditIcon.css'; + +const propTypes = { + clickIconHandler: PropTypes.func, +}; + +export default function FilterEditIcon({ clickIconHandler = () => {} }) { + return ( + + + + ); +} + +FilterEditIcon.propTypes = propTypes; diff --git a/superset/assets/src/dashboard/actions/dashboardFilters.js b/superset/assets/src/dashboard/actions/dashboardFilters.js new file mode 100644 index 000000000000..c2b8b9c1d99a --- /dev/null +++ b/superset/assets/src/dashboard/actions/dashboardFilters.js @@ -0,0 +1,68 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* eslint-disable camelcase */ +// util function to make sure filter is a valid slice in current dashboard +function isValidFilter(getState, chartId) { + return getState().dashboardState.sliceIds.includes(chartId); +} + +export const ADD_FILTER = 'ADD_FILTER'; +export function addFilter(chartId, component, form_data) { + return (dispatch, getState) => { + if (isValidFilter(getState, chartId)) { + return dispatch({ type: ADD_FILTER, chartId, component, form_data }); + } + return getState().dashboardFilters; + }; +} + +export const REMOVE_FILTER = 'REMOVE_FILTER'; +export function removeFilter(chartId) { + return (dispatch, getState) => { + if (isValidFilter(getState, chartId)) { + return dispatch({ type: REMOVE_FILTER, chartId }); + } + return getState().dashboardFilters; + }; +} + +export const CHANGE_FILTER = 'CHANGE_FILTER'; +export function changeFilter(chartId, newSelectedValues, merge) { + return (dispatch, getState) => { + if (isValidFilter(getState, chartId)) { + return dispatch({ + type: CHANGE_FILTER, + chartId, + newSelectedValues, + merge, + }); + } + return getState().dashboardFilters; + }; +} + +export const UPDATE_DIRECT_PATH_TO_FILTER = 'UPDATE_DIRECT_PATH_TO_FILTER'; +export function updateDirectPathToFilter(chartId, path) { + return (dispatch, getState) => { + if (isValidFilter(getState, chartId)) { + return dispatch({ type: UPDATE_DIRECT_PATH_TO_FILTER, chartId, path }); + } + return getState().dashboardFilters; + }; +} diff --git a/superset/assets/src/dashboard/actions/dashboardState.js b/superset/assets/src/dashboard/actions/dashboardState.js index 3318c33eb6c4..4d8430f592bc 100644 --- a/superset/assets/src/dashboard/actions/dashboardState.js +++ b/superset/assets/src/dashboard/actions/dashboardState.js @@ -24,6 +24,11 @@ import { SupersetClient } from '@superset-ui/connection'; import { addChart, removeChart, refreshChart } from '../../chart/chartAction'; import { chart as initChart } from '../../chart/chartReducer'; import { fetchDatasourceMetadata } from '../../dashboard/actions/datasources'; +import { + addFilter, + removeFilter, + updateDirectPathToFilter, +} from '../../dashboard/actions/dashboardFilters'; import { applyDefaultFormData } from '../../explore/store'; import getClientErrorObject from '../../utils/getClientErrorObject'; import { SAVE_TYPE_OVERWRITE } from '../util/constants'; @@ -39,11 +44,6 @@ export function setUnsavedChanges(hasUnsavedChanges) { return { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges } }; } -export const CHANGE_FILTER = 'CHANGE_FILTER'; -export function changeFilter(chart, col, vals, merge = true, refresh = true) { - return { type: CHANGE_FILTER, chart, col, vals, merge, refresh }; -} - export const ADD_SLICE = 'ADD_SLICE'; export function addSlice(slice) { return { type: ADD_SLICE, slice }; @@ -166,9 +166,19 @@ export function saveDashboardRequestSuccess() { export function saveDashboardRequest(data, id, saveType) { const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash'; - return dispatch => { + return (dispatch, getState) => { dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST }); + const { dashboardFilters, dashboardLayout } = getState(); + const layout = dashboardLayout.present; + Object.values(dashboardFilters).forEach(filter => { + const { chartId } = filter; + const componentId = filter.directPathToFilter.slice().pop(); + const directPathToFilter = (layout[componentId].parents || []).slice(); + directPathToFilter.push(componentId); + dispatch(updateDirectPathToFilter(chartId, directPathToFilter)); + }); + return SupersetClient.post({ endpoint: `/superset/${path}/${id}/`, postPayload: { data }, @@ -257,7 +267,7 @@ export function showBuilderPane(builderPaneType) { return { type: SHOW_BUILDER_PANE, builderPaneType }; } -export function addSliceToDashboard(id) { +export function addSliceToDashboard(id, component) { return (dispatch, getState) => { const { sliceEntities } = getState(); const selectedSlice = sliceEntities.slices[id]; @@ -282,12 +292,23 @@ export function addSliceToDashboard(id) { return Promise.all([ dispatch(addChart(newChart, id)), dispatch(fetchDatasourceMetadata(form_data.datasource)), - ]).then(() => dispatch(addSlice(selectedSlice))); + ]).then(() => { + dispatch(addSlice(selectedSlice)); + + if (selectedSlice && selectedSlice.viz_type === 'filter_box') { + dispatch(addFilter(id, component, selectedSlice.form_data)); + } + }); }; } export function removeSliceFromDashboard(id) { - return dispatch => { + return (dispatch, getState) => { + const sliceEntity = getState().sliceEntities.slices[id]; + if (sliceEntity && sliceEntity.viz_type === 'filter_box') { + dispatch(removeFilter(id)); + } + dispatch(removeSlice(id)); dispatch(removeChart(id)); }; @@ -305,6 +326,11 @@ export function setColorSchemeAndUnsavedChanges(colorScheme) { }; } +export const SET_DIRECT_PATH = 'SET_DIRECT_PATH'; +export function setDirectPathToChild(path) { + return { type: SET_DIRECT_PATH, path }; +} + // Undo history --------------------------------------------------------------- export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED'; export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) { diff --git a/superset/assets/src/dashboard/components/Dashboard.jsx b/superset/assets/src/dashboard/components/Dashboard.jsx index 13222c6ec915..72d3d949fe7c 100644 --- a/superset/assets/src/dashboard/components/Dashboard.jsx +++ b/superset/assets/src/dashboard/components/Dashboard.jsx @@ -16,12 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -/* eslint-disable camelcase */ import React from 'react'; import PropTypes from 'prop-types'; import { t } from '@superset-ui/translation'; import getChartIdsFromLayout from '../util/getChartIdsFromLayout'; +import getLayoutComponentFromChartId from '../util/getLayoutComponentFromChartId'; import DashboardBuilder from '../containers/DashboardBuilder'; import { chartPropShape, @@ -33,6 +33,7 @@ import { import { areObjectsEqual } from '../../reduxUtils'; import { LOG_ACTIONS_MOUNT_DASHBOARD } from '../../logger/LogUtils'; import OmniContainer from '../../components/OmniContainer'; +import { safeStringify } from '../../utils/safeStringify'; import '../stylesheets/index.less'; @@ -47,6 +48,7 @@ const propTypes = { dashboardState: dashboardStatePropShape.isRequired, charts: PropTypes.objectOf(chartPropShape).isRequired, slices: PropTypes.objectOf(slicePropShape).isRequired, + filters: PropTypes.object.isRequired, datasources: PropTypes.object.isRequired, loadStats: loadStatsPropShape.isRequired, layout: PropTypes.object.isRequired, @@ -78,6 +80,11 @@ class Dashboard extends React.PureComponent { return message; // Gecko + Webkit, Safari, Chrome etc. } + constructor(props) { + super(props); + this.appliedFilters = {}; + } + componentDidMount() { this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD); } @@ -91,7 +98,10 @@ class Dashboard extends React.PureComponent { key => currentChartIds.indexOf(key) === -1, ); newChartIds.forEach(newChartId => - this.props.actions.addSliceToDashboard(newChartId), + this.props.actions.addSliceToDashboard( + newChartId, + getLayoutComponentFromChartId(nextProps.layout, newChartId), + ), ); } else if (currentChartIds.length > nextChartIds.length) { // remove chart @@ -104,20 +114,23 @@ class Dashboard extends React.PureComponent { } } - componentDidUpdate(prevProps) { - const { refresh, filters, hasUnsavedChanges } = this.props.dashboardState; - if (refresh) { + componentDidUpdate() { + const { hasUnsavedChanges, editMode } = this.props.dashboardState; + + const appliedFilters = this.appliedFilters; + const { filters } = this.props; + // do not apply filter when dashboard in edit mode + if (!editMode && safeStringify(appliedFilters) !== safeStringify(filters)) { // refresh charts if a filter was removed, added, or changed let changedFilterKey = null; const currFilterKeys = Object.keys(filters); - const prevFilterKeys = Object.keys(prevProps.dashboardState.filters); + const appliedFilterKeys = Object.keys(appliedFilters); currFilterKeys.forEach(key => { - const prevFilter = prevProps.dashboardState.filters[key]; if ( // filter was added or changed - typeof prevFilter === 'undefined' || - !areObjectsEqual(prevFilter, filters[key]) + typeof appliedFilters[key] === 'undefined' || + !areObjectsEqual(appliedFilters[key], filters[key]) ) { changedFilterKey = key; } @@ -125,9 +138,10 @@ class Dashboard extends React.PureComponent { if ( !!changedFilterKey || - currFilterKeys.length !== prevFilterKeys.length + currFilterKeys.length !== appliedFilterKeys.length // remove 1 or more filters ) { this.refreshExcept(changedFilterKey); + this.appliedFilters = filters; } } @@ -144,30 +158,30 @@ class Dashboard extends React.PureComponent { } refreshExcept(filterKey) { - const { filters } = this.props.dashboardState || {}; + const { filters } = this.props; const currentFilteredNames = filterKey && filters[filterKey] ? Object.keys(filters[filterKey]) : []; - const filter_immune_slices = this.props.dashboardInfo.metadata - .filter_immune_slices; - const filter_immune_slice_fields = this.props.dashboardInfo.metadata - .filter_immune_slice_fields; + const filterImmuneSlices = this.props.dashboardInfo.metadata + .filterImmuneSlices; + const filterImmuneSliceFields = this.props.dashboardInfo.metadata + .filterImmuneSliceFields; this.getAllCharts().forEach(chart => { // filterKey is a string, filter_immune_slices array contains numbers if ( String(chart.id) === filterKey || - filter_immune_slices.includes(chart.id) + filterImmuneSlices.includes(chart.id) ) { return; } - const filter_immune_slice_fields_names = - filter_immune_slice_fields[chart.id] || []; + const filterImmuneSliceFieldsNames = + filterImmuneSliceFields[chart.id] || []; // has filter-able field names if ( currentFilteredNames.length === 0 || currentFilteredNames.some( - name => !filter_immune_slice_fields_names.includes(name), + name => !filterImmuneSliceFieldsNames.includes(name), ) ) { this.props.actions.triggerQuery(true, chart.id); diff --git a/superset/assets/src/dashboard/components/DashboardBuilder.jsx b/superset/assets/src/dashboard/components/DashboardBuilder.jsx index eadaab4e8050..a5e3d2997529 100644 --- a/superset/assets/src/dashboard/components/DashboardBuilder.jsx +++ b/superset/assets/src/dashboard/components/DashboardBuilder.jsx @@ -44,6 +44,8 @@ import { DASHBOARD_ROOT_ID, DASHBOARD_ROOT_DEPTH, } from '../util/constants'; +import getDirectPathToTabIndex from '../util/getDirectPathToTabIndex'; +import getLeafComponentIdFromPath from '../util/getLeafComponentIdFromPath'; const TABS_HEIGHT = 47; const HEADER_HEIGHT = 67; @@ -55,11 +57,11 @@ const propTypes = { editMode: PropTypes.bool.isRequired, showBuilderPane: PropTypes.func.isRequired, builderPaneType: PropTypes.string.isRequired, - setColorSchemeAndUnsavedChanges: PropTypes.func.isRequired, colorScheme: PropTypes.string, + setColorSchemeAndUnsavedChanges: PropTypes.func.isRequired, handleComponentDrop: PropTypes.func.isRequired, - toggleBuilderPane: PropTypes.func.isRequired, directPathToChild: PropTypes.arrayOf(PropTypes.string), + setDirectPathToChild: PropTypes.func.isRequired, }; const defaultProps = { @@ -78,23 +80,38 @@ class DashboardBuilder extends React.Component { ); } + static getRootLevelTabIndex(dashboardLayout, directPathToChild) { + return Math.max( + 0, + findTabIndexByComponentId({ + currentComponent: DashboardBuilder.getRootLevelTabsComponent( + dashboardLayout, + ), + directPathToChild, + }), + ); + } + + static getRootLevelTabsComponent(dashboardLayout) { + const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID]; + const rootChildId = dashboardRoot.children[0]; + return rootChildId === DASHBOARD_GRID_ID + ? dashboardLayout[DASHBOARD_ROOT_ID] + : dashboardLayout[rootChildId]; + } + constructor(props) { super(props); const { dashboardLayout, directPathToChild } = props; - const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID]; - const rootChildId = dashboardRoot.children[0]; - const tabIndex = findTabIndexByComponentId({ - currentComponent: - rootChildId === DASHBOARD_GRID_ID - ? dashboardLayout[DASHBOARD_ROOT_ID] - : dashboardLayout[rootChildId], + const tabIndex = DashboardBuilder.getRootLevelTabIndex( + dashboardLayout, directPathToChild, - }); - + ); this.state = { tabIndex, }; + this.handleChangeTab = this.handleChangeTab.bind(this); this.handleDeleteTopLevelTabs = this.handleDeleteTopLevelTabs.bind(this); } @@ -105,20 +122,37 @@ class DashboardBuilder extends React.Component { }; } + componentWillReceiveProps(nextProps) { + const nextFocusComponent = getLeafComponentIdFromPath( + nextProps.directPathToChild, + ); + const currentFocusComponent = getLeafComponentIdFromPath( + this.props.directPathToChild, + ); + if (nextFocusComponent !== currentFocusComponent) { + const { dashboardLayout, directPathToChild } = nextProps; + const nextTabIndex = DashboardBuilder.getRootLevelTabIndex( + dashboardLayout, + directPathToChild, + ); + + this.setState(() => ({ tabIndex: nextTabIndex })); + } + } + handleDeleteTopLevelTabs() { this.props.deleteTopLevelTabs(); - this.setState({ tabIndex: 0 }); + + const { dashboardLayout } = this.props; + const firstTab = getDirectPathToTabIndex( + DashboardBuilder.getRootLevelTabsComponent(dashboardLayout), + 0, + ); + this.props.setDirectPathToChild(firstTab); } - handleChangeTab({ tabIndex }) { - this.setState(() => ({ tabIndex })); - setTimeout(() => { - if (window) - window.scrollTo({ - top: 0, - behavior: 'smooth', - }); - }, 100); + handleChangeTab({ pathToTabIndex }) { + this.props.setDirectPathToChild(pathToTabIndex); } render() { diff --git a/superset/assets/src/dashboard/components/DashboardGrid.jsx b/superset/assets/src/dashboard/components/DashboardGrid.jsx index 0666f477380a..4f74b65f5f3a 100644 --- a/superset/assets/src/dashboard/components/DashboardGrid.jsx +++ b/superset/assets/src/dashboard/components/DashboardGrid.jsx @@ -32,6 +32,7 @@ const propTypes = { handleComponentDrop: PropTypes.func.isRequired, isComponentVisible: PropTypes.bool.isRequired, resizeComponent: PropTypes.func.isRequired, + setDirectPathToChild: PropTypes.func.isRequired, width: PropTypes.number.isRequired, }; @@ -51,6 +52,7 @@ class DashboardGrid extends React.PureComponent { this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this); this.getRowGuidePosition = this.getRowGuidePosition.bind(this); this.setGridRef = this.setGridRef.bind(this); + this.handleChangeTab = this.handleChangeTab.bind(this); } getRowGuidePosition(resizeRef) { @@ -108,6 +110,10 @@ class DashboardGrid extends React.PureComponent { } } + handleChangeTab({ pathToTabIndex }) { + this.props.setDirectPathToChild(pathToTabIndex); + } + render() { const { gridComponent, @@ -160,6 +166,7 @@ class DashboardGrid extends React.PureComponent { onResizeStart={this.handleResizeStart} onResize={this.handleResize} onResizeStop={this.handleResizeStop} + onChangeTab={this.handleChangeTab} /> ))} diff --git a/superset/assets/src/dashboard/components/FilterIndicator.jsx b/superset/assets/src/dashboard/components/FilterIndicator.jsx new file mode 100644 index 000000000000..5fd238d867b7 --- /dev/null +++ b/superset/assets/src/dashboard/components/FilterIndicator.jsx @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { t } from '@superset-ui/translation'; + +import { filterIndicatorPropShape } from '../util/propShapes'; +import FilterBadgeIcon from '../../components/FilterBadgeIcon'; +import FilterIndicatorTooltip from './FilterIndicatorTooltip'; +import FilterTooltipWrapper from './FilterTooltipWrapper'; + +const propTypes = { + indicator: filterIndicatorPropShape.isRequired, + setDirectPathToChild: PropTypes.func.isRequired, +}; + +class FilterIndicator extends React.PureComponent { + constructor(props) { + super(props); + + const { indicator, setDirectPathToChild } = props; + const { directPathToFilter } = indicator; + this.focusToFilterComponent = setDirectPathToChild.bind( + this, + directPathToFilter, + ); + } + + render() { + const { colorCode, label, values } = this.props.indicator; + + const filterTooltip = ( + + ); + + return ( + +
+
+ +
+ + ); + } +} + +FilterIndicator.propTypes = propTypes; + +export default FilterIndicator; diff --git a/superset/assets/src/dashboard/components/FilterIndicatorGroup.jsx b/superset/assets/src/dashboard/components/FilterIndicatorGroup.jsx new file mode 100644 index 000000000000..bc7a22c862b9 --- /dev/null +++ b/superset/assets/src/dashboard/components/FilterIndicatorGroup.jsx @@ -0,0 +1,77 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { t } from '@superset-ui/translation'; + +import FilterBadgeIcon from '../../components/FilterBadgeIcon'; +import FilterIndicatorTooltip from './FilterIndicatorTooltip'; +import FilterTooltipWrapper from './FilterTooltipWrapper'; +import { filterIndicatorPropShape } from '../util/propShapes'; + +const propTypes = { + indicators: PropTypes.arrayOf(filterIndicatorPropShape).isRequired, + setDirectPathToChild: PropTypes.func.isRequired, +}; + +class FilterIndicatorGroup extends React.PureComponent { + constructor(props) { + super(props); + + const { indicators, setDirectPathToChild } = this.props; + this.onClickIcons = indicators.map(indicator => + setDirectPathToChild.bind(this, indicator.directPathToFilter), + ); + } + + render() { + const { indicators } = this.props; + return ( + +
+ {t('%s more filters', indicators.length)} +
+
    + {indicators.map((indicator, index) => ( +
  • + +
  • + ))} +
+ + } + > +
+
+ +
+ + ); + } +} + +FilterIndicatorGroup.propTypes = propTypes; + +export default FilterIndicatorGroup; diff --git a/superset/assets/src/dashboard/components/FilterIndicatorTooltip.jsx b/superset/assets/src/dashboard/components/FilterIndicatorTooltip.jsx new file mode 100644 index 000000000000..6aab00d9073a --- /dev/null +++ b/superset/assets/src/dashboard/components/FilterIndicatorTooltip.jsx @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { t } from '@superset-ui/translation'; +import { isEmpty } from 'lodash'; + +import FilterEditIcon from '../../components/FilterEditIcon'; + +const propTypes = { + label: PropTypes.string.isRequired, + values: PropTypes.array.isRequired, + clickIconHandler: PropTypes.func, +}; + +const defaultProps = { + clickIconHandler: undefined, +}; + +export default function FilterIndicatorTooltip({ + label, + values, + clickIconHandler, +}) { + const displayValue = isEmpty(values) ? t('Not filtered') : values.join(', '); + + return ( +
+
+ + {displayValue} +
+ + {clickIconHandler && ( + + )} +
+ ); +} + +FilterIndicatorTooltip.propTypes = propTypes; +FilterIndicatorTooltip.defaultProps = defaultProps; diff --git a/superset/assets/src/dashboard/components/FilterIndicatorsContainer.jsx b/superset/assets/src/dashboard/components/FilterIndicatorsContainer.jsx new file mode 100644 index 000000000000..09a74780f4c9 --- /dev/null +++ b/superset/assets/src/dashboard/components/FilterIndicatorsContainer.jsx @@ -0,0 +1,181 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; + +import FilterIndicator from './FilterIndicator'; +import FilterIndicatorGroup from './FilterIndicatorGroup'; +import { FILTER_INDICATORS_DISPLAY_LENGTH } from '../util/constants'; +import { + getFilterColorKey, + getFilterColorMap, +} from '../util/dashboardFiltersColorMap'; + +const propTypes = { + // from props + dashboardFilters: PropTypes.object.isRequired, + chartId: PropTypes.number.isRequired, + chartStatus: PropTypes.string, + + // from redux + filterImmuneSlices: PropTypes.arrayOf(PropTypes.number).isRequired, + filterImmuneSliceFields: PropTypes.object.isRequired, + setDirectPathToChild: PropTypes.func.isRequired, +}; + +const defaultProps = { + chartStatus: 'loading', +}; + +function sortByIndicatorLabel(indicator1, indicator2) { + const s1 = (indicator1.label || indicator1.name).toLowerCase(); + const s2 = (indicator2.label || indicator2.name).toLowerCase(); + if (s1 < s2) { + return -1; + } else if (s1 > s2) { + return 1; + } + return 0; +} + +export default class FilterIndicatorsContainer extends React.PureComponent { + getFilterIndicators() { + const { + dashboardFilters, + chartId: currentChartId, + filterImmuneSlices, + filterImmuneSliceFields, + } = this.props; + + if (Object.keys(dashboardFilters).length === 0) { + return []; + } + + const dashboardFiltersColorMap = getFilterColorMap(); + + const sortIndicatorsByEmptiness = Object.values(dashboardFilters).reduce( + (indicators, dashboardFilter) => { + const { + chartId, + componentId, + directPathToFilter, + scope, + isDateFilter, + isInstantFilter, + columns, + labels, + } = dashboardFilter; + + // do not apply filter on filter_box itself + // do not apply filter on filterImmuneSlices list + if ( + currentChartId !== chartId && + !filterImmuneSlices.includes(currentChartId) + ) { + Object.keys(columns).forEach(name => { + const colorMapKey = getFilterColorKey(chartId, name); + const directPathToLabel = directPathToFilter.slice(); + directPathToLabel.push(`LABEL-${name}`); + const indicator = { + chartId, + colorCode: dashboardFiltersColorMap[colorMapKey], + componentId, + directPathToFilter: directPathToLabel, + scope, + isDateFilter, + isInstantFilter, + name, + label: labels[name] || name, + values: + isEmpty(columns[name]) || + (isDateFilter && columns[name] === 'No filter') + ? [] + : [].concat(columns[name]), + }; + + // do not apply filter on fields in the filterImmuneSliceFields map + if ( + filterImmuneSliceFields[currentChartId] && + filterImmuneSliceFields[currentChartId].includes(name) + ) { + return; + } + + if (isEmpty(indicator.values)) { + indicators[1].push(indicator); + } else { + indicators[0].push(indicator); + } + }); + } + + return indicators; + }, + [[], []], + ); + + // cypress' electron don't support [].flat(): + return [ + ...sortIndicatorsByEmptiness[0].sort(sortByIndicatorLabel), + ...sortIndicatorsByEmptiness[1].sort(sortByIndicatorLabel), + ]; + } + + render() { + const { chartStatus, setDirectPathToChild } = this.props; + if (chartStatus === 'loading') { + return null; + } + + const indicators = this.getFilterIndicators(); + // if total indicators <= 5, show all + // else: show top 4 indicators, and show all the rest in group + const showExtraIndicatorsInGroup = + indicators.length > FILTER_INDICATORS_DISPLAY_LENGTH + 1; + + return ( +
+ {indicators + .filter((indicator, index) => { + if (showExtraIndicatorsInGroup) { + return index < FILTER_INDICATORS_DISPLAY_LENGTH; + } + return true; + }) + .map(indicator => ( + + ))} + {showExtraIndicatorsInGroup && ( + + )} +
+ ); + } +} + +FilterIndicatorsContainer.propTypes = propTypes; +FilterIndicatorsContainer.defaultProps = defaultProps; diff --git a/superset/assets/src/dashboard/components/FilterTooltipWrapper.jsx b/superset/assets/src/dashboard/components/FilterTooltipWrapper.jsx new file mode 100644 index 000000000000..953f3cd78c74 --- /dev/null +++ b/superset/assets/src/dashboard/components/FilterTooltipWrapper.jsx @@ -0,0 +1,82 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Overlay, Tooltip } from 'react-bootstrap'; + +const propTypes = { + tooltip: PropTypes.node.isRequired, + children: PropTypes.node.isRequired, +}; + +class FilterTooltipWrapper extends React.Component { + constructor(props) { + super(props); + + // internal instance variable to make tooltip show/hide have delay + this.isHover = false; + this.state = { + show: false, + }; + + this.showTooltip = this.showTooltip.bind(this); + this.hideTooltip = this.hideTooltip.bind(this); + this.attachRef = target => this.setState({ target }); + } + + showTooltip() { + this.isHover = true; + + setTimeout(() => this.isHover && this.setState({ show: true }), 100); + } + + hideTooltip() { + this.isHover = false; + + setTimeout(() => !this.isHover && this.setState({ show: false }), 300); + } + + render() { + const { show, target } = this.state; + return ( + + + +
+ {this.props.tooltip} +
+
+
+ +
+ {this.props.children} +
+
+ ); + } +} + +FilterTooltipWrapper.propTypes = propTypes; + +export default FilterTooltipWrapper; diff --git a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx index 7490faab25f8..61412f3e308f 100644 --- a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx +++ b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx @@ -40,7 +40,7 @@ const propTypes = { isComponentVisible: PropTypes.bool, // from redux - chart: PropTypes.shape(chartPropShape).isRequired, + chart: chartPropShape.isRequired, formData: PropTypes.object.isRequired, datasource: PropTypes.object.isRequired, slice: slicePropShape.isRequired, @@ -50,7 +50,7 @@ const propTypes = { refreshChart: PropTypes.func.isRequired, logEvent: PropTypes.func.isRequired, toggleExpandSlice: PropTypes.func.isRequired, - addFilter: PropTypes.func.isRequired, + changeFilter: PropTypes.func.isRequired, editMode: PropTypes.bool.isRequired, isExpanded: PropTypes.bool.isRequired, isCached: PropTypes.bool, @@ -72,6 +72,7 @@ const SHOULD_UPDATE_ON_PROP_CHANGES = Object.keys(propTypes).filter( prop => prop !== 'width' && prop !== 'height', ); const OVERFLOWABLE_VIZ_TYPES = new Set(['filter_box']); +const DEFAULT_HEADER_HEIGHT = 22; class Chart extends React.Component { constructor(props) { @@ -81,7 +82,7 @@ class Chart extends React.Component { height: props.height, }; - this.addFilter = this.addFilter.bind(this); + this.changeFilter = this.changeFilter.bind(this); this.exploreChart = this.exploreChart.bind(this); this.exportCSV = this.exportCSV.bind(this); this.forceRefresh = this.forceRefresh.bind(this); @@ -142,7 +143,9 @@ class Chart extends React.Component { } getHeaderHeight() { - return (this.headerRef && this.headerRef.offsetHeight) || 30; + return ( + (this.headerRef && this.headerRef.offsetHeight) || DEFAULT_HEADER_HEIGHT + ); } setDescriptionRef(ref) { @@ -158,15 +161,12 @@ class Chart extends React.Component { this.setState(() => ({ width, height })); } - addFilter(...[col, vals, merge, refresh]) { + changeFilter(newSelectedValues = {}) { this.props.logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, { id: this.props.chart.id, - column: col, - value_count: Array.isArray(vals) ? vals.length : (vals && 1) || 0, - merge, - refresh, + columns: Object.keys(newSelectedValues), }); - this.props.addFilter(this.props.chart, col, vals, merge, refresh); + this.props.changeFilter(this.props.chart.id, newSelectedValues); } exploreChart() { @@ -277,7 +277,7 @@ class Chart extends React.Component { + {`.inFocus label[for=${labelName}] + .Select .Select-control { + border: 2px solid #00736a; + }`} + + ); + } + constructor(props) { super(props); this.state = { @@ -72,6 +88,27 @@ class ChartHolder extends React.Component { this.handleUpdateSliceName = this.handleUpdateSliceName.bind(this); } + getChartAndLabelComponentIdFromPath() { + const { directPathToChild = [] } = this.props; + const result = {}; + + if (directPathToChild.length > 0) { + const currentPath = directPathToChild.slice(); + + while (currentPath.length) { + const componentId = currentPath.pop(); + const componentType = componentId.split('-')[0]; + + result[componentType.toLowerCase()] = componentId; + if (!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)) { + break; + } + } + } + + return result; + } + handleChangeFocus(nextFocus) { this.setState(() => ({ isFocused: nextFocus })); } @@ -118,6 +155,12 @@ class ChartHolder extends React.Component { ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT : component.meta.width || GRID_MIN_COLUMN_COUNT; + const { + label: labelName, + chart: chartComponentId, + } = this.getChartAndLabelComponentIdFromPath(); + const inFocus = chartComponentId === component.id; + return (
- {!editMode && } + {!editMode && ( + + )} + {inFocus && ChartHolder.renderInFocusCSS(labelName)} + {!editMode && ( + + )} {editMode && ( ({ isFocused: nextFocus })); } + handleChangeTab({ pathToTabIndex }) { + this.props.setDirectPathToChild(pathToTabIndex); + } + handleChangeText(nextTabText) { const { updateComponents, component } = this.props; if (nextTabText && nextTabText !== component.meta.text) { @@ -171,6 +177,7 @@ export default class Tab extends React.PureComponent { onResize={onResize} onResizeStop={onResizeStop} isComponentVisible={isComponentVisible} + onChangeTab={this.handleChangeTab} /> ))} {/* Make bottom of tab droppable */} diff --git a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx index ce40b5387a40..c8ffa4124921 100644 --- a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx +++ b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx @@ -26,6 +26,8 @@ import DashboardComponent from '../../containers/DashboardComponent'; import DeleteComponentButton from '../DeleteComponentButton'; import HoverMenu from '../menu/HoverMenu'; import findTabIndexByComponentId from '../../util/findTabIndexByComponentId'; +import getDirectPathToTabIndex from '../../util/getDirectPathToTabIndex'; +import getLeafComponentIdFromPath from '../../util/getLeafComponentIdFromPath'; import { componentShape } from '../../util/propShapes'; import { NEW_TAB_ID, DASHBOARD_ROOT_ID } from '../../util/constants'; import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab'; @@ -58,7 +60,7 @@ const propTypes = { // dnd createComponent: PropTypes.func.isRequired, handleComponentDrop: PropTypes.func.isRequired, - onChangeTab: PropTypes.func, + onChangeTab: PropTypes.func.isRequired, deleteComponent: PropTypes.func.isRequired, updateComponents: PropTypes.func.isRequired, }; @@ -70,7 +72,6 @@ const defaultProps = { availableColumnCount: 0, columnWidth: 0, directPathToChild: [], - onChangeTab() {}, onResizeStart() {}, onResize() {}, onResizeStop() {}, @@ -79,10 +80,13 @@ const defaultProps = { class Tabs extends React.PureComponent { constructor(props) { super(props); - const tabIndex = findTabIndexByComponentId({ - currentComponent: props.component, - directPathToChild: props.directPathToChild, - }); + const tabIndex = Math.max( + 0, + findTabIndexByComponentId({ + currentComponent: props.component, + directPathToChild: props.directPathToChild, + }), + ); this.state = { tabIndex, @@ -98,14 +102,37 @@ class Tabs extends React.PureComponent { if (this.state.tabIndex > maxIndex) { this.setState(() => ({ tabIndex: maxIndex })); } + + if (nextProps.isComponentVisible) { + const nextFocusComponent = getLeafComponentIdFromPath( + nextProps.directPathToChild, + ); + const currentFocusComponent = getLeafComponentIdFromPath( + this.props.directPathToChild, + ); + + if (nextFocusComponent !== currentFocusComponent) { + const nextTabIndex = findTabIndexByComponentId({ + currentComponent: nextProps.component, + directPathToChild: nextProps.directPathToChild, + }); + + // make sure nextFocusComponent is under this tabs component + if (nextTabIndex > -1 && nextTabIndex !== this.state.tabIndex) { + this.setState(() => ({ tabIndex: nextTabIndex })); + } + } + } } handleClickTab(tabIndex, ev) { - const target = ev.target; - // special handler for clicking on anchor link icon (or whitespace nearby): - // will show short link popover but do not change tab - if (target.classList.contains('short-link-trigger')) { - return; + if (ev) { + const target = ev.target; + // special handler for clicking on anchor link icon (or whitespace nearby): + // will show short link popover but do not change tab + if (target && target.classList.contains('short-link-trigger')) { + return; + } } const { component, createComponent } = this.props; @@ -128,8 +155,8 @@ class Tabs extends React.PureComponent { index: tabIndex, }); - this.setState(() => ({ tabIndex })); - this.props.onChangeTab({ tabIndex, tabId: component.children[tabIndex] }); + const pathToTabIndex = getDirectPathToTabIndex(component, tabIndex); + this.props.onChangeTab({ pathToTabIndex }); } } diff --git a/superset/assets/src/dashboard/containers/Chart.jsx b/superset/assets/src/dashboard/containers/Chart.jsx index ae0d15f022c4..c7e151cf98e5 100644 --- a/superset/assets/src/dashboard/containers/Chart.jsx +++ b/superset/assets/src/dashboard/containers/Chart.jsx @@ -19,14 +19,13 @@ import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; -import { - changeFilter as addFilter, - toggleExpandSlice, -} from '../actions/dashboardState'; +import { toggleExpandSlice } from '../actions/dashboardState'; import { updateComponents } from '../actions/dashboardLayout'; +import { changeFilter } from '../actions/dashboardFilters'; import { addDangerToast } from '../../messageToasts/actions'; import { refreshChart } from '../../chart/chartAction'; import { logEvent } from '../../logger/actions'; +import { getActiveFilters } from '../util/activeDashboardFilters'; import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters'; import Chart from '../components/gridComponents/Chart'; @@ -44,7 +43,8 @@ function mapStateToProps( ) { const { id } = ownProps; const chart = chartQueries[id] || {}; - const { filters, colorScheme, colorNamespace } = dashboardState; + const { colorScheme, colorNamespace } = dashboardState; + const filters = getActiveFilters(); return { chart, @@ -77,7 +77,7 @@ function mapDispatchToProps(dispatch) { updateComponents, addDangerToast, toggleExpandSlice, - addFilter, + changeFilter, refreshChart, logEvent, }, diff --git a/superset/assets/src/dashboard/containers/Dashboard.jsx b/superset/assets/src/dashboard/containers/Dashboard.jsx index c1609a9cfd66..c602cd32dca7 100644 --- a/superset/assets/src/dashboard/containers/Dashboard.jsx +++ b/superset/assets/src/dashboard/containers/Dashboard.jsx @@ -28,6 +28,7 @@ import { import { triggerQuery } from '../../chart/chartAction'; import { logEvent } from '../../logger/actions'; import getLoadStatsPerTopLevelComponent from '../util/logging/getLoadStatsPerTopLevelComponent'; +import { getActiveFilters } from '../util/activeDashboardFilters'; function mapStateToProps(state) { const { @@ -48,6 +49,11 @@ function mapStateToProps(state) { dashboardState, charts, datasources, + // filters prop: All the filter_box's state in this dashboard + // When dashboard is first loaded into browser, + // its value is from preselect_filters that dashboard owner saved in dashboard's meta data + // When user start interacting with dashboard, it will be user picked values from all filter_box + filters: getActiveFilters(), slices: sliceEntities.slices, layout: dashboardLayout.present, impressionId, diff --git a/superset/assets/src/dashboard/containers/DashboardBuilder.jsx b/superset/assets/src/dashboard/containers/DashboardBuilder.jsx index cb3ca3ee898a..6bc81381880e 100644 --- a/superset/assets/src/dashboard/containers/DashboardBuilder.jsx +++ b/superset/assets/src/dashboard/containers/DashboardBuilder.jsx @@ -23,6 +23,7 @@ import DashboardBuilder from '../components/DashboardBuilder'; import { setColorSchemeAndUnsavedChanges, showBuilderPane, + setDirectPathToChild, } from '../actions/dashboardState'; import { deleteTopLevelTabs, @@ -47,6 +48,7 @@ function mapDispatchToProps(dispatch) { handleComponentDrop, showBuilderPane, setColorSchemeAndUnsavedChanges, + setDirectPathToChild, }, dispatch, ); diff --git a/superset/assets/src/dashboard/containers/DashboardComponent.jsx b/superset/assets/src/dashboard/containers/DashboardComponent.jsx index 2bd306033d6d..62fc57507465 100644 --- a/superset/assets/src/dashboard/containers/DashboardComponent.jsx +++ b/superset/assets/src/dashboard/containers/DashboardComponent.jsx @@ -23,6 +23,7 @@ import { connect } from 'react-redux'; import ComponentLookup from '../components/gridComponents'; import getDetailedComponentWidth from '../util/getDetailedComponentWidth'; +import { getActiveFilters } from '../util/activeDashboardFilters'; import { componentShape } from '../util/propShapes'; import { COLUMN_TYPE, ROW_TYPE } from '../util/componentTypes'; @@ -32,7 +33,7 @@ import { updateComponents, handleComponentDrop, } from '../actions/dashboardLayout'; - +import { setDirectPathToChild } from '../actions/dashboardState'; import { logEvent } from '../../logger/actions'; const propTypes = { @@ -62,7 +63,7 @@ function mapStateToProps( component, parentComponent: dashboardLayout[parentId], editMode: dashboardState.editMode, - filters: dashboardState.filters, + filters: getActiveFilters(), directPathToChild: dashboardState.directPathToChild, }; @@ -91,6 +92,7 @@ function mapDispatchToProps(dispatch) { deleteComponent, updateComponents, handleComponentDrop, + setDirectPathToChild, logEvent, }, dispatch, diff --git a/superset/assets/src/dashboard/containers/DashboardGrid.jsx b/superset/assets/src/dashboard/containers/DashboardGrid.jsx index 7d4f78422bf5..95b1216352c6 100644 --- a/superset/assets/src/dashboard/containers/DashboardGrid.jsx +++ b/superset/assets/src/dashboard/containers/DashboardGrid.jsx @@ -24,6 +24,7 @@ import { handleComponentDrop, resizeComponent, } from '../actions/dashboardLayout'; +import { setDirectPathToChild } from '../actions/dashboardState'; function mapStateToProps({ dashboardState }) { return { @@ -36,6 +37,7 @@ function mapDispatchToProps(dispatch) { { handleComponentDrop, resizeComponent, + setDirectPathToChild, }, dispatch, ); diff --git a/superset/assets/src/dashboard/containers/DashboardHeader.jsx b/superset/assets/src/dashboard/containers/DashboardHeader.jsx index 614ca178fd54..9d119987497a 100644 --- a/superset/assets/src/dashboard/containers/DashboardHeader.jsx +++ b/superset/assets/src/dashboard/containers/DashboardHeader.jsx @@ -51,8 +51,8 @@ import { } from '../../messageToasts/actions'; import { logEvent } from '../../logger/actions'; - import { DASHBOARD_HEADER_ID } from '../util/constants'; +import { getActiveFilters } from '../util/activeDashboardFilters'; function mapStateToProps({ dashboardLayout: undoableLayout, @@ -65,7 +65,7 @@ function mapStateToProps({ undoLength: undoableLayout.past.length, redoLength: undoableLayout.future.length, layout: undoableLayout.present, - filters: dashboardState.filters, + filters: getActiveFilters(), dashboardTitle: ( (undoableLayout.present[DASHBOARD_HEADER_ID] || {}).meta || {} ).text, diff --git a/superset/assets/src/dashboard/containers/FilterIndicators.jsx b/superset/assets/src/dashboard/containers/FilterIndicators.jsx new file mode 100644 index 000000000000..74f7fd097b3f --- /dev/null +++ b/superset/assets/src/dashboard/containers/FilterIndicators.jsx @@ -0,0 +1,54 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import FilterIndicatorsContainer from '../components/FilterIndicatorsContainer'; +import { setDirectPathToChild } from '../actions/dashboardState'; + +function mapStateToProps( + { dashboardFilters, dashboardInfo, charts }, + ownProps, +) { + const chartId = ownProps.chartId; + const chartStatus = charts[chartId].chartStatus; + + return { + dashboardFilters, + chartId, + chartStatus, + filterImmuneSlices: dashboardInfo.metadata.filterImmuneSlices || [], + filterImmuneSliceFields: + dashboardInfo.metadata.filterImmuneSliceFields || {}, + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators( + { + setDirectPathToChild, + }, + dispatch, + ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(FilterIndicatorsContainer); diff --git a/superset/assets/src/dashboard/reducers/dashboardFilters.js b/superset/assets/src/dashboard/reducers/dashboardFilters.js new file mode 100644 index 000000000000..7cd7c8987e4b --- /dev/null +++ b/superset/assets/src/dashboard/reducers/dashboardFilters.js @@ -0,0 +1,121 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* eslint-disable camelcase */ +import { DASHBOARD_ROOT_ID } from '../util/constants'; +import { + ADD_FILTER, + REMOVE_FILTER, + CHANGE_FILTER, + UPDATE_DIRECT_PATH_TO_FILTER, +} from '../actions/dashboardFilters'; +import { TIME_RANGE } from '../../visualizations/FilterBox/FilterBox'; +import getFilterConfigsFromFormdata from '../util/getFilterConfigsFromFormdata'; +import { buildFilterColorMap } from '../util/dashboardFiltersColorMap'; +import { buildActiveFilters } from '../util/activeDashboardFilters'; + +export const dashboardFilter = { + chartId: 0, + componentId: '', + directPathToFilter: [], + scope: DASHBOARD_ROOT_ID, + isDateFilter: false, + isInstantFilter: true, + columns: {}, +}; + +export default function dashboardFiltersReducer(dashboardFilters = {}, action) { + const actionHandlers = { + [ADD_FILTER]() { + const { chartId, component, form_data } = action; + const { columns, labels } = getFilterConfigsFromFormdata(form_data); + const directPathToFilter = component + ? (component.parents || []).slice().concat(component.id) + : []; + + const newFilter = { + ...dashboardFilter, + chartId, + componentId: component.id, + directPathToFilter, + columns, + labels, + isInstantFilter: !!form_data.instant_filtering, + isDateFilter: Object.keys(columns).includes(TIME_RANGE), + }; + + return newFilter; + }, + + [CHANGE_FILTER](state) { + const { newSelectedValues, merge } = action; + const updatedColumns = Object.keys(newSelectedValues).reduce( + (columns, name) => { + // override existed column value, or add new column name + if (!merge || !(name in columns)) { + return { + ...columns, + [name]: newSelectedValues[name], + }; + } + + return { + ...columns, + [name]: [...columns[name], ...newSelectedValues[name]], + }; + }, + { ...state.columns }, + ); + + return { + ...state, + columns: updatedColumns, + }; + }, + + [UPDATE_DIRECT_PATH_TO_FILTER](state) { + const { path } = action; + return { + ...state, + directPathToFilter: path, + }; + }, + }; + + if (action.type === REMOVE_FILTER) { + const { chartId } = action; + const { [chartId]: deletedFilter, ...updatedFilters } = dashboardFilters; + buildActiveFilters(updatedFilters); + buildFilterColorMap(updatedFilters); + + return updatedFilters; + } else if (action.type in actionHandlers) { + const updatedFilters = { + ...dashboardFilters, + [action.chartId]: actionHandlers[action.type]( + dashboardFilters[action.chartId], + ), + }; + buildActiveFilters(updatedFilters); + buildFilterColorMap(updatedFilters); + + return updatedFilters; + } + + return dashboardFilters; +} diff --git a/superset/assets/src/dashboard/reducers/dashboardState.js b/superset/assets/src/dashboard/reducers/dashboardState.js index 4a12ce05fcef..25c001aafc42 100644 --- a/superset/assets/src/dashboard/reducers/dashboardState.js +++ b/superset/assets/src/dashboard/reducers/dashboardState.js @@ -19,7 +19,6 @@ /* eslint-disable camelcase */ import { ADD_SLICE, - CHANGE_FILTER, ON_CHANGE, ON_SAVE, REMOVE_SLICE, @@ -33,6 +32,7 @@ import { TOGGLE_PUBLISHED, UPDATE_CSS, SET_REFRESH_FREQUENCY, + SET_DIRECT_PATH, } from '../actions/dashboardState'; import { BUILDER_PANE_TYPE } from '../util/constants'; @@ -54,19 +54,9 @@ export default function dashboardStateReducer(state = {}, action) { const updatedSliceIds = new Set(state.sliceIds); updatedSliceIds.delete(sliceId); - const key = sliceId; - // if this slice is a filter - const newFilter = { ...state.filters }; - let refresh = false; - if (state.filters[key]) { - delete newFilter[key]; - refresh = true; - } return { ...state, sliceIds: Array.from(updatedSliceIds), - filters: newFilter, - refresh, }; }, [TOGGLE_FAVE_STAR]() { @@ -121,46 +111,6 @@ export default function dashboardStateReducer(state = {}, action) { updatedColorScheme: false, }; }, - - [CHANGE_FILTER]() { - const hasSelectedFilter = state.sliceIds.includes(action.chart.id); - if (!hasSelectedFilter) { - return state; - } - - let filters = state.filters; - const { chart, col, vals: nextVals, merge, refresh } = action; - const sliceId = chart.id; - let newFilter = {}; - if (!(sliceId in filters)) { - // if no filters existed for the slice, set them - newFilter = { [col]: nextVals }; - } else if ((filters[sliceId] && !(col in filters[sliceId])) || !merge) { - // If no filters exist for this column, or we are overwriting them - newFilter = { ...filters[sliceId], [col]: nextVals }; - } else if (filters[sliceId][col] instanceof Array) { - newFilter[col] = [...filters[sliceId][col], ...nextVals]; - } else { - newFilter[col] = [filters[sliceId][col], ...nextVals]; - } - filters = { ...filters, [sliceId]: newFilter }; - - // remove any empty filters so they don't pollute the logs - Object.keys(filters).forEach(chartId => { - Object.keys(filters[chartId]).forEach(column => { - if ( - !filters[chartId][column] || - filters[chartId][column].length === 0 - ) { - delete filters[chartId][column]; - } - }); - if (Object.keys(filters[chartId]).length === 0) { - delete filters[chartId]; - } - }); - return { ...state, filters, refresh }; - }, [SET_UNSAVED_CHANGES]() { const { hasUnsavedChanges } = action.payload; return { ...state, hasUnsavedChanges }; @@ -172,6 +122,12 @@ export default function dashboardStateReducer(state = {}, action) { hasUnsavedChanges: true, }; }, + [SET_DIRECT_PATH]() { + return { + ...state, + directPathToChild: action.path, + }; + }, }; if (action.type in actionHandlers) { diff --git a/superset/assets/src/dashboard/reducers/getInitialState.js b/superset/assets/src/dashboard/reducers/getInitialState.js index 9120a1f7c8ef..2981890b6493 100644 --- a/superset/assets/src/dashboard/reducers/getInitialState.js +++ b/superset/assets/src/dashboard/reducers/getInitialState.js @@ -22,12 +22,11 @@ import shortid from 'shortid'; import { CategoricalColorNamespace } from '@superset-ui/color'; import { chart } from '../../chart/chartReducer'; +import { dashboardFilter } from './dashboardFilters'; import { initSliceEntities } from './sliceEntities'; import { getParam } from '../../modules/utils'; import { applyDefaultFormData } from '../../explore/store'; -import findFirstParentContainerId from '../util/findFirstParentContainer'; -import getEmptyLayout from '../util/getEmptyLayout'; -import newComponentFactory from '../util/newComponentFactory'; +import { buildActiveFilters } from '../util/activeDashboardFilters'; import { BUILDER_PANE_TYPE, DASHBOARD_HEADER_ID, @@ -39,15 +38,22 @@ import { CHART_TYPE, ROW_TYPE, } from '../util/componentTypes'; +import { buildFilterColorMap } from '../util/dashboardFiltersColorMap'; +import findFirstParentContainerId from '../util/findFirstParentContainer'; +import getEmptyLayout from '../util/getEmptyLayout'; +import getFilterConfigsFromFormdata from '../util/getFilterConfigsFromFormdata'; +import getLocationHash from '../util/getLocationHash'; +import newComponentFactory from '../util/newComponentFactory'; +import { TIME_RANGE } from '../../visualizations/FilterBox/FilterBox'; export default function(bootstrapData) { const { user_id, datasources, common, editMode } = bootstrapData; const dashboard = { ...bootstrapData.dashboard_data }; - let filters = {}; + let preselectFilters = {}; try { // allow request parameter overwrite dashboard metadata - filters = JSON.parse( + preselectFilters = JSON.parse( getParam('preselect_filters') || dashboard.metadata.default_filters, ); } catch (e) { @@ -93,6 +99,7 @@ export default function(bootstrapData) { let newSlicesContainerWidth = 0; const chartQueries = {}; + const dashboardFilters = {}; const slices = {}; const sliceIds = new Set(); dashboard.slices.forEach(slice => { @@ -127,21 +134,61 @@ export default function(bootstrapData) { newSlicesContainerWidth === 0 || newSlicesContainerWidth + GRID_DEFAULT_CHART_WIDTH > GRID_COLUMN_COUNT ) { - newSlicesContainer = newComponentFactory(ROW_TYPE); + newSlicesContainer = newComponentFactory( + ROW_TYPE, + (parent.parents || []).slice(), + ); layout[newSlicesContainer.id] = newSlicesContainer; parent.children.push(newSlicesContainer.id); newSlicesContainerWidth = 0; } - const chartHolder = newComponentFactory(CHART_TYPE, { - chartId: slice.slice_id, - }); + const chartHolder = newComponentFactory( + CHART_TYPE, + { + chartId: slice.slice_id, + }, + (newSlicesContainer.parents || []).slice(), + ); layout[chartHolder.id] = chartHolder; newSlicesContainer.children.push(chartHolder.id); chartIdToLayoutId[chartHolder.meta.chartId] = chartHolder.id; newSlicesContainerWidth += GRID_DEFAULT_CHART_WIDTH; } + + // build DashboardFilters for interactive filter features + if (slice.form_data.viz_type === 'filter_box') { + const configs = getFilterConfigsFromFormdata(slice.form_data); + let columns = configs.columns; + const labels = configs.labels; + if (preselectFilters[key]) { + Object.keys(columns).forEach(col => { + if (preselectFilters[key][col]) { + columns = { + ...columns, + [col]: preselectFilters[key][col], + }; + } + }); + } + + const componentId = chartIdToLayoutId[key]; + const directPathToFilter = (layout[componentId].parents || []).slice(); + directPathToFilter.push(componentId); + dashboardFilters[key] = { + ...dashboardFilter, + chartId: key, + componentId, + directPathToFilter, + columns, + labels, + isInstantFilter: !!slice.form_data.instant_filtering, + isDateFilter: Object.keys(columns).includes(TIME_RANGE), + }; + } + buildActiveFilters(dashboardFilters); + buildFilterColorMap(dashboardFilters); } // sync layout names with current slice names in case a slice was edited @@ -169,7 +216,7 @@ export default function(bootstrapData) { }; // find direct link component and path from root - const directLinkComponentId = (window.location.hash || '#').substring(1); + const directLinkComponentId = getLocationHash(); let directPathToChild = []; if (layout[directLinkComponentId]) { directPathToChild = (layout[directLinkComponentId].parents || []).slice(); @@ -185,9 +232,9 @@ export default function(bootstrapData) { id: dashboard.id, slug: dashboard.slug, metadata: { - filter_immune_slice_fields: + filterImmuneSliceFields: dashboard.metadata.filter_immune_slice_fields || {}, - filter_immune_slices: dashboard.metadata.filter_immune_slices || [], + filterImmuneSlices: dashboard.metadata.filter_immune_slices || [], timed_refresh_immune_slices: dashboard.metadata.timed_refresh_immune_slices, }, @@ -202,14 +249,9 @@ export default function(bootstrapData) { conf: common.conf, }, }, + dashboardFilters, dashboardState: { sliceIds: Array.from(sliceIds), - refresh: false, - // All the filter_box's state in this dashboard - // When dashboard is first loaded into browser, - // its value is from preselect_filters that dashboard owner saved in dashboard's meta data - // When user start interacting with dashboard, it will be user picked values from all filter_box - filters, directPathToChild, expandedSlices: dashboard.metadata.expanded_slices || {}, refreshFrequency: dashboard.metadata.refresh_frequency || 0, diff --git a/superset/assets/src/dashboard/reducers/index.js b/superset/assets/src/dashboard/reducers/index.js index dedf23c7c08f..b7a5a2bde247 100644 --- a/superset/assets/src/dashboard/reducers/index.js +++ b/superset/assets/src/dashboard/reducers/index.js @@ -20,6 +20,7 @@ import { combineReducers } from 'redux'; import charts from '../../chart/chartReducer'; import dashboardState from './dashboardState'; +import dashboardFilters from './dashboardFilters'; import datasources from './datasources'; import sliceEntities from './sliceEntities'; import dashboardLayout from '../reducers/undoableDashboardLayout'; @@ -32,6 +33,7 @@ export default combineReducers({ charts, datasources, dashboardInfo, + dashboardFilters, dashboardState, dashboardLayout, impressionId, diff --git a/superset/assets/src/dashboard/stylesheets/filter-indicator-tooltip.less b/superset/assets/src/dashboard/stylesheets/filter-indicator-tooltip.less new file mode 100644 index 000000000000..33dc6e72e8d7 --- /dev/null +++ b/superset/assets/src/dashboard/stylesheets/filter-indicator-tooltip.less @@ -0,0 +1,72 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +#filter-indicator-tooltip { + font-size: 15px; + text-align: left; + + > .tooltip-arrow { + margin-right: 8px; + border-left-color: #484848; + } + + > .tooltip-inner { + width: 200px; + max-width: 200px; + border-radius: 4px; + margin-right: 8px; + padding: 16px 12px; + background-color: #484848; + text-align: left; + } +} + +.tooltip-item { + position: relative; + + .filter-content { + line-height: 22px; + margin-right: 22px; + text-align: left; + + label { + font-weight: 700; + white-space: nowrap; + } + } + + .filter-edit { + position: absolute; + top: 4px; + right: 0; + } +} + +.group-title { + margin-bottom: 8px; +} + +.tooltip-group { + margin: 0; + padding: 0; + list-style: none; + + li { + margin-bottom: 8px; + } +} diff --git a/superset/assets/src/dashboard/stylesheets/filter-indicator.less b/superset/assets/src/dashboard/stylesheets/filter-indicator.less new file mode 100644 index 000000000000..610b4a1fb173 --- /dev/null +++ b/superset/assets/src/dashboard/stylesheets/filter-indicator.less @@ -0,0 +1,94 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +.dashboard-filter-indicators-container { + position: absolute; + right: -20px; + top: 40px; + width: 20px; + height: 125px; + + .indicator-container { + position: relative; + margin-bottom: 4px; + } + + .filter-indicator, + .filter-indicator-group { + width: 3px; + height: 20px; + overflow: hidden; + display: flex; + background-color: #bababa; + transition: width 0.3s; + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; + + .color-bar { + width: 0px; + height: 20px; + position: absolute; + right: 100%; + transition: width 0.3s; + } + + .filter-badge { + width: 20px; + height: 20px; + } + } + + .filter-indicator-group { + box-shadow: rgb(255, 255, 255) -2px 0 0 0, rgb(219, 219, 219) -4px 0 0 0; + } +} + +.inFocus .filter-indicator-group { + box-shadow: rgba(0, 166, 153, 1) -2px 0 0 0, rgb(255, 255, 255) -4px 0 0 0, rgb(219, 219, 219) -6px 0 0 0; +} + +.dashboard-component-chart-holder:hover, +.dashboard-filter-indicators-container:hover { + .filter-indicator, + .filter-indicator-group { + width: 20px; + background-color: transparent; + + .color-bar { + width: 2px; + + &.badge-group { + background-color: rgb(72, 72, 72); + } + } + + .filter-badge { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + + .filter-indicator-group { + box-shadow: rgb(255, 255, 255) -4px 0 0 0, rgb(219, 219, 219) -6px 0 0 0; + } +} + +.inFocus { + border-radius: 4px; + box-shadow: inset 0 0 0 2px rgba(0, 166, 153, 1), 0 0 0 3px rgba(0, 166, 153, .15); +} diff --git a/superset/assets/src/dashboard/stylesheets/grid.less b/superset/assets/src/dashboard/stylesheets/grid.less index 0d5b65969a38..270f8ae3705c 100644 --- a/superset/assets/src/dashboard/stylesheets/grid.less +++ b/superset/assets/src/dashboard/stylesheets/grid.less @@ -18,7 +18,7 @@ */ .grid-container { position: relative; - margin: 24px; + margin: 24px 36px 24px; /* without this, the grid will not get smaller upon toggling the builder panel on */ min-width: 0; width: 100%; diff --git a/superset/assets/src/dashboard/stylesheets/index.less b/superset/assets/src/dashboard/stylesheets/index.less index 63c8dd083124..01a0e3cb2eb0 100644 --- a/superset/assets/src/dashboard/stylesheets/index.less +++ b/superset/assets/src/dashboard/stylesheets/index.less @@ -23,6 +23,8 @@ @import './buttons.less'; @import './dashboard.less'; @import './dnd.less'; +@import './filter-indicator.less'; +@import './filter-indicator-tooltip.less'; @import './grid.less'; @import './hover-menu.less'; @import './popover-menu.less'; diff --git a/superset/assets/src/dashboard/stylesheets/variables.less b/superset/assets/src/dashboard/stylesheets/variables.less index 863c4326c77e..31848670805c 100644 --- a/superset/assets/src/dashboard/stylesheets/variables.less +++ b/superset/assets/src/dashboard/stylesheets/variables.less @@ -36,3 +36,40 @@ @success: #00BFA5; @warning: #FFAB00; @danger: @pink; + +/* filter indicators */ +/* make sure be consistent with FILTER_COLORS_COUNT in + dashboardFiltersColorMap.js +*/ +@badge-colors: + #228be6, + #40c057, + #fab005, + #f76707, + #e64980, + #15aabf, + #7950f2, + #fa5252, + #74b816, + #12b886, + #1864ab, + #2b8a3e, + #e67700, + #d9480f, + #a61e4d, + #0b7285, + #5f3dc4, + #c92a2a, + #5c940d, + #087f5b; + +@iterations: length(@badge-colors); +.badge-loop (@i) when (@i > 0) { + .filter-badge.badge-@{i}, + .dashboard-component-chart-holder:hover .color-bar.badge-@{i} { + @value: extract(@badge-colors, @i); + background-color: @value; + } + .badge-loop(@i - 1); +} +.badge-loop (@iterations); diff --git a/superset/assets/src/dashboard/util/activeDashboardFilters.js b/superset/assets/src/dashboard/util/activeDashboardFilters.js new file mode 100644 index 000000000000..8c70577d05ba --- /dev/null +++ b/superset/assets/src/dashboard/util/activeDashboardFilters.js @@ -0,0 +1,50 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +let activeFilters = {}; + +export function getActiveFilters() { + return activeFilters; +} + +// non-empty filters from dashboardFilters, +// this function does not take into account: filter immune or filter scope settings +export function buildActiveFilters(allDashboardFilters = {}) { + activeFilters = Object.values(allDashboardFilters).reduce( + (result, filter) => { + const { chartId, columns } = filter; + + Object.keys(columns).forEach(key => { + if ( + Array.isArray(columns[key]) + ? columns[key].length + : columns[key] !== undefined + ) { + /* eslint-disable no-param-reassign */ + result[chartId] = { + ...result[chartId], + [key]: columns[key], + }; + } + }); + + return result; + }, + {}, + ); +} diff --git a/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js b/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js index 312bd9dabf3b..fa6ad2a5033c 100644 --- a/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js +++ b/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js @@ -21,7 +21,7 @@ export default function getEffectiveExtraFilters({ filters, sliceId, }) { - const immuneSlices = dashboardMetadata.filter_immune_slices || []; + const immuneSlices = dashboardMetadata.filterImmuneSlices || []; if (sliceId && immuneSlices.includes(sliceId)) { // The slice is immune to dashboard filters @@ -33,10 +33,10 @@ export default function getEffectiveExtraFilters({ let immuneToFields = []; if ( sliceId && - dashboardMetadata.filter_immune_slice_fields && - dashboardMetadata.filter_immune_slice_fields[sliceId] + dashboardMetadata.filterImmuneSliceFields && + dashboardMetadata.filterImmuneSliceFields[sliceId] ) { - immuneToFields = dashboardMetadata.filter_immune_slice_fields[sliceId]; + immuneToFields = dashboardMetadata.filterImmuneSliceFields[sliceId]; } Object.keys(filters).forEach(filteringSliceId => { diff --git a/superset/assets/src/dashboard/util/constants.js b/superset/assets/src/dashboard/util/constants.js index 9b33ca8991e6..be98e2fdf14f 100644 --- a/superset/assets/src/dashboard/util/constants.js +++ b/superset/assets/src/dashboard/util/constants.js @@ -69,3 +69,10 @@ export const BUILDER_PANE_TYPE = { ADD_COMPONENTS: 'ADD_COMPONENTS', COLORS: 'COLORS', }; + +// filter indicators display length +export const FILTER_INDICATORS_DISPLAY_LENGTH = 4; + +// in-component element types: can be added into +// directPathToChild, used for in dashboard navigation and focus +export const IN_COMPONENT_ELEMENT_TYPES = ['LABEL']; diff --git a/superset/assets/src/dashboard/util/dashboardFiltersColorMap.js b/superset/assets/src/dashboard/util/dashboardFiltersColorMap.js new file mode 100644 index 000000000000..bb8f762fbd3f --- /dev/null +++ b/superset/assets/src/dashboard/util/dashboardFiltersColorMap.js @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +// should be consistent with @badge-colors .less variable +const FILTER_COLORS_COUNT = 20; + +let filterColorMap = {}; + +export function getFilterColorKey(chartId, column) { + return `${chartId}_${column}`; +} + +export function getFilterColorMap() { + return filterColorMap; +} + +export function buildFilterColorMap(allDashboardFilters = {}) { + let filterColorIndex = 1; + filterColorMap = Object.values(allDashboardFilters).reduce( + (colorMap, filter) => { + const { chartId, columns } = filter; + + Object.keys(columns) + .sort() + .forEach(column => { + const key = getFilterColorKey(chartId, column); + const colorCode = `badge-${filterColorIndex % FILTER_COLORS_COUNT}`; + /* eslint-disable no-param-reassign */ + colorMap[key] = colorCode; + + filterColorIndex += 1; + }); + + return colorMap; + }, + {}, + ); +} diff --git a/superset/assets/src/dashboard/util/findTabIndexByComponentId.js b/superset/assets/src/dashboard/util/findTabIndexByComponentId.js index eed141e44f58..ac644e43fcd7 100644 --- a/superset/assets/src/dashboard/util/findTabIndexByComponentId.js +++ b/superset/assets/src/dashboard/util/findTabIndexByComponentId.js @@ -25,7 +25,7 @@ export default function findTabIndexByComponentId({ directPathToChild.length === 0 || directPathToChild.indexOf(currentComponent.id) === -1 ) { - return 0; + return -1; } const currentComponentIdx = directPathToChild.findIndex( @@ -37,5 +37,5 @@ export default function findTabIndexByComponentId({ childId => childId === nextParentId, ); } - return 0; + return -1; } diff --git a/superset/assets/src/dashboard/util/getDirectPathToTabIndex.js b/superset/assets/src/dashboard/util/getDirectPathToTabIndex.js new file mode 100644 index 000000000000..df77c321c0f1 --- /dev/null +++ b/superset/assets/src/dashboard/util/getDirectPathToTabIndex.js @@ -0,0 +1,25 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export default function getDirectPathToTabIndex(tabsComponent, tabIndex) { + const directPathToFilter = (tabsComponent.parents || []).slice(); + directPathToFilter.push(tabsComponent.id); + directPathToFilter.push(tabsComponent.children[tabIndex]); + + return directPathToFilter; +} diff --git a/superset/assets/src/dashboard/util/getFilterConfigsFromFormdata.js b/superset/assets/src/dashboard/util/getFilterConfigsFromFormdata.js new file mode 100644 index 000000000000..12c275cfec1d --- /dev/null +++ b/superset/assets/src/dashboard/util/getFilterConfigsFromFormdata.js @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* eslint-disable camelcase */ +import { + TIME_RANGE, + FILTER_LABELS, +} from '../../visualizations/FilterBox/FilterBox'; + +export default function getFilterConfigsFromFormdata(form_data = {}) { + const { date_filter, filter_configs = [] } = form_data; + let configs = filter_configs.reduce( + ({ columns, labels }, config) => { + const updatedColumns = { + ...columns, + [config.column]: config.vals, + }; + const updatedLabels = { + ...labels, + [config.column]: config.label, + }; + + return { + columns: updatedColumns, + labels: updatedLabels, + }; + }, + { columns: {}, labels: {} }, + ); + + if (date_filter) { + const updatedColumns = { + ...configs.columns, + [TIME_RANGE]: form_data[TIME_RANGE], + }; + const updatedLabels = { + ...configs.labels, + [TIME_RANGE]: FILTER_LABELS[TIME_RANGE], + }; + + configs = { + ...configs, + columns: updatedColumns, + labels: updatedLabels, + }; + } + return configs; +} diff --git a/superset/assets/src/dashboard/util/getLayoutComponentFromChartId.js b/superset/assets/src/dashboard/util/getLayoutComponentFromChartId.js new file mode 100644 index 000000000000..42d317adaf56 --- /dev/null +++ b/superset/assets/src/dashboard/util/getLayoutComponentFromChartId.js @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* eslint-disable no-param-reassign */ +import { CHART_TYPE } from './componentTypes'; + +export default function getLayoutComponentFromChartId(layout, chartId) { + return Object.values(layout).find( + currentComponent => + currentComponent && + currentComponent.type === CHART_TYPE && + currentComponent.meta && + currentComponent.meta.chartId === chartId, + ); +} diff --git a/superset/assets/src/dashboard/util/getLeafComponentIdFromPath.js b/superset/assets/src/dashboard/util/getLeafComponentIdFromPath.js new file mode 100644 index 000000000000..9f8f9911fc71 --- /dev/null +++ b/superset/assets/src/dashboard/util/getLeafComponentIdFromPath.js @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { IN_COMPONENT_ELEMENT_TYPES } from './constants'; + +export default function getLeafComponentIdFromPath(directPathToChild = []) { + if (directPathToChild.length > 0) { + const currentPath = directPathToChild.slice(); + + while (currentPath.length) { + const componentId = currentPath.pop(); + const componentType = componentId.split('-')[0]; + + if (!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)) { + return componentId; + } + } + } + + return null; +} diff --git a/superset/assets/src/dashboard/util/getLocationHash.js b/superset/assets/src/dashboard/util/getLocationHash.js new file mode 100644 index 000000000000..de583aa9541b --- /dev/null +++ b/superset/assets/src/dashboard/util/getLocationHash.js @@ -0,0 +1,21 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export default function getLocationHash() { + return (window.location.hash || '#').substring(1); +} diff --git a/superset/assets/src/dashboard/util/newComponentFactory.js b/superset/assets/src/dashboard/util/newComponentFactory.js index cd9838f40ec0..1c3d2cf16a6e 100644 --- a/superset/assets/src/dashboard/util/newComponentFactory.js +++ b/superset/assets/src/dashboard/util/newComponentFactory.js @@ -57,11 +57,12 @@ function uuid(type) { return `${type}-${shortid.generate()}`; } -export default function entityFactory(type, meta) { +export default function entityFactory(type, meta, parents = []) { return { type, id: uuid(type), children: [], + parents, meta: { ...typeToDefaultMetaData[type], ...meta, diff --git a/superset/assets/src/dashboard/util/propShapes.jsx b/superset/assets/src/dashboard/util/propShapes.jsx index 099feb3bfd98..7bcd8f35aea4 100644 --- a/superset/assets/src/dashboard/util/propShapes.jsx +++ b/superset/assets/src/dashboard/util/propShapes.jsx @@ -66,10 +66,21 @@ export const slicePropShape = PropTypes.shape({ description_markeddown: PropTypes.string, }); +export const filterIndicatorPropShape = PropTypes.shape({ + chartId: PropTypes.number.isRequired, + colorCode: PropTypes.string.isRequired, + componentId: PropTypes.string.isRequired, + directPathToFilter: PropTypes.arrayOf(PropTypes.string).isRequired, + isDateFilter: PropTypes.bool.isRequired, + isInstantFilter: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + scope: PropTypes.string.isRequired, + values: PropTypes.array.isRequired, +}); + export const dashboardStatePropShape = PropTypes.shape({ sliceIds: PropTypes.arrayOf(PropTypes.number).isRequired, - refresh: PropTypes.bool.isRequired, - filters: PropTypes.object.isRequired, expandedSlices: PropTypes.object, editMode: PropTypes.bool, isPublished: PropTypes.bool.isRequired, diff --git a/superset/assets/src/visualizations/FilterBox/FilterBox.css b/superset/assets/src/visualizations/FilterBox/FilterBox.css index dca4bd777d04..97b4ed999de0 100644 --- a/superset/assets/src/visualizations/FilterBox/FilterBox.css +++ b/superset/assets/src/visualizations/FilterBox/FilterBox.css @@ -45,7 +45,7 @@ ul.select2-results div.filter_box{ border-color: transparent; } .filter_box { - padding: 10px; + padding: 10px 0; overflow: visible !important; } .filter_box:hover { @@ -54,3 +54,17 @@ ul.select2-results div.filter_box{ .m-b-5 { margin-bottom: 5px; } +.filter-container { + display: flex; +} +.filter-container label { + display: flex; + font-weight: bold; + margin-bottom: 8px; +} +.filter-container .filter-badge-container { + width: 30px; +} +.filter-container .filter-badge-container + div { + width: 100%; +} diff --git a/superset/assets/src/visualizations/FilterBox/FilterBox.jsx b/superset/assets/src/visualizations/FilterBox/FilterBox.jsx index 4ec01afdbfd8..28983c1e65dc 100644 --- a/superset/assets/src/visualizations/FilterBox/FilterBox.jsx +++ b/superset/assets/src/visualizations/FilterBox/FilterBox.jsx @@ -29,6 +29,9 @@ import Control from '../../explore/components/Control'; import controls from '../../explore/controls'; import OnPasteSelect from '../../components/OnPasteSelect'; import VirtualizedRendererWrap from '../../components/VirtualizedRendererWrap'; +import { getFilterColorKey, getFilterColorMap } from '../../dashboard/util/dashboardFiltersColorMap'; +import FilterBadgeIcon from '../../components/FilterBadgeIcon'; + import './FilterBox.css'; // maps control names to their key in extra_filters @@ -40,9 +43,13 @@ const TIME_FILTER_MAP = { granularity: '__granularity', }; -const TIME_RANGE = '__time_range'; +export const TIME_RANGE = '__time_range'; +export const FILTER_LABELS = { + [TIME_RANGE]: 'Time range', +}; const propTypes = { + chartId: PropTypes.number.isRequired, origSelectedValues: PropTypes.object, datasource: PropTypes.object.isRequired, instantFiltering: PropTypes.bool, @@ -79,6 +86,7 @@ class FilterBox extends React.Component { super(props); this.state = { selectedValues: props.origSelectedValues, + // this flag is used by non-instant filter, to make the apply button enabled/disabled hasChanged: false, }; this.changeFilter = this.changeFilter.bind(this); @@ -100,14 +108,9 @@ class FilterBox extends React.Component { clickApply() { const { selectedValues } = this.state; - Object.keys(selectedValues).forEach((fltr, i, arr) => { - let refresh = false; - if (i === arr.length - 1) { - refresh = true; - } - this.props.onChange(fltr, selectedValues[fltr], false, refresh); + this.setState({ hasChanged: false }, () => { + this.props.onChange(selectedValues, false); }); - this.setState({ hasChanged: false }); } changeFilter(filter, options) { @@ -122,23 +125,29 @@ class FilterBox extends React.Component { vals = options; } } - const selectedValues = Object.assign({}, this.state.selectedValues); - selectedValues[fltr] = vals; - this.setState({ selectedValues, hasChanged: true }); - if (this.props.instantFiltering) { - this.props.onChange(fltr, vals, false, true); - } + const selectedValues = { + ...this.state.selectedValues, + [fltr]: vals, + }; + + this.setState({ selectedValues, hasChanged: true }, () => { + if (this.props.instantFiltering) { + this.props.onChange({ [fltr]: vals }, false); + } + }); } renderDateFilter() { - const { showDateFilter } = this.props; + const { showDateFilter, chartId } = this.props; + const label = t(FILTER_LABELS[TIME_RANGE]); if (showDateFilter) { return (
-
+
+ {this.renderFilterBadge(chartId, TIME_RANGE, label)} { this.changeFilter(TIME_RANGE, ...args); }} value={this.state.selectedValues[TIME_RANGE] || 'No filter'} @@ -255,19 +264,35 @@ class FilterBox extends React.Component { } renderFilters() { - - const { filtersFields } = this.props; + const { filtersFields, chartId } = this.props; return filtersFields.map((filterConfig) => { const { label, key } = filterConfig; return ( -
- {label} - {this.renderSelect(filterConfig)} +
+ {this.renderFilterBadge(chartId, key, label)} +
+ + {this.renderSelect(filterConfig)} +
); }); } + renderFilterBadge(chartId, column) { + const colorKey = getFilterColorKey(chartId, column); + const filterColorMap = getFilterColorMap(); + const colorCode = filterColorMap[colorKey]; + + return ( +
+ +
+ ); + } + render() { const { instantFiltering } = this.props; diff --git a/superset/assets/src/visualizations/FilterBox/transformProps.js b/superset/assets/src/visualizations/FilterBox/transformProps.js index 54e1b1abef9b..7c49225be0f6 100644 --- a/superset/assets/src/visualizations/FilterBox/transformProps.js +++ b/superset/assets/src/visualizations/FilterBox/transformProps.js @@ -26,6 +26,7 @@ export default function transformProps(chartProps) { rawDatasource, } = chartProps; const { + sliceId, dateFilter, instantFiltering, showDruidTimeGranularity, @@ -43,6 +44,7 @@ export default function transformProps(chartProps) { })); return { + chartId: sliceId, datasource: rawDatasource, filtersFields, filtersChoices: payload.data,