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

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


The following commit(s) were added to refs/heads/master by this push:
     new 5a74f537b4 [ZEPPELIN-6688] Respect published mode in React 
visualization controls
5a74f537b4 is described below

commit 5a74f537b4f1fa2c08c51124c53ad5214534a332
Author: Minho Jang <[email protected]>
AuthorDate: Fri Oct 30 01:26:06 2026 +0900

    [ZEPPELIN-6688] Respect published mode in React visualization controls
    
    ### What is this PR for?
    The opt-in React published-paragraph view exposes visualization mode 
buttons that only update local React state. This allows viewers to switch away 
from the saved visualization, unlike the Angular published view, which hides 
these editing controls.
    
    This PR propagates the published state from the host through result 
rendering to `VisualizationControls`. Published results use the saved graph 
configuration and do not expose visualization switching. Table exports remain 
available, and non-published controls retain their existing behavior.
    
    ### What type of PR is it?
    Bug Fix
    
    ### What is the Jira issue?
    * https://issues.apache.org/jira/browse/ZEPPELIN-6688
    
    ### How should this be tested?
    With frontend dependencies installed and the Zeppelin backend running at 
`http://localhost:8080`, run the following from `zeppelin-web-angular`:
    
    ```bash
    npm run test:react
    npm run e2e:fast -- --grep "Published table results"
    ```
    
    Published paragraphs should hide visualization mode controls while 
preserving table exports.
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    Closes #5595 from miinhho/ZEPPELIN-6688-published-visualization.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 .../e2e/models/published-paragraph-page.ts         | 17 ++++++
 .../e2e/models/published-paragraph-page.util.ts    | 27 +++++++++-
 .../notebook/published/published-paragraph.spec.ts | 36 ++++++++++++-
 .../visualizations/TableVisualization.tsx          | 14 +++--
 .../visualizations/VisualizationControls.tsx       | 60 +++++++++++++---------
 .../src/pages/PublishedParagraph.spec.tsx          | 17 ++++++
 .../src/pages/PublishedParagraph.tsx               |  2 +-
 .../src/templates/SingleResultRenderer.spec.tsx    | 34 +++++++-----
 .../src/templates/SingleResultRenderer.tsx         |  5 +-
 9 files changed, 165 insertions(+), 47 deletions(-)

diff --git a/zeppelin-web-angular/e2e/models/published-paragraph-page.ts 
b/zeppelin-web-angular/e2e/models/published-paragraph-page.ts
index f2a4ed3eae..7e593a1677 100644
--- a/zeppelin-web-angular/e2e/models/published-paragraph-page.ts
+++ b/zeppelin-web-angular/e2e/models/published-paragraph-page.ts
@@ -20,6 +20,11 @@ export class PublishedParagraphPage extends BasePage {
   readonly angularRenderer: Locator;
   readonly reactWidget: Locator;
   readonly reactWidgetOrEmptyState: Locator;
+  readonly dataTable: Locator;
+  readonly tableHeaders: Locator;
+  readonly tableCells: Locator;
+  readonly visualizationModeControls: Locator;
+  readonly exportControls: Locator;
   private readonly errorModalContent: Locator;
   private readonly errorModalOkButton: Locator;
 
@@ -34,6 +39,18 @@ export class PublishedParagraphPage extends BasePage {
     this.textOutput = page.locator('zeppelin-publish-paragraph pre');
     // Without paragraph data the remote mounts an <Empty>, so tests that only 
assert "React took over" accept either.
     this.reactWidgetOrEmptyState = 
this.reactWidget.or(page.getByTestId('react-empty-paragraph'));
+    this.dataTable = page.getByRole('table');
+    // NG-ZORRO exposes its sortable header cells as role=cell, unlike antd's 
columnheader.
+    this.tableHeaders = this.dataTable.locator('thead th');
+    this.tableCells = this.dataTable.locator('tbody 
tr:not(.ant-table-measure-now)').getByRole('cell');
+    // Angular's icon-only controls have no accessible names; React's controls 
have visible labels.
+    this.visualizationModeControls = page.locator('.viz-icon:visible').or(
+      page.getByRole('button', {
+        name: /\b(Table|Bar Chart|Pie Chart|Line Chart|Area Chart|Scatter 
Chart)$/,
+        exact: true
+      })
+    );
+    this.exportControls = page.getByRole('button', { name: /^Export\b/, exact: 
true });
   }
 
   async navigateToNotebook(noteId: string): Promise<void> {
diff --git a/zeppelin-web-angular/e2e/models/published-paragraph-page.util.ts 
b/zeppelin-web-angular/e2e/models/published-paragraph-page.util.ts
index a10cce38da..51d98a406f 100644
--- a/zeppelin-web-angular/e2e/models/published-paragraph-page.util.ts
+++ b/zeppelin-web-angular/e2e/models/published-paragraph-page.util.ts
@@ -10,8 +10,9 @@
  * limitations under the License.
  */
 
-import { Page } from '@playwright/test';
-import { BasePage } from './base-page';
+import { randomUUID } from 'node:crypto';
+import { APIRequestContext, expect, Page } from '@playwright/test';
+import { BasePage, E2E_TEST_FOLDER } from './base-page';
 import { PublishedParagraphPage } from './published-paragraph-page';
 
 export class PublishedParagraphTestUtil extends BasePage {
@@ -42,3 +43,25 @@ export class PublishedParagraphTestUtil extends BasePage {
     };
   }
 }
+
+export const importPublishedTable = async (request: APIRequestContext): 
Promise<string> => {
+  const name = `published-table-${randomUUID()}`;
+  const response = await request.post('/api/notebook/import', {
+    params: { notePath: `/${E2E_TEST_FOLDER}/${name}` },
+    data: {
+      name,
+      paragraphs: [
+        {
+          title: 'Saved table',
+          text: '%md Saved table',
+          status: 'FINISHED',
+          config: { results: { '0': { graph: { mode: 'table' } } } },
+          results: { code: 'SUCCESS', msg: [{ type: 'TABLE', data: 
'city\tsales\nSeoul\t30\nBusan\t20\n' }] }
+        }
+      ],
+      config: {}
+    }
+  });
+  expect(response.ok(), `Notebook import failed (${response.status()}): 
${await response.text()}`).toBe(true);
+  return (await response.json()).body as string;
+};
diff --git 
a/zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts 
b/zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts
index fc9dce1f6f..2a341ace8f 100644
--- 
a/zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts
+++ 
b/zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts
@@ -12,7 +12,8 @@
 
 import { expect, test } from '@playwright/test';
 import { PublishedParagraphPage } from 'e2e/models/published-paragraph-page';
-import { PublishedParagraphTestUtil } from 
'../../../models/published-paragraph-page.util';
+import { deleteNote, firstParagraphId } from 
'../../../models/notebook-core-read-page.util';
+import { importPublishedTable, PublishedParagraphTestUtil } from 
'../../../models/published-paragraph-page.util';
 import {
   addPageAnnotationBeforeEach,
   waitForNotebookLinks,
@@ -254,3 +255,36 @@ test.describe('Published Paragraph', () => {
     }
   });
 });
+
+test.describe('Published table results', () => {
+  addPageAnnotationBeforeEach(PAGES.WORKSPACE.PUBLISHED_PARAGRAPH);
+
+  for (const { label, query, exportCount } of [
+    { label: 'Angular', query: '', exportCount: 1 },
+    { label: 'React', query: '?react=true', exportCount: 2 }
+  ]) {
+    test(`keeps table results and exports without visualization switching 
(${label})`, async ({ page }) => {
+      const noteId = await importPublishedTable(page.request);
+      try {
+        const paragraphId = await firstParagraphId(page.request, noteId);
+        const published = new PublishedParagraphPage(page);
+        const renderer = label === 'React' ? published.reactWidget : 
published.angularRenderer;
+        const otherRenderer = label === 'React' ? published.angularRenderer : 
published.reactWidget;
+
+        await 
page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}${query}`);
+
+        // A failed React mount must not pass through the Angular fallback.
+        await expect(renderer).toBeAttached({ timeout: 30000 });
+        await expect(otherRenderer).toHaveCount(0);
+        await expect(published.dataTable).toBeVisible();
+        await expect(published.tableHeaders).toHaveText(['city', 'sales']);
+        await expect(published.tableCells).toHaveText(['Seoul', '30', 'Busan', 
'20']);
+        await expect(published.visualizationModeControls).toHaveCount(0);
+        await expect(published.exportControls).toHaveCount(exportCount);
+        await expect(published.exportControls.first()).toBeVisible();
+      } finally {
+        await deleteNote(page.request, noteId);
+      }
+    });
+  }
+});
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/components/visualizations/TableVisualization.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/components/visualizations/TableVisualization.tsx
index 85de7869b2..8c0ae680ae 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/components/visualizations/TableVisualization.tsx
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/components/visualizations/TableVisualization.tsx
@@ -21,10 +21,13 @@ import type { Chart, ChartConfiguration } from 'chart.js';
 interface TableVisualizationProps {
   result: ParagraphIResultsMsgItem;
   config?: ParagraphConfigResult;
+  published?: boolean;
 }
 
-export const TableVisualization = ({ result, config }: 
TableVisualizationProps) => {
-  const [currentMode, setCurrentMode] = 
useState<VisualizationMode>(config?.graph.mode || 'table');
+export const TableVisualization = ({ result, config, published = false }: 
TableVisualizationProps) => {
+  const savedMode = config?.graph.mode || 'table';
+  const [localMode, setLocalMode] = useState<VisualizationMode>(savedMode);
+  const currentMode = published ? savedMode : localMode;
   const chartRef = useRef<HTMLDivElement>(null);
   const themeMode = useHostThemeMode();
 
@@ -232,7 +235,12 @@ export const TableVisualization = ({ result, config }: 
TableVisualizationProps)
 
   return (
     <div>
-      <VisualizationControls currentMode={currentMode} 
onModeChange={setCurrentMode} onExport={handleExport} />
+      <VisualizationControls
+        currentMode={currentMode}
+        onModeChange={setLocalMode}
+        onExport={handleExport}
+        published={published}
+      />
       {renderVisualization()}
     </div>
   );
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/components/visualizations/VisualizationControls.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/components/visualizations/VisualizationControls.tsx
index a05f2b3948..fcfcb2460d 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/components/visualizations/VisualizationControls.tsx
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/components/visualizations/VisualizationControls.tsx
@@ -25,39 +25,49 @@ interface VisualizationControlsProps {
   currentMode: VisualizationMode;
   onModeChange: (mode: VisualizationMode) => void;
   onExport: (type: 'csv' | 'xlsx') => void;
+  published?: boolean;
 }
 
-export const VisualizationControls = ({ currentMode, onModeChange, onExport }: 
VisualizationControlsProps) => {
-  const visualizations = [
-    { id: 'table', name: 'Table', icon: <TableOutlined /> },
-    { id: 'multiBarChart', name: 'Bar Chart', icon: <BarChartOutlined /> },
-    { id: 'pieChart', name: 'Pie Chart', icon: <PieChartOutlined /> },
-    { id: 'lineChart', name: 'Line Chart', icon: <LineChartOutlined /> },
-    { id: 'stackedAreaChart', name: 'Area Chart', icon: <AreaChartOutlined /> 
},
-    { id: 'scatterChart', name: 'Scatter Chart', icon: <DotChartOutlined /> }
-  ] as const;
+const visualizations = [
+  { id: 'table', name: 'Table', icon: <TableOutlined /> },
+  { id: 'multiBarChart', name: 'Bar Chart', icon: <BarChartOutlined /> },
+  { id: 'pieChart', name: 'Pie Chart', icon: <PieChartOutlined /> },
+  { id: 'lineChart', name: 'Line Chart', icon: <LineChartOutlined /> },
+  { id: 'stackedAreaChart', name: 'Area Chart', icon: <AreaChartOutlined /> },
+  { id: 'scatterChart', name: 'Scatter Chart', icon: <DotChartOutlined /> }
+] as const;
+
+export const VisualizationControls = ({
+  currentMode,
+  onModeChange,
+  onExport,
+  published = false
+}: VisualizationControlsProps) => {
+  if (published && currentMode !== 'table') return null;
 
   return (
     <div>
-      <Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
-        <Space.Compact>
-          {visualizations.map(viz => (
-            <Button
-              key={viz.id}
-              type={currentMode === viz.id ? 'primary' : 'default'}
-              icon={viz.icon}
-              onClick={() => onModeChange(viz.id as VisualizationMode)}
-              size="small"
-            >
-              {viz.name}
-            </Button>
-          ))}
-        </Space.Compact>
+      <Space wrap style={{ width: '100%', justifyContent: published ? 
'flex-end' : 'space-between' }}>
+        {!published && (
+          <Space.Compact>
+            {visualizations.map(viz => (
+              <Button
+                key={viz.id}
+                type={currentMode === viz.id ? 'primary' : 'default'}
+                icon={viz.icon}
+                onClick={() => onModeChange(viz.id as VisualizationMode)}
+                size="small"
+              >
+                {viz.name}
+              </Button>
+            ))}
+          </Space.Compact>
+        )}
         <Space.Compact>
-          <Button icon={<DownloadOutlined />} size="small" onClick={() => 
onExport('csv')}>
+          <Button aria-label="Export CSV" icon={<DownloadOutlined />} 
size="small" onClick={() => onExport('csv')}>
             Export CSV
           </Button>
-          <Button icon={<FileExcelOutlined />} size="small" onClick={() => 
onExport('xlsx')}>
+          <Button aria-label="Export Excel" icon={<FileExcelOutlined />} 
size="small" onClick={() => onExport('xlsx')}>
             Export Excel
           </Button>
         </Space.Compact>
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.spec.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.spec.tsx
index 0999b7a71e..622d2c159b 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.spec.tsx
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.spec.tsx
@@ -11,6 +11,7 @@
  */
 
 import { act } from 'react';
+import { within } from '@testing-library/react';
 import { afterEach, describe, expect, it, vi } from 'vitest';
 import { DatasetType } from '@zeppelin/sdk';
 import { mount, PublishedParagraphMountHandle, PublishedParagraphProps } from 
'./PublishedParagraph';
@@ -60,6 +61,22 @@ describe('PublishedParagraph mount contract', () => {
     expect(rendered!.textContent).toContain('first output');
   });
 
+  it('renders table exports without visualization mode controls', () => {
+    mountParagraph({
+      paragraphId: 'paragraph-1',
+      results: [{ type: DatasetType.TABLE, data: 'city\tsales\nSeoul\t30' }]
+    });
+
+    const published = within(host!);
+    expect(published.getByRole('cell', { name: 'Seoul' })).toBeTruthy();
+    expect(published.getByRole('cell', { name: '30' })).toBeTruthy();
+    expect(
+      published.queryAllByRole('button', { name: /Table|Bar Chart|Pie 
Chart|Line Chart|Area Chart|Scatter Chart/ })
+    ).toEqual([]);
+    expect(published.getByRole('button', { name: /Export CSV/ })).toBeTruthy();
+    expect(published.getByRole('button', { name: /Export Excel/ 
})).toBeTruthy();
+  });
+
   it('renders the empty state when the paragraph has no results', () => {
     mountParagraph({ ...baseProps, results: [] });
 
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx 
b/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx
index df803619ee..1581baed28 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx
@@ -35,7 +35,7 @@ export const PublishedParagraph = ({ results, config }: 
PublishedParagraphProps)
       <div data-testid="react-published-paragraph">
         {results.map((result, index) => (
           <div key={index}>
-            <SingleResultRenderer result={result} index={index} 
config={config} />
+            <SingleResultRenderer result={result} index={index} 
config={config} published />
           </div>
         ))}
       </div>
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.spec.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.spec.tsx
index ab9f4423c6..ad687509cc 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.spec.tsx
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.spec.tsx
@@ -34,27 +34,35 @@ describe('SingleResultRenderer', () => {
     expect(screen.getByText(/line one <b>not bold<\/b>/)).toBeTruthy();
   });
 
-  it('renders TABLE through the visualization', () => {
+  it('keeps mode selection and exports available outside published mode', () 
=> {
     render(<SingleResultRenderer index={0} result={result(DatasetType.TABLE, 
TABLE_DATA)} />);
 
-    // Only that the arm was taken. The visualization's display-mode state is a
-    // known defect (projects/zeppelin-react/AGENTS.md), so nothing here pins 
it.
-    expect(screen.getByText('alice')).toBeTruthy();
-    expect(screen.getByRole('button', { name: /Bar Chart/ })).toBeTruthy();
+    expect(screen.getByRole('table')).toBeTruthy();
+    expect(
+      screen.getAllByRole('button', { name: /\b(Table|Bar Chart|Pie Chart|Line 
Chart|Area Chart|Scatter Chart)$/ })
+    ).toHaveLength(6);
+    expect(screen.getByRole('button', { name: 'Export CSV' })).toBeTruthy();
+    expect(screen.getByRole('button', { name: 'Export Excel' })).toBeTruthy();
   });
 
-  it('hands the visualization the display config for its own result index', () 
=> {
-    // Both indices are rendered: index 1 alone would pass against a 
hard-coded [1],
-    // and the positive assertion keeps an absent chart from reading as 
success.
-    const table = render(
-      <SingleResultRenderer index={0} config={configs} 
result={result(DatasetType.TABLE, TABLE_DATA)} />
+  it('renders the host-selected mode for each result and follows config 
updates', () => {
+    const view = render(
+      <SingleResultRenderer index={0} config={configs} 
result={result(DatasetType.TABLE, TABLE_DATA)} published />
     );
+    expect(screen.getByRole('table')).toBeTruthy();
     expect(screen.getByText('alice')).toBeTruthy();
-    table.unmount();
 
-    render(<SingleResultRenderer index={1} config={configs} 
result={result(DatasetType.TABLE, TABLE_DATA)} />);
-    expect(screen.getByRole('button', { name: /Table/ })).toBeTruthy();
+    view.rerender(
+      <SingleResultRenderer index={1} config={configs} 
result={result(DatasetType.TABLE, TABLE_DATA)} published />
+    );
+    expect(screen.queryByRole('table')).toBeNull();
     expect(screen.queryByText('alice')).toBeNull();
+
+    view.rerender(
+      <SingleResultRenderer index={0} config={configs} 
result={result(DatasetType.TABLE, TABLE_DATA)} published />
+    );
+    expect(screen.getByRole('table')).toBeTruthy();
+    expect(screen.getByText('alice')).toBeTruthy();
   });
 
   it('renders IMG as a base64 png', () => {
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.tsx
index e5c3ba7f7b..a9095bc3d5 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.tsx
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.tsx
@@ -19,14 +19,15 @@ interface SingleResultRendererProps {
   result: ParagraphIResultsMsgItem;
   index: number;
   config?: ParagraphConfigResults;
+  published?: boolean;
 }
 
-export const SingleResultRenderer = ({ result, index, config }: 
SingleResultRendererProps) => {
+export const SingleResultRenderer = ({ result, index, config, published = 
false }: SingleResultRendererProps) => {
   const resultConfig: ParagraphConfigResult | undefined = config?.[index];
 
   switch (result.type) {
     case DatasetType.TABLE:
-      return <TableVisualization result={result} config={resultConfig} />;
+      return <TableVisualization result={result} config={resultConfig} 
published={published} />;
     case DatasetType.HTML:
       return <HTMLRenderer html={result.data} />;
     case DatasetType.TEXT:

Reply via email to