This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 7260916b905 test(dashboard): cover "View as table" end-to-end for a 
view-as-table-only role (#44881)
7260916b905 is described below

commit 7260916b905bbcc9d7b950a72838bb6cb97f0638
Author: Evan Rusackas <[email protected]>
AuthorDate: Fri Oct 2 19:42:49 2026 -0700

    test(dashboard): cover "View as table" end-to-end for a view-as-table-only 
role (#44881)
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 .../ViewAsTableRendering.test.tsx                  | 309 +++++++++++++++++++++
 1 file changed, 309 insertions(+)

diff --git 
a/superset-frontend/src/dashboard/components/SliceHeaderControls/ViewAsTableRendering.test.tsx
 
b/superset-frontend/src/dashboard/components/SliceHeaderControls/ViewAsTableRendering.test.tsx
new file mode 100644
index 00000000000..d63e5b3f072
--- /dev/null
+++ 
b/superset-frontend/src/dashboard/components/SliceHeaderControls/ViewAsTableRendering.test.tsx
@@ -0,0 +1,309 @@
+/**
+ * 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.
+ */
+
+// SliceHeaderControls.test.tsx mocks DataTablesPane's ResultsPaneOnDashboard
+// entirely, which lets every test there confirm the *permission gating* and
+// the *verbose_map computed for it* -- but none of them render the real
+// grid. That leaves the actual "View as table" render path -- the thing
+// apache/superset#41268 reports as silently broken for an embedded/guest-ish
+// role -- with no coverage. This file renders the real ResultsPaneOnDashboard
+// (no DataTablesPane mock) so the permission check, the drill_info fetch,
+// the verbose_map, and the grid render are exercised end to end, the same
+// way a user clicking "View as table" would hit them.
+import {
+  render,
+  screen,
+  userEvent,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import { FeatureFlag, VizType } from '@superset-ui/core';
+import { setupAGGridModules } from 
'@superset-ui/core/components/ThemedAgGridReact';
+import mockState from 'spec/fixtures/mockState';
+import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
+
+jest.mock('src/utils/cachedSupersetGet');
+jest.mock('src/components/Chart/chartAction', () => ({
+  getChartDataRequest: jest.fn(),
+}));
+jest.mock('src/utils/downloadAsImage', () =>
+  jest.fn(() => jest.fn().mockResolvedValue(undefined)),
+);
+jest.mock('src/utils/downloadAsPdf', () =>
+  jest.fn(() => jest.fn().mockResolvedValue(undefined)),
+);
+
+const mockCachedSupersetGet = cachedSupersetGet as jest.MockedFunction<
+  typeof cachedSupersetGet
+>;
+const mockGetChartDataRequest = getChartDataRequest as jest.MockedFunction<
+  typeof getChartDataRequest
+>;
+
+const SLICE_ID = 27;
+const DATASOURCE_ID = 58;
+
+// Mirrors the chart from apache/superset#41268: a count grouped by a
+// dimension, with a `contribution` post-processing column -- and the exact
+// colnames/coltypes/data/rowcount the reporter captured from the API.
+const createProps = () =>
+  ({
+    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%20${SLICE_ID}%7D`,
+      slice_name: 'Patients by specialty',
+      slice_description: '',
+      form_data: {
+        adhoc_filters: [],
+        color_scheme: 'supersetColors',
+        datasource: `${DATASOURCE_ID}__table`,
+        groupby: ['speciality'],
+        metrics: ['COUNT(id)'],
+        post_processing: [
+          {
+            operation: 'contribution',
+            options: {
+              columns: ['COUNT(id)'],
+              rename_columns: ['COUNT(id)__contribution'],
+            },
+          },
+        ],
+        row_limit: 10000,
+        slice_id: SLICE_ID,
+        time_range: 'No filter',
+        url_params: {},
+        viz_type: VizType.Table,
+      },
+      viz_type: VizType.Table,
+      datasource: `${DATASOURCE_ID}__table`,
+      description: '',
+      description_markeddown: '',
+      modified: '<span class="no-wrap">22 hours ago</span>',
+      changed_on: 1617143411523,
+      editors: [],
+    },
+    isCached: [false],
+    isExpanded: false,
+    cachedDttm: [''],
+    updatedDttm: 1617213803803,
+    supersetCanExplore: false,
+    supersetCanDownload: true,
+    componentId: 'CHART-fYo7IyvKZQ',
+    dashboardId: 26,
+    isFullSize: false,
+    chartStatus: 'rendered',
+    showControls: true,
+    supersetCanShare: true,
+    formData: {
+      slice_id: SLICE_ID,
+      datasource: `${DATASOURCE_ID}__table`,
+      viz_type: VizType.Table,
+      groupby: ['speciality'],
+      metrics: ['COUNT(id)'],
+      row_limit: 10000,
+    },
+    exploreUrl: '/explore',
+    defaultOpen: true,
+  }) as SliceHeaderControlsProps;
+
+// A role with the reporter's exact permission shape: `can_view_chart_as_table`
+// and `can_get_drill_info`, but not `can_explore` (they granted
+// `can_explore_json` on Superset, a different permission that does not
+// satisfy `canExplore` in usePermissions).
+const EMBED_LIKE_ROLE = {
+  Gamma: [
+    ['can_view_chart_as_table', 'Dashboard'],
+    ['can_get_drill_info', 'Dataset'],
+    ['can_drill', 'Dashboard'],
+    ['can_samples', 'Datasource'],
+  ],
+};
+
+const renderWrapper = () =>
+  render(<SliceHeaderControls {...createProps()} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState: {
+      ...mockState,
+      user: {
+        ...mockState.user,
+        roles: EMBED_LIKE_ROLE,
+      },
+    },
+  });
+
+const openViewAsTable = async () => {
+  await userEvent.click(screen.getByRole('button', { name: 'More Options' }));
+  await userEvent.click(screen.getByTestId('view-query-menu-item'));
+};
+
+beforeAll(() => {
+  setupAGGridModules();
+});
+
+beforeEach(() => {
+  (global as any).featureFlags = {
+    [FeatureFlag.DrillToDetail]: false,
+  };
+  mockCachedSupersetGet.mockReset();
+  mockGetChartDataRequest.mockReset();
+});
+
+test('"View as table" renders the actual rows and headers for a 
view-as-table-only role', async () => {
+  // Post-#43719 drill_info shape: metrics are present (pre-#43719 the
+  // schema had no metrics field at all), labels equal to the raw names
+  // here -- same as the reporter's dataset, which never set custom labels.
+  mockCachedSupersetGet.mockResolvedValue({
+    response: {} as Response,
+    json: {
+      result: {
+        columns: [{ column_name: 'speciality', verbose_name: null }],
+        metrics: [{ metric_name: 'COUNT(id)', verbose_name: null }],
+      },
+    },
+  } as any);
+  mockGetChartDataRequest.mockResolvedValue({
+    json: {
+      result: [
+        {
+          colnames: ['speciality', 'COUNT(id)', 'COUNT(id)__contribution'],
+          coltypes: [1, 0, 0],
+          data: [
+            {
+              speciality: 'Dental',
+              'COUNT(id)': 100,
+              'COUNT(id)__contribution': 0.2,
+            },
+            {
+              speciality: 'ENT',
+              'COUNT(id)': 100,
+              'COUNT(id)__contribution': 0.2,
+            },
+            {
+              speciality: 'Eye',
+              'COUNT(id)': 100,
+              'COUNT(id)__contribution': 0.2,
+            },
+            {
+              speciality: 'Heart',
+              'COUNT(id)': 100,
+              'COUNT(id)__contribution': 0.2,
+            },
+            {
+              speciality: 'Tech',
+              'COUNT(id)': 100,
+              'COUNT(id)__contribution': 0.2,
+            },
+          ],
+          rowcount: 5,
+          sql_rowcount: 5,
+        },
+      ],
+    },
+  } as any);
+
+  renderWrapper();
+  await openViewAsTable();
+
+  // The row count label is a plain React-rendered span, not subject to
+  // ag-grid's row virtualization, so it is the most reliable signal that
+  // `useResultsPane` actually resolved with 5 rows rather than getting
+  // stuck in its loading state (#41268's reported symptom) or landing on
+  // the error/empty-results branches. Both the chart-data fetch and the
+  // drill_info fetch resolve independently and each triggers a re-render
+  // (verbose_map arriving can swap ag-grid's columnDefs), so this re-queries
+  // fresh on every attempt via `waitFor` rather than reusing a node handle
+  // from an earlier `findByText` that a later re-render could detach.
+  await waitFor(() => {
+    expect(screen.getByText('5 rows')).toBeVisible();
+    expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
+    expect(
+      screen.queryByText('Failed to load results'),
+    ).not.toBeInTheDocument();
+    expect(
+      screen.queryByText('No results were returned for this query'),
+    ).not.toBeInTheDocument();
+  });
+
+  // The grid itself must have actually received the fetched rows. ag-grid
+  // virtualizes row rendering based on measured container height, which
+  // jsdom (no real layout engine) reports as 0 inside this resizable modal,
+  // so the cells it does render are not reliably `toBeVisible()` here --
+  // but they must still be in the document with the right text.
+  await waitFor(() => {
+    expect(screen.getByText('Dental')).toBeInTheDocument();
+    expect(screen.getByText('ENT')).toBeInTheDocument();
+    expect(screen.getByText('Eye')).toBeInTheDocument();
+    expect(screen.getByText('Heart')).toBeInTheDocument();
+    expect(screen.getByText('Tech')).toBeInTheDocument();
+  });
+
+  // Headers come from the column names via the drill_info verbose_map
+  // (null verbose_name falls back to the raw name).
+  await waitFor(() => {
+    expect(screen.getAllByText('speciality').length).toBeGreaterThan(0);
+    expect(screen.getAllByText('COUNT(id)').length).toBeGreaterThan(0);
+  });
+});
+
+test('"View as table" does not get stuck loading when drill_info 403s for a 
view-as-table-only role', async () => {
+  mockCachedSupersetGet.mockRejectedValue(
+    new Error('403: Forbidden (simulated)'),
+  );
+  mockGetChartDataRequest.mockResolvedValue({
+    json: {
+      result: [
+        {
+          colnames: ['speciality', 'COUNT(id)'],
+          coltypes: [1, 0],
+          data: [{ speciality: 'Dental', 'COUNT(id)': 100 }],
+          rowcount: 1,
+        },
+      ],
+    },
+  } as any);
+
+  renderWrapper();
+  await openViewAsTable();
+
+  // Even if drill_info fails, the chart-data fetch should still resolve and
+  // the grid should still render using raw column names -- a failure here
+  // (e.g. a stuck spinner) would mean the drill_info request's rejection is
+  // somehow propagating into the results fetch rather than staying
+  // contained to the verbose-map fallback.
+  await waitFor(() => 
expect(mockGetChartDataRequest).toHaveBeenCalledTimes(1));
+  await waitFor(() => {
+    expect(screen.getByText('1 row')).toBeVisible();
+    expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
+    expect(screen.getByText('Dental')).toBeInTheDocument();
+  });
+});

Reply via email to