Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion zeppelin-web-angular/e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 being reimplemented, but do not build parity infrastructure ahead of need.
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 this route to React" flag, and no cross-framework parity project in this config. Write specs so they survive a route being reimplemented, but do not build parity infrastructure ahead of need.

### Write Framework-Neutral Specs

Expand Down
52 changes: 52 additions & 0 deletions zeppelin-web-angular/e2e/models/configuration-page.ts
Original file line number Diff line number Diff line change
@@ -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];
})
);
}
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
8 changes: 6 additions & 2 deletions zeppelin-web-angular/projects/zeppelin-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Angular host (port 4200) React remote (port 3001)
│ calls mount(el, props) │ │ exposes: │
└───────────────────────────────┘ │ ./PublishedParagraph │
│ ./ParagraphFooter │
│ ./ConfigurationTable │
└─────────────────────────┘
```

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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'
}
```
Expand Down
1 change: 1 addition & 0 deletions zeppelin-web-angular/projects/zeppelin-react/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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('');
});
});
Loading
Loading