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 2ee7ed671b [ZEPPELIN-6630] Render the /configuration table through a 
React remote behind a flag
2ee7ed671b is described below

commit 2ee7ed671ba84b05152068c891cb32b00d000977
Author: 김예나 <[email protected]>
AuthorDate: Sun Aug 30 00:30:26 2026 +0900

    [ZEPPELIN-6630] Render the /configuration table through a React remote 
behind a flag
    
    ### What is this PR for?
    
    Renders the `/configuration` table through the React remote behind a flag, 
and adds the e2e coverage the page was missing. ZEPPELIN-6630 recommends doing 
ZEPPELIN-6363 first or alongside, so both are here: the safety net first, then 
the change it protects. `[ZEPPELIN-6363]` stands on its own, its spec passes 
with none of the port applied, so the two split cleanly if you would rather 
take them separately. The last two commits are <at>voidmatcha's, cherry-picked 
from the branch offered  [...]
    
    **ZEPPELIN-6363**, first commit. The page had no spec of its own; what 
existed touched it only in passing through header navigation and the user menu. 
The new spec covers the header title and the security note, the Name and Value 
table, and that every entry carries a name while a value may legitimately be 
empty. Two assertions go past structure and pin behaviour the component owns: 
the entries come out sorted by name, which is the sort in `getAllConfig()`, and 
they survive a reload. T [...]
    
    **ZEPPELIN-6630**, second commit. The suggested boundary is kept: 
`ConfigurationService` still fetches and sorts, `zeppelin-page-header` stays 
where it is, routing is untouched, and only the table inside `.content` moves. 
The remote receives the sorted `[string, string][]` and owns no state, so this 
does not wait on the host/remote state-sharing decision.
    
    * The flag goes through `ReactFeatureService` from ZEPPELIN-6564, as a new 
`configurationTable` surface reading `?reactConfiguration`. No new parsing.
    * Both branches render the shared `configuration-table` id, and the mount 
host around it is what tells them apart. An `onError` from the remote falls 
back to the Angular table for the rest of the session.
    * The props getter is memoized on `configEntries`, the only input that 
changes. `shallowEquals` in `paragraph.component.ts` is a private helper of 
that component; rather than copy it, a single reference compare does the job 
here.
    * `queryParamMap` is subscribed rather than read once, because navigating 
between `/configuration` and `/configuration?reactConfiguration` reuses the 
component. Replacing the subscription with a snapshot read fails the parity 
spec.
    
    On visual consistency: the remote renders antd's `Table` inside 
`ZeppelinThemeProvider`, so it follows the shell's theme rather than the 
shell's stylesheets. Measured against the Angular table, font size, cell 
padding and background match in both themes. Column headers were the one 
difference visible side by side, since antd draws them at 600 and ng-zorro at 
500, so the surface passes `fontWeightStrong` through the provider's token 
prop. Row height still differs by 1px in light mode,  [...]
    
    Two things worth raising for the surfaces that follow:
    
    * Every host component now repeats the props memoization. Doing it inside 
`ReactMountDirective`, which could skip `handle.update()` when the incoming 
props are shallow-equal, would remove that from each call site. It felt out of 
scope here, so it is only a suggestion.
    * `src/pages/index.ts` is not updated. Nothing imports that barrel, and 
`export *` from a second page conflicts on `mount`, which is inherent to the 
mount-per-module contract.
    
    `projects/zeppelin-react/src/test-setup.ts` gains a `matchMedia` stub: 
jsdom implements none and antd's responsive observer calls it while rendering, 
so `Table` throws and the error boundary renders nothing. Any later spec 
touching an antd component would hit the same wall.
    
    ### What type of PR is it?
    
    Improvement
    
    ### Todos
    
    None
    
    ### What is the Jira issue?
    
    * https://issues.apache.org/jira/browse/ZEPPELIN-6630
    * https://issues.apache.org/jira/browse/ZEPPELIN-6363
    
    ### How should this be tested?
    
    * Playwright, chromium: the two new specs under 
`e2e/tests/workspace/configuration/`, 13 tests, no retries and no flakes. 
`react-footer.spec.ts`, `published-paragraph.spec.ts` and `dark-mode.spec.ts` 
re-run for regressions, 23 tests green.
    * vitest: `projects/zeppelin-react` at 43 including the new 
`ConfigurationTable.spec.tsx`, and `npm run test:shell` at 4.
    * Production builds: the remote (`ConfigurationTable` shows up in 
`remoteEntry.js`) and `ng build --configuration production`.
    * Each new assertion was checked by breaking what it covers. Reversing the 
row order in the remote fails the parity spec; removing the sort in 
`getAllConfig()` fails the ZEPPELIN-6363 sort spec; replacing the query param 
subscription with a snapshot read fails the parity spec; dropping 
`!this.reactTableFailed` from `shouldUseReactTable` fails the fallback spec and 
nothing else; removing the `matchMedia` stub fails four vitest specs.
    * The flip the ZEPPELIN-6363 spec exists to survive: with 
`configurationTable.defaultEnabled` set to `true`, so the page serves the React 
table by default, all seven of its tests still pass.
    * The fallback spec's `waitForRequest` guard: renaming the surface's 
`queryParam` so `?reactConfiguration=true` never resolves makes it time out. 
Without the guard the assertions would have passed on the default Angular 
branch.
    * Manually in both themes at `/#/configuration` and 
`/#/configuration?reactConfiguration=true`.
    
    Firefox and WebKit were not run locally.
    
    ### Screenshots (if appropriate)
    
    The two tables side by side are hard to tell apart, which is the intent. In 
light mode the header weight now matches and only row height differs, by 1px.
    
    ### Questions:
    
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No, the flag defaults to 
off and the Angular table is unchanged when it is
    * Does this needs documentation? No
    
    
    
    Closes #5436 from kimyenac/ZEPPELIN-6630.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 zeppelin-web-angular/e2e/AGENTS.md                 |   2 +-
 .../e2e/models/configuration-page.ts               |  52 ++++++++++
 .../configuration-page-structure.spec.ts           |  75 +++++++++++++++
 .../react-configuration-table.spec.ts              | 105 ++++++++++++++++++++
 .../projects/zeppelin-react/README.md              |   8 +-
 .../projects/zeppelin-react/src/main.ts            |   1 +
 .../src/pages/ConfigurationTable.spec.tsx          | 107 +++++++++++++++++++++
 .../src/pages/ConfigurationTable.tsx               |  85 ++++++++++++++++
 .../projects/zeppelin-react/src/test-setup.ts      |  16 +++
 .../projects/zeppelin-react/webpack.config.js      |   3 +-
 .../configuration/configuration.component.html     |  42 +++++---
 .../configuration/configuration.component.ts       |  48 ++++++++-
 .../src/app/services/react-feature.service.ts      |   6 +-
 13 files changed, 528 insertions(+), 22 deletions(-)

diff --git a/zeppelin-web-angular/e2e/AGENTS.md 
b/zeppelin-web-angular/e2e/AGENTS.md
index 43e18edf9c..6360429976 100644
--- a/zeppelin-web-angular/e2e/AGENTS.md
+++ b/zeppelin-web-angular/e2e/AGENTS.md
@@ -121,7 +121,7 @@ Use an existing key from the `PAGES` object in 
`e2e/utils.ts`; add a new one the
 
 ## Migration (Angular to React Microfrontend)
 
-Pages are moving from Angular to React fragments incrementally. Today this is 
narrow: the published paragraph route reads a `?react=true` flag 
(`published/paragraph/paragraph.component`), and the notebook footer swaps via 
a `?reactFooter=true` flag (read into the notebook component's `useReactFooter` 
input). Both are query params inside the hash. There is no app-wide "flip this 
route to React" flag, and no cross-framework parity project in this config. 
Write specs so they survive a route [...]
+Pages are moving from Angular to React fragments incrementally. Today this is 
narrow: the published paragraph route reads a `?react=true` flag 
(`published/paragraph/paragraph.component`), the notebook footer swaps via a 
`?reactFooter=true` flag (read into the notebook component's `useReactFooter` 
input), and the configuration table swaps via a `?reactConfiguration=true` flag 
(`configuration/configuration.component`). All three are query params inside 
the hash. There is no app-wide "flip  [...]
 
 ### Write Framework-Neutral Specs
 
diff --git a/zeppelin-web-angular/e2e/models/configuration-page.ts 
b/zeppelin-web-angular/e2e/models/configuration-page.ts
new file mode 100644
index 0000000000..3bb1615e0e
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/configuration-page.ts
@@ -0,0 +1,52 @@
+/*
+ * Licensed 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 { Locator, Page } from '@playwright/test';
+import { waitForZeppelinReady } from '../utils';
+import { BasePage } from './base-page';
+
+export class ConfigurationPage extends BasePage {
+  readonly pageDescription: Locator;
+  readonly table: Locator;
+  readonly headerCells: Locator;
+  readonly rows: Locator;
+
+  constructor(page: Page) {
+    super(page);
+    this.pageDescription = page.locator('text=Shows current configurations for 
Zeppelin Server.');
+    // A shared id, not the ng-zorro element: this page is a migration seam and
+    // these tests have to survive the flip.
+    this.table = page.locator('[data-testid="configuration-table"]');
+    this.headerCells = this.table.locator('thead th');
+    // Both antd and ng-zorro render the "no data" state as a row, so exclude 
it
+    // to keep the counts about actual configuration entries.
+    this.rows = this.table.locator('tbody tr:not(.ant-table-placeholder)');
+  }
+
+  async navigate(): Promise<void> {
+    await this.navigateToRoute('/configuration', { timeout: 60000 });
+    await this.page.waitForURL('**/#/configuration', { timeout: 60000 });
+    await waitForZeppelinReady(this.page);
+    await this.zeppelinPageHeader.filter({ hasText: 'Configurations' 
}).waitFor({ state: 'visible' });
+  }
+
+  /** `[name, value]` for every rendered entry, in the order the page shows 
them. */
+  async readEntries(): Promise<Array<[string, string]>> {
+    await this.rows.first().waitFor({ state: 'visible', timeout: 15000 });
+    return this.rows.evaluateAll(rows =>
+      rows.map(row => {
+        const cells = Array.from(row.querySelectorAll('td')).map(cell => 
(cell.textContent ?? '').trim());
+        return [cells[0] ?? '', cells[1] ?? ''] as [string, string];
+      })
+    );
+  }
+}
diff --git 
a/zeppelin-web-angular/e2e/tests/workspace/configuration/configuration-page-structure.spec.ts
 
b/zeppelin-web-angular/e2e/tests/workspace/configuration/configuration-page-structure.spec.ts
new file mode 100644
index 0000000000..a8292f9ec2
--- /dev/null
+++ 
b/zeppelin-web-angular/e2e/tests/workspace/configuration/configuration-page-structure.spec.ts
@@ -0,0 +1,75 @@
+/*
+ * Licensed 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 { expect, test } from '@playwright/test';
+import { ConfigurationPage } from '../../../models/configuration-page';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from 
'../../../utils';
+
+test.describe('Configuration Page - Structure', () => {
+  addPageAnnotationBeforeEach(PAGES.WORKSPACE.CONFIGURATION);
+
+  let configurationPage: ConfigurationPage;
+
+  test.beforeEach(async ({ page }) => {
+    await page.goto('/#/');
+    await waitForZeppelinReady(page);
+    configurationPage = new ConfigurationPage(page);
+    await configurationPage.navigate();
+  });
+
+  test('should display page header with correct title and description', async 
() => {
+    await expect(configurationPage.zeppelinPageHeader).toBeVisible();
+    await 
expect(configurationPage.zeppelinPageHeader).toContainText('Configurations');
+    await expect(configurationPage.pageDescription).toBeVisible();
+    await expect(configurationPage.zeppelinPageHeader).toContainText(
+      'Note: For security reasons, some key/value pairs including passwords 
would not be shown.'
+    );
+  });
+
+  test('should display the entries in a Name and Value table', async () => {
+    await expect(configurationPage.table).toBeVisible();
+    await expect(configurationPage.headerCells).toHaveText(['Name', 'Value']);
+    expect((await configurationPage.readEntries()).length).toBeGreaterThan(0);
+  });
+
+  test('should sort the entries by name', async () => {
+    const names = (await configurationPage.readEntries()).map(([name]) => 
name);
+
+    expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
+  });
+
+  test('should name every entry, allowing an empty value', async () => {
+    const entries = await configurationPage.readEntries();
+
+    // A configuration key is always present; its value can legitimately be
+    // empty, either unset or withheld as a secret.
+    expect(entries.every(([name]) => name.length > 0)).toBe(true);
+  });
+
+  test('should keep the table on a reload', async ({ page }) => {
+    const before = await configurationPage.readEntries();
+
+    await page.reload();
+    await waitForZeppelinReady(page);
+
+    await expect(configurationPage.table).toBeVisible();
+    expect(await configurationPage.readEntries()).toEqual(before);
+  });
+
+  test('should reach the page from a direct URL without going through the 
menu', async ({ page }) => {
+    await page.goto('/#/configuration');
+    await waitForZeppelinReady(page);
+
+    await 
expect(configurationPage.zeppelinPageHeader).toContainText('Configurations');
+    await expect(configurationPage.table).toBeVisible();
+  });
+});
diff --git 
a/zeppelin-web-angular/e2e/tests/workspace/configuration/react-configuration-table.spec.ts
 
b/zeppelin-web-angular/e2e/tests/workspace/configuration/react-configuration-table.spec.ts
new file mode 100644
index 0000000000..ee18d357c1
--- /dev/null
+++ 
b/zeppelin-web-angular/e2e/tests/workspace/configuration/react-configuration-table.spec.ts
@@ -0,0 +1,105 @@
+/*
+ * Licensed 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 { expect, test, Page } from '@playwright/test';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from 
'../../../utils';
+
+// Both branches render TABLE; only the React branch has a mount host around 
it.
+// Which branch is live is therefore a question about MOUNT, not about the 
table.
+const TABLE = '[data-testid="configuration-table"]';
+const MOUNT = '[data-testid="react-configuration-table"]';
+const MOUNTED_TABLE = `${MOUNT} ${TABLE}`;
+
+// The entries arrive from ConfigurationService after the page settles, so wait
+// for the first row before reading; evaluateAll does not retry on its own.
+const readRows = async (page: Page, root: string): Promise<string[][]> => {
+  const rows = page.locator(`${root} tbody tr:not(.ant-table-placeholder)`);
+  await expect(rows.first()).toBeVisible({ timeout: 15000 });
+  return rows.evaluateAll(all =>
+    all.map(row => Array.from(row.querySelectorAll('td')).map(cell => 
(cell.textContent ?? '').trim()))
+  );
+};
+
+const openConfiguration = async (page: Page, query = ''): Promise<void> => {
+  await page.goto(`/#/configuration${query}`);
+  await waitForZeppelinReady(page);
+};
+
+test.describe('Configuration Page - React table behind a flag', () => {
+  addPageAnnotationBeforeEach(PAGES.WORKSPACE.CONFIGURATION);
+
+  test('without the flag, the Angular table renders', async ({ page }) => {
+    await openConfiguration(page);
+
+    await expect(page.locator(TABLE)).toBeVisible();
+    await expect(page.locator(MOUNT)).toHaveCount(0);
+    expect((await readRows(page, TABLE)).length).toBeGreaterThan(0);
+    await expect(page.locator(`${TABLE} thead th`)).toHaveText(['Name', 
'Value']);
+  });
+
+  test('with reactConfiguration=true, the React table renders instead', async 
({ page }) => {
+    await openConfiguration(page, '?reactConfiguration=true');
+
+    await expect(page.locator(MOUNTED_TABLE)).toBeVisible({ timeout: 15000 });
+    // One table on the page, and it is the mounted one: the Angular branch is 
gone.
+    await expect(page.locator(TABLE)).toHaveCount(1);
+  });
+
+  test('with a bare reactConfiguration flag, the React table renders', async 
({ page }) => {
+    await openConfiguration(page, '?reactConfiguration');
+
+    await expect(page.locator(MOUNTED_TABLE)).toBeVisible({ timeout: 15000 });
+    await expect(page.locator(TABLE)).toHaveCount(1);
+  });
+
+  test('both tables show the same configuration entries', async ({ page }) => {
+    await openConfiguration(page);
+    await expect(page.locator(TABLE)).toBeVisible();
+    const angularRows = await readRows(page, TABLE);
+
+    await openConfiguration(page, '?reactConfiguration=true');
+    await expect(page.locator(MOUNTED_TABLE)).toBeVisible({ timeout: 15000 });
+    const reactRows = await readRows(page, MOUNTED_TABLE);
+
+    // Same names, same values, same order: the host still owns the fetch and
+    // the sort, so the remote must not reshape what it is given.
+    expect(reactRows).toEqual(angularRows);
+  });
+
+  test('the header keeps the Name and Value columns', async ({ page }) => {
+    await openConfiguration(page, '?reactConfiguration=true');
+    await expect(page.locator(MOUNTED_TABLE)).toBeVisible({ timeout: 15000 });
+
+    await expect(page.locator(`${MOUNTED_TABLE} thead 
th`)).toHaveText(['Name', 'Value']);
+  });
+
+  test('when the remote fails to load, the Angular table renders', async ({ 
page }) => {
+    await test.step('Given a dead remote whose entry never loads', async () => 
{
+      await page.route('**/remoteEntry.js', route => route.abort());
+    });
+
+    await test.step('When the page opens with the React table enabled', async 
() => {
+      // Angular is the default branch, so the assertions below pass even if 
the flag
+      // never took. Awaiting the request is what proves this is a real 
fallback.
+      const remoteRequested = page.waitForRequest('**/remoteEntry.js');
+      await openConfiguration(page, '?reactConfiguration=true');
+      await remoteRequested;
+    });
+
+    await test.step('Then the Angular table takes over, showing the 
host-fetched entries', async () => {
+      await expect(page.locator(TABLE)).toBeVisible({ timeout: 15000 });
+      await expect(page.locator(MOUNT)).toHaveCount(0);
+      // JUSTIFIED: this spec uses raw selectors throughout so it can scope to 
the mount host; it builds no POM.
+      await expect(page.locator(`${TABLE} tbody 
tr:not(.ant-table-placeholder)`)).not.toHaveCount(0);
+    });
+  });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/README.md 
b/zeppelin-web-angular/projects/zeppelin-react/README.md
index 293fe0541f..e8b51bf97f 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/README.md
+++ b/zeppelin-web-angular/projects/zeppelin-react/README.md
@@ -54,6 +54,7 @@ Angular host (port 4200)              React remote (port 3001)
 │  calls mount(el, props)       │     │  exposes:               │
 └───────────────────────────────┘     │    ./PublishedParagraph │
                                       │    ./ParagraphFooter    │
+                                      │    ./ConfigurationTable │
                                       └─────────────────────────┘
 ```
 
@@ -76,7 +77,7 @@ Each React surface is behind a URL query flag, resolved by 
`ReactFeatureService`
 | `?react=false` | disabled |
 | flag absent | disabled |
 
-Append `?react=true` to any published paragraph URL, or `?reactFooter=true` to 
a notebook URL, to activate React mode.
+Append `?react=true` to any published paragraph URL, `?reactFooter=true` to a 
notebook URL, or `?reactConfiguration=true` to the configuration URL to 
activate React mode.
 
 ## Setup
 
@@ -98,10 +99,12 @@ From `projects/zeppelin-react/`, run `npm run lint` to 
check, `npm run lint:fix`
 src/
 ├── components/
 │   ├── common/          # Empty, Loading
+│   ├── paragraph/       # ParagraphFooter
 │   ├── renderers/       # HTMLRenderer, ImageRenderer, TextRenderer
 │   └── visualizations/  # TableVisualization, VisualizationControls
 ├── pages/
-│   └── PublishedParagraph.tsx   # entry component + mount()
+│   ├── PublishedParagraph.tsx   # entry component + mount()
+│   └── ConfigurationTable.tsx   # /configuration table + mount()
 ├── templates/
 │   └── SingleResultRenderer.tsx # routes result types to renderers
 ├── theme/               # host theme detection, antd + chart.js theming
@@ -141,6 +144,7 @@ export function mount(element: HTMLElement, props: Props): 
ReactMountHandle;
    exposes: {
      './PublishedParagraph': './src/pages/PublishedParagraph',
      './ParagraphFooter': './src/components/paragraph/ParagraphFooter',
+     './ConfigurationTable': './src/pages/ConfigurationTable',
      './ExampleFeature': './src/components/<area>/ExampleFeature'
    }
    ```
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts 
b/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
index cf8e866a31..ce7edc883f 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
@@ -10,5 +10,6 @@
  * limitations under the License.
  */
 
+export { ConfigurationTable, mount as mountConfigurationTable } from 
'./pages/ConfigurationTable';
 export { PublishedParagraph, mount } from './pages/PublishedParagraph';
 export { ParagraphFooter, mount as mountParagraphFooter } from 
'./components/paragraph/ParagraphFooter';
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/pages/ConfigurationTable.spec.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/pages/ConfigurationTable.spec.tsx
new file mode 100644
index 0000000000..19641f0195
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/pages/ConfigurationTable.spec.tsx
@@ -0,0 +1,107 @@
+/*
+ * Licensed 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 { act } from 'react';
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+  ConfigurationEntry,
+  ConfigurationTableMountHandle,
+  ConfigurationTableProps,
+  mount
+} from './ConfigurationTable';
+
+const entries: ConfigurationEntry[] = [
+  ['zeppelin.server.addr', '127.0.0.1'],
+  ['zeppelin.server.port', '8080']
+];
+
+// antd renders its "No data" placeholder as a row, so data rows are the rest.
+const rowTexts = (host: HTMLElement): string[][] =>
+  Array.from(host.querySelectorAll('tbody 
tr:not(.ant-table-placeholder)')).map(row =>
+    Array.from(row.querySelectorAll('td')).map(cell => cell.textContent ?? '')
+  );
+
+describe('ConfigurationTable mount contract', () => {
+  let host: HTMLElement | null = null;
+  let handle: ConfigurationTableMountHandle | null = null;
+
+  const mountTable = (props: ConfigurationTableProps): void => {
+    host = document.createElement('div');
+    document.body.appendChild(host);
+    act(() => {
+      handle = mount(host as HTMLElement, props);
+    });
+  };
+
+  afterEach(() => {
+    if (handle) {
+      const h = handle;
+      act(() => h.unmount());
+      handle = null;
+    }
+    host?.remove();
+    host = null;
+  });
+
+  it('throws when no element is given', () => {
+    expect(() => mount(null as unknown as HTMLElement, { entries 
})).toThrow('Mount element is required');
+  });
+
+  it('returns an update/unmount handle and renders one row per entry', () => {
+    mountTable({ entries });
+
+    expect(typeof handle!.update).toBe('function');
+    expect(typeof handle!.unmount).toBe('function');
+
+    const headers = Array.from(host!.querySelectorAll('thead th')).map(th => 
th.textContent);
+    expect(headers).toEqual(['Name', 'Value']);
+    expect(rowTexts(host!)).toEqual([
+      ['zeppelin.server.addr', '127.0.0.1'],
+      ['zeppelin.server.port', '8080']
+    ]);
+  });
+
+  it('keeps the order the host passed in', () => {
+    // The shell sorts by name before handing the entries over, so the remote
+    // must not impose its own ordering.
+    mountTable({ entries: [...entries].reverse() });
+
+    expect(rowTexts(host!).map(([name]) => 
name)).toEqual(['zeppelin.server.port', 'zeppelin.server.addr']);
+  });
+
+  it('shows the empty placeholder when the host has no entries yet', () => {
+    mountTable({});
+
+    
expect(host!.querySelector('[data-testid="configuration-table"]')).not.toBeNull();
+    expect(host!.querySelector('.ant-table-placeholder')).not.toBeNull();
+    expect(rowTexts(host!)).toEqual([]);
+  });
+
+  it('update() re-renders in place with new entries', () => {
+    mountTable({ entries });
+
+    const h = handle!;
+    act(() => h.update({ entries: [['zeppelin.war', 'zeppelin-web/dist']] }));
+
+    expect(rowTexts(host!)).toEqual([['zeppelin.war', 'zeppelin-web/dist']]);
+  });
+
+  it('unmount() empties the host element', () => {
+    mountTable({ entries });
+    const h = handle!;
+    handle = null;
+
+    act(() => h.unmount());
+
+    expect(host!.innerHTML).toBe('');
+  });
+});
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/pages/ConfigurationTable.tsx 
b/zeppelin-web-angular/projects/zeppelin-react/src/pages/ConfigurationTable.tsx
new file mode 100644
index 0000000000..2a022eeeb6
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/pages/ConfigurationTable.tsx
@@ -0,0 +1,85 @@
+/*
+ * Licensed 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 { createRoot, Root } from 'react-dom/client';
+import { Table } from 'antd';
+import { ReactErrorBoundary } from '@/components';
+import { ZeppelinThemeProvider } from '@/theme';
+
+/** One `[name, value]` pair as the shell's ConfigurationService hands it 
over. */
+export type ConfigurationEntry = [string, string];
+
+export interface ConfigurationTableProps {
+  entries?: ConfigurationEntry[];
+  onError?: (error: unknown) => void;
+}
+
+interface ConfigurationRow {
+  key: string;
+  name: string;
+  value: string;
+}
+
+const COLUMNS = [
+  { title: 'Name', dataIndex: 'name', key: 'name' },
+  { title: 'Value', dataIndex: 'value', key: 'value' }
+];
+
+// antd draws table headers at 600 and ng-zorro at 500. Column headers are the
+// one difference visible side by side on this page, so match the shell's 
weight.
+const TABLE_TOKENS = { fontWeightStrong: 500 };
+
+export const ConfigurationTable = ({ entries = [] }: ConfigurationTableProps) 
=> {
+  const rows: ConfigurationRow[] = entries.map(([name, value]) => ({ key: 
name, name, value }));
+
+  return (
+    // Deliberately the same id the Angular table carries, so page-level specs
+    // work on either side of the flag.
+    <div data-testid="configuration-table">
+      <Table<ConfigurationRow> columns={COLUMNS} dataSource={rows} 
size="small" pagination={false} />
+    </div>
+  );
+};
+
+export interface ConfigurationTableMountHandle {
+  update: (props: ConfigurationTableProps) => void;
+  unmount: () => void;
+}
+
+export const mount = (element: HTMLElement, initialProps: 
ConfigurationTableProps): ConfigurationTableMountHandle => {
+  if (!element) {
+    throw new Error('Mount element is required');
+  }
+
+  const root: Root = createRoot(element);
+
+  const renderWith = (props: ConfigurationTableProps) => {
+    root.render(
+      <ReactErrorBoundary onError={props.onError}>
+        <ZeppelinThemeProvider token={TABLE_TOKENS}>
+          <ConfigurationTable {...props} />
+        </ZeppelinThemeProvider>
+      </ReactErrorBoundary>
+    );
+  };
+
+  renderWith(initialProps);
+
+  return {
+    update: (newProps: ConfigurationTableProps) => {
+      renderWith(newProps);
+    },
+    unmount: () => {
+      root.unmount();
+    }
+  };
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/test-setup.ts 
b/zeppelin-web-angular/projects/zeppelin-react/src/test-setup.ts
index 43cdd4dba5..2c3b0a5d39 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/test-setup.ts
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/test-setup.ts
@@ -20,3 +20,19 @@ import { afterEach } from 'vitest';
 // Vitest globals are disabled, so Testing Library cannot self-register its
 // auto-cleanup hook; without this, rendered DOM leaks between tests.
 afterEach(cleanup);
+
+// jsdom implements no matchMedia, and antd's responsive observer calls it 
while
+// rendering. Without this, components such as Table throw and render nothing.
+if (typeof window.matchMedia !== 'function') {
+  window.matchMedia = (query: string): MediaQueryList =>
+    ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: () => undefined,
+      removeListener: () => undefined,
+      addEventListener: () => undefined,
+      removeEventListener: () => undefined,
+      dispatchEvent: () => false
+    }) as unknown as MediaQueryList;
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js 
b/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js
index 65a635b8b2..d7de57b3d9 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js
+++ b/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js
@@ -72,7 +72,8 @@ module.exports = (_env, argv) => {
         filename: 'remoteEntry.js',
         exposes: {
           './PublishedParagraph': './src/pages/PublishedParagraph',
-          './ParagraphFooter': './src/components/paragraph/ParagraphFooter'
+          './ParagraphFooter': './src/components/paragraph/ParagraphFooter',
+          './ConfigurationTable': './src/pages/ConfigurationTable'
         }
       }),
       new HtmlWebpackPlugin({
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/configuration/configuration.component.html
 
b/zeppelin-web-angular/src/app/pages/workspace/configuration/configuration.component.html
index ee8f0dd1b9..0a644abc42 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/configuration/configuration.component.html
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/configuration/configuration.component.html
@@ -17,20 +17,34 @@
   Note: For security reasons, some key/value pairs including passwords would 
not be shown.
 </ng-template>
 <div class="content">
-  <nz-table nzSize="small" [nzData]="configEntries" 
[nzFrontPagination]="false" [nzShowPagination]="false">
-    <thead>
-      <tr>
-        <th>Name</th>
-        <th>Value</th>
-      </tr>
-    </thead>
-    <tbody>
-      @for (data of configEntries; track data) {
+  @if (shouldUseReactTable) {
+    <div
+      data-testid="react-configuration-table"
+      zeppelin-react-mount="./ConfigurationTable"
+      [reactProps]="reactTableProps"
+    ></div>
+  } @else {
+    <nz-table
+      data-testid="configuration-table"
+      nzSize="small"
+      [nzData]="configEntries"
+      [nzFrontPagination]="false"
+      [nzShowPagination]="false"
+    >
+      <thead>
         <tr>
-          <td>{{ data[0] }}</td>
-          <td>{{ data[1] }}</td>
+          <th>Name</th>
+          <th>Value</th>
         </tr>
-      }
-    </tbody>
-  </nz-table>
+      </thead>
+      <tbody>
+        @for (data of configEntries; track data) {
+          <tr>
+            <td>{{ data[0] }}</td>
+            <td>{{ data[1] }}</td>
+          </tr>
+        }
+      </tbody>
+    </nz-table>
+  }
 </div>
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/configuration/configuration.component.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/configuration/configuration.component.ts
index 001e2212da..c40d52fe3a 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/configuration/configuration.component.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/configuration/configuration.component.ts
@@ -9,8 +9,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from 
'@angular/core';
-import { ConfigurationService } from '@zeppelin/services';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, 
OnInit } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { ConfigurationService, ReactFeatureService } from '@zeppelin/services';
 
 @Component({
   selector: 'zeppelin-configuration',
@@ -19,18 +22,57 @@ import { ConfigurationService } from '@zeppelin/services';
   changeDetection: ChangeDetectionStrategy.OnPush,
   standalone: false
 })
-export class ConfigurationComponent implements OnInit {
+export class ConfigurationComponent implements OnInit, OnDestroy {
   configEntries: Array<[string, string]> = [];
+  useReactTable = false;
+  reactTableFailed = false;
+
+  private destroy$ = new Subject<void>();
+  private lastReactTableProps: Record<string, unknown> | null = null;
 
   constructor(
     private configurationService: ConfigurationService,
+    private activatedRoute: ActivatedRoute,
+    private reactFeature: ReactFeatureService,
     private cdr: ChangeDetectorRef
   ) {}
 
+  get shouldUseReactTable(): boolean {
+    return this.useReactTable && !this.reactTableFailed;
+  }
+
+  // Memoized on configEntries, the only input that changes. An object literal 
in
+  // the template would hand ReactMountDirective a new identity on every
+  // change-detection pass and make it call handle.update() each time.
+  get reactTableProps(): Record<string, unknown> {
+    if (this.lastReactTableProps?.entries !== this.configEntries) {
+      this.lastReactTableProps = { entries: this.configEntries, onError: 
this.onReactTableError };
+    }
+    return this.lastReactTableProps;
+  }
+
+  readonly onReactTableError = (error: unknown): void => {
+    console.error('React configuration table error', error);
+    this.reactTableFailed = true;
+    this.cdr.markForCheck();
+  };
+
   ngOnInit() {
+    // Subscribed rather than read once: navigating between /configuration and
+    // /configuration?reactConfiguration reuses this component, so a snapshot
+    // read would keep the flag it saw first.
+    
this.activatedRoute.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe(params
 => {
+      this.useReactTable = this.reactFeature.isEnabled('configurationTable', 
params);
+      this.cdr.markForCheck();
+    });
     this.getAllConfig();
   }
 
+  ngOnDestroy() {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
   getAllConfig(): void {
     this.configurationService.getAll().subscribe(data => {
       this.configEntries = [...Object.entries<string>(data)].sort((a, b) => 
a[0].localeCompare(b[0]));
diff --git a/zeppelin-web-angular/src/app/services/react-feature.service.ts 
b/zeppelin-web-angular/src/app/services/react-feature.service.ts
index e921eacd8a..6d0d3897eb 100644
--- a/zeppelin-web-angular/src/app/services/react-feature.service.ts
+++ b/zeppelin-web-angular/src/app/services/react-feature.service.ts
@@ -13,7 +13,7 @@
 import { Injectable } from '@angular/core';
 import { parseBooleanFlag } from './query-flag.util';
 
-export type ReactSurface = 'publishedParagraph' | 'paragraphFooter';
+export type ReactSurface = 'publishedParagraph' | 'paragraphFooter' | 
'configurationTable';
 
 interface ReactSurfaceConfig {
   queryParam: string;
@@ -28,6 +28,10 @@ const SURFACES: Record<ReactSurface, ReactSurfaceConfig> = {
   paragraphFooter: {
     queryParam: 'reactFooter',
     defaultEnabled: false
+  },
+  configurationTable: {
+    queryParam: 'reactConfiguration',
+    defaultEnabled: false
   }
 };
 

Reply via email to