Skip to content
Open
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 @@ -122,7 +122,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`), 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 separate cross-framework Playwright project in this config. The notebook parity registry records the Angular behavior baseline and links it to existing framework-neutral tests; add scenarios as migration work reaches them rather than duplicating the suite for both frameworks.
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), the configuration table swaps via a `?reactConfiguration=true` flag (`configuration/configuration.component`), and the notebook repository list swaps via a `?reactNotebookRepos=true` flag (`notebook-repos/notebook-repos.component`). All four are query params inside the hash. There is no app-wide "flip this route to React" flag and no separate cross-framework Playwright project in this config. The notebook parity registry records the Angular behavior baseline and links it to existing framework-neutral tests; add scenarios as migration work reaches them rather than duplicating the suite for both frameworks.

### Write Framework-Neutral Specs

Expand Down
11 changes: 7 additions & 4 deletions zeppelin-web-angular/e2e/models/notebook-repos-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export class NotebookReposPage extends BasePage {
constructor(page: Page) {
super(page);
this.pageDescription = page.locator("text=Manage your Notebook Repositories' settings.");
this.repositoryItems = page.locator('zeppelin-notebook-repo-item');
// Shared id, not the Angular element: /notebook-repos is a migration seam
// and these models have to survive the flip.
this.repositoryItems = page.locator('[data-testid="notebook-repo-item"]');
}

async navigate(): Promise<void> {
Expand All @@ -46,13 +48,14 @@ export class NotebookRepoItemPage extends BasePage {

constructor(page: Page, repoName: string) {
super(page);
this.repositoryCard = page.locator('nz-card').filter({ hasText: repoName });
this.repositoryCard = page.locator(`[data-testid="notebook-repo-item"][data-repo-name="${repoName}"]`);
this.repositoryName = this.repositoryCard.locator('.ant-card-head-title');
this.editButton = this.repositoryCard.locator('button:has-text("Edit")');
this.saveButton = this.repositoryCard.locator('button:has-text("Save")');
this.cancelButton = this.repositoryCard.locator('button:has-text("Cancel")');
this.settingTable = this.repositoryCard.locator('nz-table');
this.settingRows = this.repositoryCard.locator('tbody tr');
// .ant-table is what both ng-zorro and antd render.
this.settingTable = this.repositoryCard.locator('.ant-table');
this.settingRows = this.repositoryCard.locator('tbody tr:not(.ant-table-placeholder)');
}

async clickEdit(): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* 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 { NotebookReposPage, NotebookRepoItemPage } from '../../../models/notebook-repos-page';
import { NodeListPage } from '../../../models/node-list-page';
import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from '../../../utils';

const createNoteAtRoot = async (page: Page, name: string): Promise<string> => {
const response = await page.request.post('/api/notebook', {
data: { notePath: `/${name}`, addingEmptyParagraph: true },
failOnStatusCode: false
});
expect(response.ok(), `Create notebook failed: ${response.status()}`).toBe(true);
return JSON.parse(await response.text()).body as string;
};

test.describe('Notebook Repository - save reloads the note tree', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_REPOS);

// Run on both branches of the ZEPPELIN-6631 flag. The reload is the host's
// job either way, so a React list that swallows the save would show up here.
for (const { label, query } of [
{ label: 'Angular list', query: '' },
{ label: 'React list', query: '?reactNotebookRepos=true' }
]) {
test(`a repository save reloads notebooks and refreshes the shell note tree (${label})`, async ({ context }) => {
// Two clients on purpose. The header's note tree is destroyed when the
// dropdown closes and calls listNodes() again on every open, so it cannot
// tell a broadcast from its own refetch. The home route keeps a tree
// mounted, which leaves the broadcast as the only thing that can change it.
const watcher = await context.newPage();
const actor = await context.newPage();
const noteName = `NotebookRepoReload_${Date.now()}`;
let noteId = '';

try {
await watcher.goto('/#/');
await waitForZeppelinReady(watcher);
const noteTree = new NodeListPage(watcher);
await expect(noteTree.nodeListContainer).toBeVisible();

await actor.goto(`/#/notebook-repos${query}`);
await waitForZeppelinReady(actor);
const reposPage = new NotebookReposPage(actor);
await expect(reposPage.repositoryItems.first()).toBeVisible({ timeout: 20000 });
// JUSTIFIED: .first() picks the first configured repo; the page requires at least one.
const repoName = (await reposPage.repositoryItems.first().getAttribute('data-repo-name')) || '';
const repoItem = new NotebookRepoItemPage(actor, repoName);

await test.step('Given a note created out of band, which no broadcast has announced', async () => {
await expect(noteTree.noteLinkByName(noteName)).toHaveCount(0);
noteId = await createNoteAtRoot(actor, noteName);
// Creating a note over REST does not broadcast the list, so a tree
// that picked this up on its own would make the assertion after the
// save meaningless.
await expect(noteTree.noteLinkByName(noteName)).toHaveCount(0);
});

await test.step('When the repository settings are saved unchanged', async () => {
await repoItem.clickEdit();
await repoItem.clickSave();
});

await test.step('Then the note tree of the other client picks the note up', async () => {
await expect(noteTree.noteLinkByName(noteName)).toHaveCount(1, { timeout: 20000 });
});
} finally {
if (noteId) {
await actor.request.delete(`/api/notebook/${noteId}`, { failOnStatusCode: false });
}
}
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* 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 REPO_ITEM; only the React branch has a mount host around
// it. Which branch is live is therefore a question about MOUNT.
const REPO_ITEM = '[data-testid="notebook-repo-item"]';
const MOUNT = '[data-testid="react-notebook-repo-list"]';
const MOUNTED_LIST = `${MOUNT} [data-testid="notebook-repo-list"]`;

const openRepos = async (page: Page, query = ''): Promise<void> => {
await page.goto(`/#/notebook-repos${query}`);
await waitForZeppelinReady(page);
};

const settingRows = (page: Page, root: string) => page.locator(`${root} tbody tr:not(.ant-table-placeholder)`);

test.describe('Notebook Repository - React list behind a flag', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_REPOS);

test('without the flag, the Angular list renders', async ({ page }) => {
await openRepos(page);

await expect(page.locator(REPO_ITEM).first()).toBeVisible();
await expect(page.locator(MOUNT)).toHaveCount(0);
await expect(page.locator(`${REPO_ITEM} button:has-text("Edit")`).first()).toBeVisible();
});

test('with reactNotebookRepos=true, the React list renders instead', async ({ page }) => {
await openRepos(page, '?reactNotebookRepos=true');

await expect(page.locator(MOUNTED_LIST)).toBeVisible({ timeout: 15000 });
await expect(page.locator(REPO_ITEM).first()).toBeVisible();
});

test('with a bare reactNotebookRepos flag, the React list renders', async ({ page }) => {
await openRepos(page, '?reactNotebookRepos');

await expect(page.locator(MOUNTED_LIST)).toBeVisible({ timeout: 15000 });
});

test('both lists show the same repositories and settings', async ({ page }) => {
await openRepos(page);
await expect(page.locator(REPO_ITEM).first()).toBeVisible();
const angularNames = await page
.locator(`${REPO_ITEM}`)
.evaluateAll(cards => cards.map(card => card.getAttribute('data-repo-name')));
const angularRows = await settingRows(page, REPO_ITEM).allInnerTexts();

await openRepos(page, '?reactNotebookRepos=true');
await expect(page.locator(MOUNTED_LIST)).toBeVisible({ timeout: 15000 });
const reactNames = await page
.locator(`${MOUNT} ${REPO_ITEM}`)
.evaluateAll(cards => cards.map(card => card.getAttribute('data-repo-name')));
// JUSTIFIED: prefer-web-first-assertions. Both sides must be read the same
// way to compare, and toHaveText() reads textContent, which loses the cell
// separator innerText adds.
const reactRows = await settingRows(page, `${MOUNT} ${REPO_ITEM}`).allInnerTexts();

// The host still owns the fetch and the sort, so the remote must not
// reshape what it is handed.
expect(reactNames).toEqual(angularNames);
expect(reactRows).toEqual(angularRows);
});

test('the React card switches to inputs on edit and back on cancel', async ({ page }) => {
await openRepos(page, '?reactNotebookRepos=true');
await expect(page.locator(MOUNTED_LIST)).toBeVisible({ timeout: 15000 });

const card = page.locator(`${MOUNT} ${REPO_ITEM}`).first();
const value = (await settingRows(page, `${MOUNT} ${REPO_ITEM}`).first().locator('td').nth(1).innerText()).trim();

await card.getByRole('button', { name: 'Edit' }).click();
const input = card.locator('input').first();
await expect(input).toBeVisible();
await expect(input).toHaveValue(value);

await card.getByRole('button', { name: 'Cancel' }).click();
await expect(card.getByRole('button', { name: 'Edit' })).toBeVisible();
await expect(card.locator('input')).toHaveCount(0);
});

test('when the remote fails to load, the Angular list 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 list 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 fallback.
const remoteRequested = page.waitForRequest('**/remoteEntry.js');
await openRepos(page, '?reactNotebookRepos=true');
await remoteRequested;
});

await test.step('Then the Angular list takes over', async () => {
await expect(page.locator(REPO_ITEM).first()).toBeVisible({ timeout: 15000 });
await expect(page.locator(MOUNT)).toHaveCount(0);
});
});
});
2 changes: 1 addition & 1 deletion zeppelin-web-angular/projects/zeppelin-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,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, `?reactFooter=true` to a notebook URL, or `?reactConfiguration=true` to the configuration URL to activate React mode.
Append `?react=true` to any published paragraph URL, `?reactFooter=true` to a notebook URL, `?reactConfiguration=true` to the configuration URL, or `?reactNotebookRepos=true` to the notebook repository URL to activate React mode.

## Setup

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 @@ -11,5 +11,6 @@
*/

export { ConfigurationTable, mount as mountConfigurationTable } from './pages/ConfigurationTable';
export { NotebookRepoList, mount as mountNotebookRepoList } from './pages/NotebookRepoList';
export { PublishedParagraph, mount } from './pages/PublishedParagraph';
export { ParagraphFooter, mount as mountParagraphFooter } from './components/paragraph/ParagraphFooter';
Loading
Loading