Skip to content
Draft
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
106 changes: 106 additions & 0 deletions superset-frontend/playwright/pages/HomePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,21 @@
import { Locator, Page } from '@playwright/test';
import { gotoWithRetry } from '../helpers/navigation';
import { URL } from '../utils/urls';
import { DeleteConfirmationModal } from '../components/modals';

/** The two card lists on Home that this page object can drive. */
export type HomeAssetKind = 'Dashboards' | 'Charts';

/** The three list tabs shared by both the Dashboards and Charts sections. */
export type HomeListTab = 'Favorite' | 'Mine' | 'All';

/**
* Home/welcome page object.
*
* Covers the "Recents" activity panel and the Dashboards/Charts card lists,
* including the card kebab menu's delete (or, with SOFT_DELETE on, archive)
* action -- used to verify that deleting a dashboard/chart promptly refreshes
* the Recents panel (see recent-activity-refresh.spec.ts).
*/
export class HomePage {
private readonly page: Page;
Expand All @@ -48,4 +60,98 @@ export class HomePage {
getRecentsSection(): Locator {
return this.page.getByText(HomePage.SECTION_NAMES.RECENTS, { exact: true });
}

/**
* Expands the Recents panel if it is currently collapsed.
*
* Collapse state persists across sessions (it is saved to localStorage),
* so a prior run/test can leave it collapsed, hiding its content -- and
* with it, the "Viewed" tab {@link getRecentsViewedCount} depends on.
*/
async expandRecents(): Promise<void> {
const header = this.page.getByRole('button', { name: 'Recents' });
if ((await header.getAttribute('aria-expanded')) === 'false') {
await header.click();
}
}

/**
* Gets the collapsible panel for a given card list ("Dashboards" or
* "Charts"). Scoping to this panel (rather than matching card/tab text
* loosely on the whole page) is what keeps lookups from also matching the
* visually-similar cards in the Recents panel above it.
*/
getPanel(kind: HomeAssetKind): Locator {
return this.page
.locator('.ant-collapse-item')
.filter({ has: this.page.getByText(kind, { exact: true }) });
}

/**
* Switches a card list's Favorite/Mine/All tab.
*/
async switchTab(kind: HomeAssetKind, tab: HomeListTab): Promise<void> {
await this.getPanel(kind)
.getByRole('tab', { name: tab, exact: true })
.click();
}

/**
* Gets a specific card by its title within a card list panel.
*/
getCard(kind: HomeAssetKind, title: string): Locator {
return this.getPanel(kind)
.locator('[data-test="styled-card"]')
.filter({ hasText: title });
}

/**
* Gets the count of a title's occurrences within the Recents "Viewed" tab
* specifically -- used to assert that a just-deleted item's Recents entry
* has (or has not yet) disappeared.
*/
async getRecentsViewedCount(title: string): Promise<number> {
const recentsPanel = this.page
.locator('.ant-collapse-item')
.filter({ has: this.getRecentsSection() });
const viewedTab = recentsPanel.getByRole('tab', {
name: 'Viewed',
exact: true,
});
if (await viewedTab.count()) {
await viewedTab.click();
}
return recentsPanel.getByText(title, { exact: true }).count();
}

/**
* Deletes (or, with SOFT_DELETE on, archives) a card via its kebab menu.
*
* The kebab button is only revealed on hover, so a plain `.click()` -- no
* actionability wait sees it as interactable until the hover state is
* actually entered -- needs a real `.hover()` first, not `force: true`.
*
* @param kind - Which card list the card belongs to.
* @param title - The card's title.
* @param menuTestId - The kebab menu's `data-test` (differs between
* dashboard and chart cards).
* @param deleteOptionTestId - The delete/archive menu item's `data-test`
* (also differs between dashboard and chart cards).
*/
async deleteCardViaMenu(
kind: HomeAssetKind,
title: string,
menuTestId: string,
deleteOptionTestId: string,
): Promise<void> {
const card = this.getCard(kind, title).first();
await card.hover();
await card.locator(`[data-test="${menuTestId}"]`).click();
await this.page.locator(`[data-test="${deleteOptionTestId}"]`).click();

const deleteModal = new DeleteConfirmationModal(this.page);
await deleteModal.waitForVisible();
await deleteModal.confirmDeletion();
await deleteModal.waitForHidden();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* 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.
*/

/**
* Regression coverage for the Home page's "Recents" panel after a
* dashboard/chart is deleted from the Dashboards or Charts card list.
*
* Before this fix, deleting an item from some tabs (notably Mine) could
* leave its stale "Viewed" entry sitting in Recents after the delete had
* already succeeded, which reads as if the delete silently failed. Each
* test here creates a throwaway dashboard/chart, views it to seed a real
* Recents entry, deletes it from a specific tab, and asserts the Recents
* entry disappears promptly rather than polling/sleeping for a fixed
* window -- a regression that reintroduces staleness should fail here
* instead of flaking.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { apiPostChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
import { apiPost } from '../../helpers/api/requests';
import { DashboardPage } from '../../pages/DashboardPage';
import { ExplorePage } from '../../pages/ExplorePage';
import { HomePage, HomeListTab } from '../../pages/HomePage';
import { Toast } from '../../components/core';
import { TIMEOUT } from '../../utils/constants';

// Uncomment to force video recording when running this file locally (e.g. to
// produce a demo recording). CI relies on the 'retain-on-failure' policy in
// playwright.config.ts instead, so this stays off for the contributed test.
// testWithAssets.use({ video: 'on' });

const DATASET_NAME = 'birth_names';

/** Short, readable, still worker-unique: no epoch timestamps in the title. */
function assetName(prefix: string, testInfo: { parallelIndex: number }) {
return `${prefix}_${testInfo.parallelIndex}`;
}

for (const tab of ['Favorite', 'Mine', 'All'] as HomeListTab[]) {
testWithAssets(
`deleting a dashboard from the ${tab} tab refreshes Recents`,
async ({ page, testAssets }, testInfo) => {
const name = assetName(`PR_Dash_${tab}`, testInfo);

const createResp = await apiPostDashboard(page, {
dashboard_title: name,
});
expect(createResp.ok()).toBe(true);
const { id: dashboardId } = await createResp.json();
testAssets.trackDashboard(dashboardId);

if (tab === 'Favorite') {
const favResp = await apiPost(
page,
`api/v1/dashboard/${dashboardId}/favorites/`,
);
expect(favResp.ok()).toBe(true);
}

// View it so a "mount_dashboard" log entry seeds a real Recents card.
// Logging is debounced client-side (loggerMiddleware batches events up
// to 1s before sending), so wait for the actual beacon request rather
// than assuming it has landed once the page itself has loaded.
const dashboardPage = new DashboardPage(page);
const logRequest = page.waitForResponse(
resp =>
resp.request().method() === 'POST' &&
resp.url().includes('/log/') &&
(resp.request().postData() ?? '').includes('mount_dashboard'),
{ timeout: TIMEOUT.API_RESPONSE },
);
await dashboardPage.gotoById(dashboardId);
await dashboardPage.waitForLoad();
await logRequest;

const homePage = new HomePage(page);
await homePage.goto();
await homePage.expandRecents();

await expect(async () => {
expect(await homePage.getRecentsViewedCount(name)).toBeGreaterThan(0);
}).toPass({ timeout: TIMEOUT.API_RESPONSE });

await homePage.switchTab('Dashboards', tab);
await expect(homePage.getCard('Dashboards', name)).toBeVisible();

await homePage.deleteCardViaMenu(
'Dashboards',
name,
'dashboard-card-menu',
'dashboard-card-option-delete-button',
);

const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();

await expect(homePage.getCard('Dashboards', name)).toHaveCount(0);
await expect(async () => {
expect(await homePage.getRecentsViewedCount(name)).toBe(0);
}).toPass({ timeout: TIMEOUT.API_RESPONSE });
},
);
}

for (const tab of ['Favorite', 'Mine', 'All'] as HomeListTab[]) {
testWithAssets(
`deleting a chart from the ${tab} tab refreshes Recents`,
async ({ page, testAssets }, testInfo) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const name = assetName(`PR_Chart_${tab}`, testInfo);

const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}

const createResp = await apiPostChart(page, {
slice_name: name,
viz_type: 'table',
datasource_id: dataset.id,
datasource_type: 'table',
params: JSON.stringify({
viz_type: 'table',
datasource: `${dataset.id}__table`,
query_mode: 'aggregate',
groupby: ['name'],
metrics: ['count'],
adhoc_filters: [],
row_limit: 5,
}),
});
expect(createResp.ok()).toBe(true);
const chartBody = await createResp.json();
const chartId: number = chartBody.result?.id ?? chartBody.id;
testAssets.trackChart(chartId);

if (tab === 'Favorite') {
const favResp = await apiPost(
page,
`api/v1/chart/${chartId}/favorites/`,
);
expect(favResp.ok()).toBe(true);
}

// View it in Explore so a "mount_explorer" log entry seeds a real
// Recents card. See the dashboard test above for why this waits for
// the actual (debounced) log beacon rather than just the page load.
const explorePage = new ExplorePage(page);
const logRequest = page.waitForResponse(
resp =>
resp.request().method() === 'POST' &&
resp.url().includes('/log/') &&
(resp.request().postData() ?? '').includes('mount_explorer'),
{ timeout: TIMEOUT.API_RESPONSE },
);
await explorePage.goto(chartId);
await logRequest;

const homePage = new HomePage(page);
await homePage.goto();
await homePage.expandRecents();

await expect(async () => {
expect(await homePage.getRecentsViewedCount(name)).toBeGreaterThan(0);
}).toPass({ timeout: TIMEOUT.API_RESPONSE });

await homePage.switchTab('Charts', tab);
await expect(homePage.getCard('Charts', name)).toBeVisible();

await homePage.deleteCardViaMenu(
'Charts',
name,
'chart-card-menu',
'chart-list-delete-option',
);

const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();

await expect(homePage.getCard('Charts', name)).toHaveCount(0);
await expect(async () => {
expect(await homePage.getRecentsViewedCount(name)).toBe(0);
}).toPass({ timeout: TIMEOUT.API_RESPONSE });
},
);
}
4 changes: 3 additions & 1 deletion superset-frontend/src/features/home/ActivityTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ interface ActivityProps {
setActiveChild: (arg0: string) => void;
activityData: ActivityData;
isFetchingActivityData: boolean;
refreshKey?: number;
}

const Styles = styled.div`
Expand Down Expand Up @@ -129,6 +130,7 @@ export default function ActivityTable({
activityData,
user,
isFetchingActivityData,
refreshKey = 0,
}: ActivityProps) {
const [editedCards, setEditedCards] = useState<ActivityData[]>();
const [isFetchingEditedCards, setIsFetchingEditedCards] = useState(false);
Expand All @@ -155,7 +157,7 @@ export default function ActivityTable({
isMounted = false;
setIsFetchingEditedCards(false);
};
}, [activeChild, user.userId]);
}, [activeChild, user.userId, refreshKey]);
Comment thread
rusackas marked this conversation as resolved.
Resolved

const tabs = [
{
Expand Down
Loading
Loading