/** * 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 { render, screen, userEvent, waitFor, } from 'spec/helpers/testing-library'; import { FeatureFlag, VizType } from '@superset-ui/core'; import mockState from 'spec/fixtures/mockState'; import { cachedSupersetGet } from 'src/utils/cachedSupersetGet'; import SliceHeaderControls, { SliceHeaderControlsProps } from '.'; jest.mock('src/utils/cachedSupersetGet'); const mockCachedSupersetGet = cachedSupersetGet as jest.MockedFunction< typeof cachedSupersetGet >; const SLICE_ID = 371; const createProps = (viz_type = VizType.Sunburst) => ({ addDangerToast: jest.fn(), addSuccessToast: jest.fn(), exploreChart: jest.fn(), exportCSV: jest.fn(), exportFullCSV: jest.fn(), exportXLSX: jest.fn(), exportFullXLSX: jest.fn(), exportPivotExcel: jest.fn(), forceRefresh: jest.fn(), handleToggleFullSize: jest.fn(), toggleExpandSlice: jest.fn(), logEvent: jest.fn(), slice: { slice_id: SLICE_ID, slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20371%7D', slice_name: 'Vaccine Candidates per Country & Stage', slice_description: 'Table of vaccine candidates for 100 countries', form_data: { adhoc_filters: [], color_scheme: 'supersetColors', datasource: '58__table', ...(viz_type === VizType.Sunburst ? { columns: ['product_category', 'clinical_stage'] } : { groupby: ['product_category', 'clinical_stage'] }), linear_color_scheme: 'schemeYlOrBr', metric: 'count', queryFields: { groupby: 'groupby', metric: 'metrics', secondary_metric: 'metrics', }, row_limit: 10000, slice_id: SLICE_ID, time_range: 'No filter', url_params: {}, viz_type, }, viz_type, datasource: '58__table', description: 'test-description', description_markeddown: '', owners: [], modified: '22 hours ago', changed_on: 1617143411523, }, isCached: [false], isExpanded: false, cachedDttm: [''], updatedDttm: 1617213803803, supersetCanExplore: true, supersetCanDownload: true, componentId: 'CHART-fYo7IyvKZQ', dashboardId: 26, isFullSize: false, chartStatus: 'rendered', showControls: true, supersetCanShare: true, formData: { slice_id: 1, datasource: '58__table', viz_type: VizType.Sunburst, }, exploreUrl: '/explore', defaultOpen: true, }) as SliceHeaderControlsProps; const renderWrapper = ( overrideProps?: SliceHeaderControlsProps, roles?: Record, ) => { const props = overrideProps || createProps(); return render(, { useRedux: true, useRouter: true, initialState: { ...mockState, user: { ...mockState.user, roles: roles ?? { Admin: [['can_samples', 'Datasource']], }, }, }, }); }; const openMenu = () => { userEvent.click(screen.getByRole('button', { name: 'More Options' })); }; const mockFullscreenElement = (getElement: () => Element | null) => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: getElement, }); }; beforeEach(() => { mockCachedSupersetGet.mockClear(); mockCachedSupersetGet.mockResolvedValue({ response: {} as Response, json: { result: { columns: [], metrics: [], }, }, }); }); afterEach(() => { Reflect.deleteProperty(document, 'fullscreenElement'); }); test('Should render', () => { renderWrapper(); openMenu(); expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument(); }); test('Should render default props', () => { const props = createProps(); // @ts-expect-error - testing with missing required props delete props.forceRefresh; delete props.toggleExpandSlice; delete props.logExploreChart; delete props.exportCSV; delete props.exportXLSX; // @ts-expect-error - testing with missing required props delete props.cachedDttm; // @ts-expect-error - testing with missing required props delete props.updatedDttm; // @ts-expect-error - testing with missing required props delete props.isCached; delete props.isExpanded; renderWrapper(props); openMenu(); expect(screen.getByText('Enter fullscreen')).toBeInTheDocument(); expect(screen.getByText('Force refresh')).toBeInTheDocument(); expect(screen.getByText('Show chart description')).toBeInTheDocument(); expect(screen.getByText('Edit chart')).toBeInTheDocument(); expect(screen.getByText('Download')).toBeInTheDocument(); expect(screen.getByText('Share')).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'More Options' }), ).toBeInTheDocument(); expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument(); }); test('Should "export to CSV"', async () => { const props = createProps(); renderWrapper(props); openMenu(); expect(props.exportCSV).toHaveBeenCalledTimes(0); userEvent.hover(screen.getByText('Download')); userEvent.click(await screen.findByText('Export to .CSV')); expect(props.exportCSV).toHaveBeenCalledTimes(1); expect(props.exportCSV).toHaveBeenCalledWith(371); }); test('Should "export to Excel"', async () => { const props = createProps(); renderWrapper(props); openMenu(); expect(props.exportXLSX).toHaveBeenCalledTimes(0); userEvent.hover(screen.getByText('Download')); userEvent.click(await screen.findByText('Export to Excel')); expect(props.exportXLSX).toHaveBeenCalledTimes(1); expect(props.exportXLSX).toHaveBeenCalledWith(371); }); test('Export full CSV is under featureflag', async () => { (global as any).featureFlags = { [FeatureFlag.AllowFullCsvExport]: false, }; const props = createProps(VizType.Table); renderWrapper(props); openMenu(); userEvent.hover(screen.getByText('Download')); expect(await screen.findByText('Export to .CSV')).toBeInTheDocument(); expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument(); }); test('Should "export full CSV"', async () => { (global as any).featureFlags = { [FeatureFlag.AllowFullCsvExport]: true, }; const props = createProps(VizType.Table); renderWrapper(props); openMenu(); expect(props.exportFullCSV).toHaveBeenCalledTimes(0); userEvent.hover(screen.getByText('Download')); userEvent.click(await screen.findByText('Export to full .CSV')); expect(props.exportFullCSV).toHaveBeenCalledTimes(1); expect(props.exportFullCSV).toHaveBeenCalledWith(371); }); test('Should not show export full CSV if report is not table', async () => { (global as any).featureFlags = { [FeatureFlag.AllowFullCsvExport]: true, }; renderWrapper(); openMenu(); userEvent.hover(screen.getByText('Download')); expect(await screen.findByText('Export to .CSV')).toBeInTheDocument(); expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument(); }); test('Export full Excel is under featureflag', async () => { (global as any).featureFlags = { [FeatureFlag.AllowFullCsvExport]: false, }; const props = createProps(VizType.Table); renderWrapper(props); openMenu(); userEvent.hover(screen.getByText('Download')); expect(await screen.findByText('Export to Excel')).toBeInTheDocument(); expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument(); }); test('Should "export full Excel"', async () => { (global as any).featureFlags = { [FeatureFlag.AllowFullCsvExport]: true, }; const props = createProps(VizType.Table); renderWrapper(props); openMenu(); expect(props.exportFullXLSX).toHaveBeenCalledTimes(0); userEvent.hover(screen.getByText('Download')); userEvent.click(await screen.findByText('Export to full Excel')); expect(props.exportFullXLSX).toHaveBeenCalledTimes(1); expect(props.exportFullXLSX).toHaveBeenCalledWith(371); }); test('Should not show export full Excel if report is not table', async () => { (global as any).featureFlags = { [FeatureFlag.AllowFullCsvExport]: true, }; renderWrapper(); openMenu(); userEvent.hover(screen.getByText('Download')); expect(await screen.findByText('Export to Excel')).toBeInTheDocument(); expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument(); }); test('Should export to pivoted Excel if report is pivot table', async () => { const props = createProps(VizType.PivotTable); renderWrapper(props); openMenu(); expect(props.exportPivotExcel).toHaveBeenCalledTimes(0); userEvent.hover(screen.getByText('Download')); userEvent.click(await screen.findByText('Export to Pivoted Excel')); expect(props.exportPivotExcel).toHaveBeenCalledTimes(1); expect(props.exportPivotExcel).toHaveBeenCalledWith( '#chart-id-371 .pvtTable', props.slice.slice_name, ); }); test('Should "Show chart description"', () => { const props = createProps(); renderWrapper(props); openMenu(); expect(props.toggleExpandSlice).toHaveBeenCalledTimes(0); userEvent.click(screen.getByText('Show chart description')); expect(props.toggleExpandSlice).toHaveBeenCalledTimes(1); expect(props.toggleExpandSlice).toHaveBeenCalledWith(371); }); test('Should "Force refresh"', () => { const props = createProps(); renderWrapper(props); openMenu(); expect(props.forceRefresh).toHaveBeenCalledTimes(0); userEvent.click(screen.getByText('Force refresh')); expect(props.forceRefresh).toHaveBeenCalledTimes(1); expect(props.forceRefresh).toHaveBeenCalledWith(371, 26); expect(props.addSuccessToast).toHaveBeenCalledTimes(1); }); test('Should sync local state after entering fullscreen', async () => { const mockDiv = document.createElement('div'); let fullscreenElement: Element | null = null; mockFullscreenElement(() => fullscreenElement); mockDiv.requestFullscreen = jest.fn().mockImplementation(async () => { fullscreenElement = mockDiv; }); const originalExitFullscreen = document.exitFullscreen; (document as any).exitFullscreen = jest.fn().mockResolvedValue(undefined); const props = { ...createProps(), chartHolderRef: { current: mockDiv }, }; renderWrapper(props); openMenu(); expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0); const fullscreenItem = screen.getByRole('menuitem', { name: /enter fullscreen/i, }); await userEvent.click(fullscreenItem); expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0); expect(mockDiv.requestFullscreen).toHaveBeenCalled(); document.dispatchEvent(new Event('fullscreenchange')); await waitFor(() => { expect(props.handleToggleFullSize).toHaveBeenCalledTimes(1); }); (document as any).exitFullscreen = originalExitFullscreen; }); test('Should sync local state after exiting fullscreen', async () => { const mockDiv = document.createElement('div'); let fullscreenElement: Element | null = mockDiv; mockFullscreenElement(() => fullscreenElement); const originalExitFullscreen = document.exitFullscreen; (document as any).exitFullscreen = jest.fn().mockImplementation(async () => { fullscreenElement = null; }); const props = { ...createProps(), isFullSize: true, chartHolderRef: { current: mockDiv }, }; renderWrapper(props); openMenu(); const fullscreenItem = screen.getByRole('menuitem', { name: /exit fullscreen/i, }); await userEvent.click(fullscreenItem); expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0); expect(document.exitFullscreen).toHaveBeenCalledTimes(1); document.dispatchEvent(new Event('fullscreenchange')); await waitFor(() => { expect(props.handleToggleFullSize).toHaveBeenCalledTimes(1); }); (document as any).exitFullscreen = originalExitFullscreen; }); test('Drill to detail modal is under featureflag', () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: false, }; const props = createProps(); renderWrapper(props); openMenu(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); test('Should show "Drill to detail" with `can_explore`, `can_samples` & `can_get_drill_info` perms', () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; const props = createProps(); props.slice.slice_id = 18; renderWrapper(props, { Admin: [ ['can_samples', 'Datasource'], ['can_explore', 'Superset'], ['can_get_drill_info', 'Dataset'], ], }); openMenu(); expect(screen.getByText('Drill to detail')).toBeInTheDocument(); }); test('Should show "Drill to detail" with `can_drill` & `can_samples` & `can_get_drill_info` perms', () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [ ['can_samples', 'Datasource'], ['can_drill', 'Dashboard'], ['can_get_drill_info', 'Dataset'], ], }); openMenu(); expect(screen.getByText('Drill to detail')).toBeInTheDocument(); }); test('Should show "Drill to detail" with both `canexplore` + `can_drill` & `can_samples` & `can_get_drill_info` perms', () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; const props = { ...createProps(), supersetCanExplore: true, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [ ['can_samples', 'Datasource'], ['can_explore', 'Superset'], ['can_drill', 'Dashboard'], ['can_get_drill_info', 'Dataset'], ], }); openMenu(); expect(screen.getByText('Drill to detail')).toBeInTheDocument(); }); test('Should not show "Drill to detail" with neither of required perms', () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [['invalid_permission', 'Dashboard']], }); openMenu(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); test('Should not show "Drill to detail" only `can_drill` perm', () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [['can_drill', 'Dashboard']], }); openMenu(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); test('Should not show "Drill to detail" with only `can_drill` & `can_samples` perms', () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [ ['can_drill', 'Dashboard'], ['can_samples', 'Datasource'], ], }); openMenu(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); test('Should not show "Drill to detail" with only `can_explore` & `can_samples` perms', () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [ ['can_explore', 'Superset'], ['can_samples', 'Datasource'], ], }); openMenu(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); test('Should not show "Drill to detail" with only `can_explore`, `can_drill` & `can_samples` perms', () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [ ['can_explore', 'Superset'], ['can_samples', 'Datasource'], ['can_drill', 'Dashboard'], ], }); openMenu(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); test('Should show "View query"', () => { const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [['can_view_query', 'Dashboard']], }); openMenu(); expect(screen.getByText('View query')).toBeInTheDocument(); }); test('Should not show "View query"', () => { const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [['invalid_permission', 'Dashboard']], }); openMenu(); expect(screen.queryByText('View query')).not.toBeInTheDocument(); }); test('Should show "View as table"', () => { const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [['can_view_chart_as_table', 'Dashboard']], }); openMenu(); expect(screen.getByText('View as table')).toBeInTheDocument(); }); test('Should not show "View as table"', () => { const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [['invalid_permission', 'Dashboard']], }); openMenu(); expect(screen.queryByText('View as table')).not.toBeInTheDocument(); }); test('Should not show the "Edit chart" button', () => { const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { Admin: [ ['can_samples', 'Datasource'], ['can_view_query', 'Dashboard'], ['can_view_chart_as_table', 'Dashboard'], ], }); openMenu(); expect(screen.queryByText('Edit chart')).not.toBeInTheDocument(); }); test('Dataset drill info API call is made when user has drill permissions', async () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; renderWrapper(undefined, { Admin: [ ['can_samples', 'Datasource'], ['can_explore', 'Superset'], ['can_get_drill_info', 'Dataset'], ], }); await new Promise(resolve => setTimeout(resolve, 0)); expect(mockCachedSupersetGet).toHaveBeenCalledWith({ endpoint: expect.stringContaining( '/api/v1/dataset/58/drill_info/?q=(dashboard_id:26)', ), }); }); test('Dataset drill info API call is not made when user lacks drill permissions', async () => { (global as any).featureFlags = { [FeatureFlag.DrillToDetail]: true, }; renderWrapper(undefined, { Admin: [['invalid_permission', 'Dashboard']], }); await new Promise(resolve => setTimeout(resolve, 0)); expect(mockCachedSupersetGet).not.toHaveBeenCalled(); }); test('Should show "Embed code" in Share menu when feature flag is enabled and chart has data', async () => { window.featureFlags = { EMBEDDABLE_CHARTS: true, }; const props = createProps(); renderWrapper(props); openMenu(); userEvent.hover(screen.getByText('Share')); expect(await screen.findByText('Embed code')).toBeInTheDocument(); }); test('Should NOT show "Embed code" in Share menu when feature flag is disabled', async () => { window.featureFlags = { EMBEDDABLE_CHARTS: false, }; const props = createProps(); renderWrapper(props); openMenu(); userEvent.hover(screen.getByText('Share')); expect( await screen.findByText('Copy permalink to clipboard'), ).toBeInTheDocument(); expect(screen.queryByText('Embed code')).not.toBeInTheDocument(); }); test('Should pass formData to Share menu for embed code feature', () => { window.featureFlags = { EMBEDDABLE_CHARTS: true, }; const props = createProps(); const { container } = renderWrapper(props); expect(container).toBeInTheDocument(); openMenu(); expect(screen.getByText('Share')).toBeInTheDocument(); }); test('Should show single fetched query tooltip with timestamp', async () => { const updatedDttm = Date.parse('2024-01-28T10:00:00.000Z'); const props = createProps(); props.isCached = [false]; props.cachedDttm = ['']; props.updatedDttm = updatedDttm; renderWrapper(props); openMenu(); const refreshButton = screen.getByText('Force refresh'); expect(refreshButton).toBeInTheDocument(); userEvent.hover(refreshButton); expect(await screen.findByText(/Fetched/)).toBeInTheDocument(); }); test('Should show single cached query tooltip with timestamp', async () => { const cachedDttm = '2024-01-28T10:00:00.000Z'; const props = createProps(); props.isCached = [true]; props.cachedDttm = [cachedDttm]; props.updatedDttm = null; renderWrapper(props); openMenu(); const refreshButton = screen.getByText('Force refresh'); expect(refreshButton).toBeInTheDocument(); userEvent.hover(refreshButton); expect(await screen.findByText(/Cached/)).toBeInTheDocument(); }); test('Should show multiple per-query tooltips when all queries are fetched', async () => { const cachedDttm1 = ''; const cachedDttm2 = ''; const updatedDttm = Date.parse('2024-01-28T10:10:00.000Z'); const props = createProps(VizType.Table); props.isCached = [false, false]; props.cachedDttm = [cachedDttm1, cachedDttm2]; props.updatedDttm = updatedDttm; renderWrapper(props); openMenu(); const refreshButton = screen.getByText('Force refresh'); expect(refreshButton).toBeInTheDocument(); userEvent.hover(refreshButton); expect(await screen.findByText(/Fetched/)).toBeInTheDocument(); }); test('Should show multiple per-query tooltips when all queries are cached', async () => { const cachedDttm1 = '2025-01-28T10:00:00.000Z'; const cachedDttm2 = '2024-01-28T10:05:00.000Z'; const props = createProps(VizType.Table); props.isCached = [true, true]; props.cachedDttm = [cachedDttm1, cachedDttm2]; props.updatedDttm = null; renderWrapper(props); openMenu(); const refreshButton = screen.getByText('Force refresh'); expect(refreshButton).toBeInTheDocument(); userEvent.hover(refreshButton); expect(await screen.findByText(/Query 1: Cached/)).toBeInTheDocument(); expect(await screen.findByText(/Query 2: Cached/)).toBeInTheDocument(); }); test('Should deduplicate identical cache times in tooltip', async () => { const sameCachedDttm = '2024-01-28T10:00:00.000Z'; const props = createProps(VizType.Table); props.isCached = [true, true]; props.cachedDttm = [sameCachedDttm, sameCachedDttm]; props.updatedDttm = null; renderWrapper(props); openMenu(); const refreshButton = screen.getByText('Force refresh'); expect(refreshButton).toBeInTheDocument(); userEvent.hover(refreshButton); expect(await screen.findByText(/Cached/)).toBeInTheDocument(); }); test('Should handle three or more queries with different cache states', async () => { const cachedDttm1 = '2024-01-28T10:00:00.000Z'; const cachedDttm2 = '2024-01-28T10:05:00.000Z'; const cachedDttm3 = ''; const updatedDttm = Date.parse('2024-01-28T10:15:00.000Z'); const props = createProps(VizType.Table); props.isCached = [true, false, true]; props.cachedDttm = [cachedDttm1, cachedDttm2, cachedDttm3]; props.updatedDttm = updatedDttm; renderWrapper(props); openMenu(); const refreshButton = screen.getByText('Force refresh'); expect(refreshButton).toBeInTheDocument(); userEvent.hover(refreshButton); expect(await screen.findByText(/Query 1:/)).toBeInTheDocument(); expect(await screen.findByText(/Query 2:/)).toBeInTheDocument(); expect(await screen.findByText(/Query 3:/)).toBeInTheDocument(); });