mirror of
https://github.com/apache/superset.git
synced 2026-04-28 12:34:23 +00:00
Compare commits
33 Commits
dashboard-
...
mobile-das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9dd05e4bc | ||
|
|
9da50c0cc3 | ||
|
|
80fe26ef92 | ||
|
|
eb8946ba3b | ||
|
|
3970a53fe9 | ||
|
|
61aa514c21 | ||
|
|
70274f9d24 | ||
|
|
1b5a01aab1 | ||
|
|
1b127432ff | ||
|
|
4c2973fe8a | ||
|
|
c098053785 | ||
|
|
164faa8810 | ||
|
|
6f38727041 | ||
|
|
1d21516b77 | ||
|
|
94c448b4b1 | ||
|
|
4f5690a7fa | ||
|
|
4941cfe7fd | ||
|
|
2ff2dea3cb | ||
|
|
a820356c5b | ||
|
|
6ffc954b71 | ||
|
|
c38652bcd4 | ||
|
|
1f265dd399 | ||
|
|
2d3577683f | ||
|
|
711da4d681 | ||
|
|
e82249d663 | ||
|
|
2d91c138c4 | ||
|
|
7dddbb0f4e | ||
|
|
960fa46bb9 | ||
|
|
32c1bf0f00 | ||
|
|
1d1fc7a9ec | ||
|
|
594cf060b8 | ||
|
|
665c283989 | ||
|
|
247bd9c3c3 |
@@ -186,11 +186,11 @@
|
||||
"markdown-to-jsx": "^9.7.6",
|
||||
"match-sorter": "^6.3.4",
|
||||
"memoize-one": "^5.2.1",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"ol": "^10.8.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"query-string": "9.3.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^17.0.2",
|
||||
|
||||
@@ -81,6 +81,15 @@ const headerStyles = (theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Mobile: center the title between left and right panels */
|
||||
@media (max-width: 767px) {
|
||||
.title-panel {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const buttonsStyles = (theme: SupersetTheme) => css`
|
||||
@@ -108,6 +117,7 @@ export type PageHeaderWithActionsProps = {
|
||||
showFaveStar: boolean;
|
||||
showMenuDropdown?: boolean;
|
||||
faveStarProps: FaveStarProps;
|
||||
leftPanelItems?: ReactNode;
|
||||
titlePanelAdditionalItems: ReactNode;
|
||||
rightPanelAdditionalItems: ReactNode;
|
||||
additionalActionsMenu: ReactElement;
|
||||
@@ -124,6 +134,7 @@ export const PageHeaderWithActions = ({
|
||||
certificatiedBadgeProps,
|
||||
showFaveStar,
|
||||
faveStarProps,
|
||||
leftPanelItems,
|
||||
titlePanelAdditionalItems,
|
||||
rightPanelAdditionalItems,
|
||||
additionalActionsMenu,
|
||||
@@ -134,6 +145,7 @@ export const PageHeaderWithActions = ({
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<div css={headerStyles} className="header-with-actions">
|
||||
{leftPanelItems}
|
||||
<div className="title-panel">
|
||||
<DynamicEditableTitle {...editableTitleProps} />
|
||||
{showTitlePanelItems && (
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Mobile dashboard viewing tests verify that dashboards can be viewed
|
||||
* and interacted with on mobile devices.
|
||||
*
|
||||
* These tests assume the World Bank's Health sample dashboard exists.
|
||||
*/
|
||||
|
||||
// Use iPhone 12 viewport for mobile tests
|
||||
const mobileViewport = devices['iPhone 12'];
|
||||
|
||||
test.describe('Mobile Dashboard Viewing', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to dashboard list to find a dashboard
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('dashboard list renders in card view on mobile', async ({ page }) => {
|
||||
// On mobile, dashboard list should show cards, not table
|
||||
// Look for card elements
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
|
||||
// Should have at least one card if dashboards exist
|
||||
// (This test may need adjustment based on test data availability)
|
||||
const cardCount = await cards.count();
|
||||
|
||||
// Either cards are visible, or the empty state is shown
|
||||
if (cardCount > 0) {
|
||||
await expect(cards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
} else {
|
||||
// No dashboards - that's OK for the test environment
|
||||
await expect(
|
||||
page
|
||||
.getByText('No dashboards yet')
|
||||
.or(page.locator('[data-test="listview-table"]')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile search button appears in dashboard list', async ({ page }) => {
|
||||
// On mobile, the search/filter button should appear in the header
|
||||
const searchButton = page
|
||||
.locator('[aria-label="Search"]')
|
||||
.or(page.locator('[data-test="mobile-search-button"]'));
|
||||
|
||||
// Search button should be visible on mobile
|
||||
await expect(searchButton.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('tapping dashboard card opens the dashboard', async ({ page }) => {
|
||||
// Find a dashboard card
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
// Click the first card
|
||||
await cards.first().click();
|
||||
|
||||
// Should navigate to dashboard view
|
||||
await page.waitForURL(
|
||||
url => url.pathname.includes('superset/dashboard'),
|
||||
{
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
},
|
||||
);
|
||||
|
||||
// Dashboard should load (look for dashboard content)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="dashboard-content-wrapper"]')
|
||||
.or(page.locator('.dashboard')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Dashboard Interaction', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
// Skip this test suite if no dashboards exist
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
await page.close();
|
||||
|
||||
if (cardCount === 0) {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('dashboard loads and shows charts on mobile', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard to load
|
||||
await page.waitForURL(
|
||||
url => url.pathname.includes('superset/dashboard'),
|
||||
{
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
},
|
||||
);
|
||||
|
||||
// Dashboard content should be visible
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="dashboard-content-wrapper"]')
|
||||
.or(page.locator('.dashboard')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Charts should start loading (look for chart containers)
|
||||
const chartContainers = page
|
||||
.locator('[data-test="chart-container"]')
|
||||
.or(page.locator('.dashboard-chart'));
|
||||
|
||||
// Wait for at least one chart to be visible (with timeout)
|
||||
await expect(chartContainers.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD * 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('dashboard header shows hamburger menu on mobile', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard
|
||||
await page.waitForURL(
|
||||
url => url.pathname.includes('superset/dashboard'),
|
||||
{
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
},
|
||||
);
|
||||
|
||||
// Look for the hamburger menu / more actions button
|
||||
const menuButton = page
|
||||
.locator('[data-test="actions-trigger"]')
|
||||
.or(page.locator('[aria-label="Menu actions trigger"]'));
|
||||
|
||||
await expect(menuButton.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('refresh dashboard works from mobile menu', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard
|
||||
await page.waitForURL(
|
||||
url => url.pathname.includes('superset/dashboard'),
|
||||
{
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
},
|
||||
);
|
||||
|
||||
// Open the actions menu
|
||||
const menuButton = page
|
||||
.locator('[data-test="actions-trigger"]')
|
||||
.or(page.locator('[aria-label="Menu actions trigger"]'));
|
||||
|
||||
if ((await menuButton.count()) > 0) {
|
||||
await menuButton.first().click();
|
||||
|
||||
// Look for refresh option
|
||||
const refreshOption = page.getByText('Refresh dashboard');
|
||||
|
||||
if ((await refreshOption.count()) > 0) {
|
||||
await refreshOption.click();
|
||||
|
||||
// Should show success toast or refresh the charts
|
||||
// This is hard to verify without checking network requests
|
||||
// Just verify the menu closes and we're still on the dashboard
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('superset/dashboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Filter Drawer', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test('filter button appears on dashboards with filters', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard
|
||||
await page.waitForURL(
|
||||
url => url.pathname.includes('superset/dashboard'),
|
||||
{
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
},
|
||||
);
|
||||
|
||||
// Give filters time to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check for filter button (only visible if dashboard has filters)
|
||||
const filterButton = page
|
||||
.locator('[data-test="filter-icon"]')
|
||||
.or(
|
||||
page
|
||||
.locator('[aria-label="Filters"]')
|
||||
.or(page.locator('.mobile-filter-button')),
|
||||
);
|
||||
|
||||
const filterCount = await filterButton.count();
|
||||
|
||||
// The test passes whether filters exist or not
|
||||
// If filters exist, button should be visible
|
||||
// If no filters, that's also valid
|
||||
if (filterCount > 0) {
|
||||
await expect(filterButton.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('filter drawer opens when filter button is tapped', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard
|
||||
await page.waitForURL(
|
||||
url => url.pathname.includes('superset/dashboard'),
|
||||
{
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
},
|
||||
);
|
||||
|
||||
// Give filters time to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check for filter button
|
||||
const filterButton = page
|
||||
.locator('[data-test="filter-icon"]')
|
||||
.or(page.locator('[aria-label="Filters"]'));
|
||||
|
||||
if ((await filterButton.count()) > 0) {
|
||||
await filterButton.first().click();
|
||||
|
||||
// Filter drawer should open
|
||||
const drawer = page
|
||||
.locator('.ant-drawer-open')
|
||||
.or(page.locator('[data-test="filter-bar"]'));
|
||||
|
||||
await expect(drawer.first()).toBeVisible({
|
||||
timeout: TIMEOUT.FORM_LOAD,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
import { URL } from '../../utils/urls';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Mobile navigation tests verify the MobileRouteGuard behavior
|
||||
* and mobile-specific navigation patterns.
|
||||
*
|
||||
* These tests run with a mobile viewport to trigger mobile-specific behavior.
|
||||
*/
|
||||
|
||||
// Use iPhone 12 viewport for mobile tests
|
||||
const mobileViewport = devices['iPhone 12'];
|
||||
|
||||
test.describe('Mobile Navigation', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear any previous bypass flags
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
sessionStorage.removeItem('mobile-bypass');
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile viewport redirects from chart list to MobileUnsupported page', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to chart list (not mobile-supported)
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should show the MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Primary action buttons should be visible
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'View Dashboards' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Go to Welcome Page' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile viewport allows access to dashboard list', async ({ page }) => {
|
||||
// Navigate to dashboard list (mobile-supported)
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show dashboard list content (look for dashboard list elements)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
|
||||
test('mobile viewport allows access to welcome page', async ({ page }) => {
|
||||
// Navigate to welcome page (mobile-supported)
|
||||
await page.goto(URL.WELCOME);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show welcome page content
|
||||
await expect(
|
||||
page.getByText('Recents').or(page.getByText('Dashboards')).first(),
|
||||
).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('View Dashboards button navigates to dashboard list', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click View Dashboards button
|
||||
await page.getByRole('button', { name: 'View Dashboards' }).click();
|
||||
|
||||
// Should navigate to dashboard list
|
||||
await page.waitForURL(url => url.pathname.includes('dashboard/list'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Dashboard list should be accessible
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
|
||||
test('Go to Welcome Page button navigates to welcome', async ({ page }) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click Go to Welcome Page button
|
||||
await page.getByRole('button', { name: 'Go to Welcome Page' }).click();
|
||||
|
||||
// Should navigate to welcome page
|
||||
await page.waitForURL(url => url.pathname.includes('superset/welcome'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('Continue anyway bypasses guard for the session', async ({ page }) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click Continue anyway
|
||||
await page.getByText('Continue anyway').click();
|
||||
|
||||
// Should navigate to the original destination
|
||||
await page.waitForURL(url => url.pathname.includes('chart/list'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Verify bypass is set in sessionStorage
|
||||
const bypassValue = await page.evaluate(() =>
|
||||
sessionStorage.getItem('mobile-bypass'),
|
||||
);
|
||||
expect(bypassValue).toBe('true');
|
||||
|
||||
// Navigate away and back - should still be bypassed
|
||||
await page.goto(URL.WELCOME);
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page anymore
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
});
|
||||
|
||||
test('SQL Lab is not accessible on mobile', async ({ page }) => {
|
||||
// Navigate to SQL Lab (not mobile-supported)
|
||||
await page.goto(URL.SQLLAB);
|
||||
|
||||
// Should show the MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Desktop Navigation (control group)', () => {
|
||||
// Use default desktop viewport
|
||||
|
||||
test('desktop viewport allows access to all routes', async ({ page }) => {
|
||||
// Navigate to chart list
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show chart list content
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
});
|
||||
145
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal file
145
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile testing utilities for Jest tests.
|
||||
*
|
||||
* Note: We mock 'antd' directly rather than '@superset-ui/core/components' because
|
||||
* mocking the latter causes circular dependency issues with ActionButton during
|
||||
* jest.requireActual evaluation. Since Grid is re-exported from antd, mocking
|
||||
* antd at the source works correctly.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard mobile breakpoint values (below md breakpoint)
|
||||
*/
|
||||
export const mobileBreakpoints = {
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: false,
|
||||
lg: false,
|
||||
xl: false,
|
||||
xxl: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard desktop breakpoint values (at or above md breakpoint)
|
||||
*/
|
||||
export const desktopBreakpoints = {
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: true,
|
||||
lg: true,
|
||||
xl: true,
|
||||
xxl: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock for antd Grid.useBreakpoint that returns mobile breakpoints.
|
||||
* Use this at the top of test files that need to simulate mobile viewport.
|
||||
*
|
||||
* @example
|
||||
* jest.mock('antd', () => mockAntdWithMobileBreakpoint());
|
||||
*/
|
||||
export const mockAntdWithMobileBreakpoint = () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => mobileBreakpoints,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock for antd Grid.useBreakpoint that returns desktop breakpoints.
|
||||
* Use this at the top of test files that need to simulate desktop viewport.
|
||||
*
|
||||
* @example
|
||||
* jest.mock('antd', () => mockAntdWithDesktopBreakpoint());
|
||||
*/
|
||||
export const mockAntdWithDesktopBreakpoint = () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => desktopBreakpoints,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Common mobile viewport dimensions for reference
|
||||
*/
|
||||
export const mobileViewports = {
|
||||
iPhoneX: { width: 375, height: 812 },
|
||||
iPhoneSE: { width: 375, height: 667 },
|
||||
iPhone12Pro: { width: 390, height: 844 },
|
||||
pixel5: { width: 393, height: 851 },
|
||||
samsungGalaxyS20: { width: 360, height: 800 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Common tablet viewport dimensions for reference
|
||||
*/
|
||||
export const tabletViewports = {
|
||||
iPadMini: { width: 768, height: 1024 },
|
||||
iPadAir: { width: 820, height: 1180 },
|
||||
iPadPro11: { width: 834, height: 1194 },
|
||||
surfacePro7: { width: 912, height: 1368 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to mock sessionStorage for tests
|
||||
*/
|
||||
export const mockSessionStorage = () => {
|
||||
const store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: jest.fn((key: string) => store[key] ?? null),
|
||||
setItem: jest.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: jest.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: jest.fn(() => {
|
||||
Object.keys(store).forEach(key => delete store[key]);
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: jest.fn((index: number) => Object.keys(store)[index] ?? null),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to create a failing sessionStorage mock (simulates privacy mode)
|
||||
*/
|
||||
export const mockSessionStorageFailure = () => ({
|
||||
getItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
setItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
removeItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
clear: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
length: 0,
|
||||
key: jest.fn(() => null),
|
||||
});
|
||||
@@ -160,6 +160,17 @@ const Styles = styled.div<{ height: number; width?: number }>`
|
||||
margin: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Full width charts on mobile */
|
||||
@media (max-width: 767px) {
|
||||
width: 100% !important;
|
||||
|
||||
.slice_container,
|
||||
.slice_container div {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingDiv = styled.div`
|
||||
|
||||
@@ -42,6 +42,14 @@ const CardContainer = styled.div<{ showThumbnails?: boolean }>`
|
||||
? `${theme.sizeUnit * 8 + 3}px ${theme.sizeUnit * 20}px`
|
||||
: `${theme.sizeUnit * 8 + 1}px ${theme.sizeUnit * 20}px`
|
||||
};
|
||||
|
||||
/* Full-width cards on mobile */
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-gap: ${theme.sizeUnit * 4}px;
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
padding-right: ${theme.sizeUnit * 4}px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -350,3 +350,225 @@ describe('ListView', () => {
|
||||
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile support tests
|
||||
test('respects forceViewMode prop and hides view toggle', () => {
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div>Card</div>}
|
||||
forceViewMode="card"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// View toggle should not be present when forceViewMode is set
|
||||
expect(screen.queryByLabelText('card-view')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('list-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows card view when forceViewMode is card', () => {
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div data-test="test-card">Card Content</div>}
|
||||
forceViewMode="card"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Should render cards, not table rows
|
||||
expect(screen.getAllByTestId('test-card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('renders mobile filter drawer when mobileFiltersOpen is true', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search Dashboards"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Drawer should be visible with custom title
|
||||
expect(screen.getByText('Search Dashboards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls setMobileFiltersOpen(false) when drawer is closed', async () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Click the close button on the drawer
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(setMobileFiltersOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('mobile drawer contains FilterControls', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// The drawer should contain filter controls (comboboxes for select filters)
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mobile drawer contains CardSortSelect when in card view with sort options', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...mockedPropsComprehensive}
|
||||
renderCard={() => <div>Card</div>}
|
||||
forceViewMode="card"
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
initialSort={[{ id: 'something' }]}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Sort select should be present (may be multiple - one in drawer, one in header)
|
||||
const sortSelects = screen.getAllByTestId('card-sort-select');
|
||||
expect(sortSelects.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('uses default drawer title when mobileFiltersDrawerTitle not provided', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Default title should be 'Search'
|
||||
expect(screen.getByText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render drawer when mobileFiltersOpen is false', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen={false}
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Drawer should not be visible (title not in visible content)
|
||||
// Note: Ant Design drawer might still be in DOM but hidden
|
||||
const drawer = document.querySelector('.ant-drawer-open');
|
||||
expect(drawer).toBeNull();
|
||||
});
|
||||
|
||||
test('does not render mobile drawer without setMobileFiltersOpen prop', () => {
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView {...propsWithoutSort} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// No drawer elements should exist
|
||||
const drawer = document.querySelector('.ant-drawer');
|
||||
expect(drawer).toBeNull();
|
||||
});
|
||||
|
||||
test('forceViewMode table shows table view', () => {
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div data-test="card">Card</div>}
|
||||
forceViewMode="table"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Should show table, not cards
|
||||
expect(screen.queryByTestId('card')).not.toBeInTheDocument();
|
||||
// Table should be present
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Button,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Icons,
|
||||
EmptyState,
|
||||
Loading,
|
||||
@@ -64,6 +65,14 @@ const ListViewStyles = styled.div`
|
||||
flex-wrap: wrap;
|
||||
column-gap: ${theme.sizeUnit * 7}px;
|
||||
row-gap: ${theme.sizeUnit * 4}px;
|
||||
|
||||
/* Hide desktop filters and sort on mobile when mobile drawer is used */
|
||||
@media (max-width: 767px) {
|
||||
.desktop-filters,
|
||||
.desktop-sort {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +172,11 @@ const ViewModeContainer = styled.div`
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
|
||||
/* Hide view mode toggle on mobile - force card view */
|
||||
@media (max-width: 767px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
display: inline-block;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
@@ -194,6 +208,30 @@ const EmptyWrapper = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const MobileFilterDrawerContent = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
|
||||
/* Make filter inputs stack vertically and full-width */
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Override inline filter styling for vertical layout */
|
||||
.filter-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
.ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ViewModeToggle = ({
|
||||
mode,
|
||||
setMode,
|
||||
@@ -257,12 +295,19 @@ export interface ListViewProps<T extends object = any> {
|
||||
renderCard?: (row: T & { loading: boolean }) => ReactNode;
|
||||
cardSortSelectOptions?: Array<CardSortSelectOption>;
|
||||
defaultViewMode?: ViewModeType;
|
||||
forceViewMode?: ViewModeType;
|
||||
highlightRowId?: number;
|
||||
showThumbnails?: boolean;
|
||||
emptyState?: EmptyStateProps;
|
||||
columnsForWrapText?: string[];
|
||||
enableBulkTag?: boolean;
|
||||
bulkTagResourceName?: string;
|
||||
/** Whether mobile filters drawer is open (controlled externally) */
|
||||
mobileFiltersOpen?: boolean;
|
||||
/** Callback to set mobile filters drawer open state */
|
||||
setMobileFiltersOpen?: (open: boolean) => void;
|
||||
/** Title for the mobile filters drawer */
|
||||
mobileFiltersDrawerTitle?: string;
|
||||
}
|
||||
|
||||
export function ListView<T extends object = any>({
|
||||
@@ -284,6 +329,7 @@ export function ListView<T extends object = any>({
|
||||
showThumbnails,
|
||||
cardSortSelectOptions,
|
||||
defaultViewMode = 'card',
|
||||
forceViewMode,
|
||||
highlightRowId,
|
||||
emptyState,
|
||||
columnsForWrapText,
|
||||
@@ -291,6 +337,9 @@ export function ListView<T extends object = any>({
|
||||
bulkTagResourceName,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
mobileFiltersOpen = false,
|
||||
setMobileFiltersOpen,
|
||||
mobileFiltersDrawerTitle,
|
||||
}: ListViewProps<T>) {
|
||||
const {
|
||||
getTableProps,
|
||||
@@ -319,6 +368,7 @@ export function ListView<T extends object = any>({
|
||||
initialFilters: filters,
|
||||
renderCard: Boolean(renderCard),
|
||||
defaultViewMode,
|
||||
forceViewMode,
|
||||
});
|
||||
const allowBulkTagActions = bulkTagResourceName && enableBulkTag;
|
||||
const filterable = Boolean(filters.length);
|
||||
@@ -373,11 +423,12 @@ export function ListView<T extends object = any>({
|
||||
)}
|
||||
<div data-test={className} className={`superset-list-view ${className} `}>
|
||||
<div className="header">
|
||||
{cardViewEnabled && (
|
||||
{cardViewEnabled && !forceViewMode && (
|
||||
<ViewModeToggle mode={viewMode} setMode={setViewMode} />
|
||||
)}
|
||||
<div className="controls" data-test="filters-select">
|
||||
{filterable && (
|
||||
{/* On mobile, filters are shown in drawer; on desktop, show inline */}
|
||||
{filterable && !setMobileFiltersOpen && (
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
@@ -385,12 +436,27 @@ export function ListView<T extends object = any>({
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
)}
|
||||
{filterable && setMobileFiltersOpen && (
|
||||
<>
|
||||
{/* Desktop: show inline filters */}
|
||||
<div className="desktop-filters">
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
internalFilters={internalFilters}
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{viewMode === 'card' && cardSortSelectOptions && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
<div className="desktop-sort">
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,6 +615,33 @@ export function ListView<T extends object = any>({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile filter drawer */}
|
||||
{filterable && setMobileFiltersOpen && (
|
||||
<Drawer
|
||||
title={mobileFiltersDrawerTitle || t('Search')}
|
||||
placement="left"
|
||||
onClose={() => setMobileFiltersOpen(false)}
|
||||
open={mobileFiltersOpen}
|
||||
width={300}
|
||||
>
|
||||
<MobileFilterDrawerContent>
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
internalFilters={internalFilters}
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
{viewMode === 'card' && cardSortSelectOptions && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
</MobileFilterDrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</ListViewStyles>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ interface UseListViewConfig {
|
||||
};
|
||||
renderCard?: boolean;
|
||||
defaultViewMode?: ViewModeType;
|
||||
forceViewMode?: ViewModeType;
|
||||
}
|
||||
|
||||
export function useListViewState({
|
||||
@@ -215,6 +216,7 @@ export function useListViewState({
|
||||
bulkSelectColumnConfig,
|
||||
renderCard = false,
|
||||
defaultViewMode = 'card',
|
||||
forceViewMode,
|
||||
}: UseListViewConfig) {
|
||||
const [query, setQuery] = useQueryParams({
|
||||
filters: RisonParam,
|
||||
@@ -242,10 +244,19 @@ export function useListViewState({
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewModeType>(
|
||||
(query.viewMode as ViewModeType) ||
|
||||
// forceViewMode overrides everything (used for mobile)
|
||||
forceViewMode ||
|
||||
(query.viewMode as ViewModeType) ||
|
||||
(renderCard ? defaultViewMode : 'table'),
|
||||
);
|
||||
|
||||
// Update viewMode when forceViewMode changes (e.g., screen resize)
|
||||
useEffect(() => {
|
||||
if (forceViewMode) {
|
||||
setViewMode(forceViewMode);
|
||||
}
|
||||
}, [forceViewMode]);
|
||||
|
||||
const columnsWithSelect = useMemo(() => {
|
||||
// add exact filter type so filters with falsy values are not filtered out
|
||||
const columnsWithFilter = columns.map(f => ({ ...f, filter: 'exact' }));
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import MobileRouteGuard, {
|
||||
isMobileSupportedRoute,
|
||||
MOBILE_SUPPORTED_ROUTES,
|
||||
} from './index';
|
||||
|
||||
// Store the original sessionStorage
|
||||
const originalSessionStorage = window.sessionStorage;
|
||||
|
||||
// Clean up sessionStorage before each test
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore sessionStorage if it was mocked
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: originalSessionStorage,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Unit tests for isMobileSupportedRoute helper function
|
||||
test('isMobileSupportedRoute returns true for dashboard list', () => {
|
||||
expect(isMobileSupportedRoute('/dashboard/list/')).toBe(true);
|
||||
expect(isMobileSupportedRoute('/dashboard/list')).toBe(true);
|
||||
});
|
||||
|
||||
test('isMobileSupportedRoute returns true for individual dashboards', () => {
|
||||
expect(isMobileSupportedRoute('/superset/dashboard/123/')).toBe(true);
|
||||
expect(isMobileSupportedRoute('/superset/dashboard/my-dashboard/')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isMobileSupportedRoute('/superset/dashboard/abc-123/')).toBe(true);
|
||||
});
|
||||
|
||||
test('isMobileSupportedRoute returns true for welcome page', () => {
|
||||
expect(isMobileSupportedRoute('/superset/welcome/')).toBe(true);
|
||||
expect(isMobileSupportedRoute('/superset/welcome')).toBe(true);
|
||||
});
|
||||
|
||||
test('isMobileSupportedRoute returns true for auth routes', () => {
|
||||
expect(isMobileSupportedRoute('/login/')).toBe(true);
|
||||
expect(isMobileSupportedRoute('/logout/')).toBe(true);
|
||||
expect(isMobileSupportedRoute('/register/')).toBe(true);
|
||||
});
|
||||
|
||||
test('isMobileSupportedRoute returns false for chart routes', () => {
|
||||
expect(isMobileSupportedRoute('/chart/list/')).toBe(false);
|
||||
expect(isMobileSupportedRoute('/explore/')).toBe(false);
|
||||
expect(isMobileSupportedRoute('/superset/explore/')).toBe(false);
|
||||
});
|
||||
|
||||
test('isMobileSupportedRoute returns false for SQL Lab', () => {
|
||||
expect(isMobileSupportedRoute('/sqllab/')).toBe(false);
|
||||
expect(isMobileSupportedRoute('/superset/sqllab/')).toBe(false);
|
||||
});
|
||||
|
||||
test('isMobileSupportedRoute returns false for database/dataset routes', () => {
|
||||
expect(isMobileSupportedRoute('/database/list/')).toBe(false);
|
||||
expect(isMobileSupportedRoute('/dataset/list/')).toBe(false);
|
||||
});
|
||||
|
||||
test('isMobileSupportedRoute strips query params and hash', () => {
|
||||
expect(isMobileSupportedRoute('/dashboard/list/?page=1')).toBe(true);
|
||||
expect(isMobileSupportedRoute('/dashboard/list/#section')).toBe(true);
|
||||
expect(isMobileSupportedRoute('/chart/list/?page=1')).toBe(false);
|
||||
});
|
||||
|
||||
test('MOBILE_SUPPORTED_ROUTES includes expected patterns', () => {
|
||||
// Verify the constant is exported and has expected patterns
|
||||
expect(MOBILE_SUPPORTED_ROUTES).toBeInstanceOf(Array);
|
||||
expect(MOBILE_SUPPORTED_ROUTES.length).toBeGreaterThan(0);
|
||||
|
||||
// Check some patterns exist
|
||||
const hasLoginPattern = MOBILE_SUPPORTED_ROUTES.some(p => p.test('/login/'));
|
||||
const hasDashboardListPattern = MOBILE_SUPPORTED_ROUTES.some(p =>
|
||||
p.test('/dashboard/list/'),
|
||||
);
|
||||
const hasWelcomePattern = MOBILE_SUPPORTED_ROUTES.some(p =>
|
||||
p.test('/superset/welcome/'),
|
||||
);
|
||||
|
||||
expect(hasLoginPattern).toBe(true);
|
||||
expect(hasDashboardListPattern).toBe(true);
|
||||
expect(hasWelcomePattern).toBe(true);
|
||||
});
|
||||
|
||||
// Integration tests for MobileRouteGuard component
|
||||
// Note: These tests require mocking at the module level which is complex
|
||||
// The tests below verify the component structure and exports
|
||||
|
||||
test('MobileRouteGuard exports the component as default', () => {
|
||||
expect(MobileRouteGuard).toBeDefined();
|
||||
expect(typeof MobileRouteGuard).toBe('function');
|
||||
});
|
||||
|
||||
test('isMobileSupportedRoute is exported', () => {
|
||||
expect(isMobileSupportedRoute).toBeDefined();
|
||||
expect(typeof isMobileSupportedRoute).toBe('function');
|
||||
});
|
||||
|
||||
test('MOBILE_SUPPORTED_ROUTES is exported', () => {
|
||||
expect(MOBILE_SUPPORTED_ROUTES).toBeDefined();
|
||||
expect(Array.isArray(MOBILE_SUPPORTED_ROUTES)).toBe(true);
|
||||
});
|
||||
114
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal file
114
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Grid } from '@superset-ui/core/components';
|
||||
import MobileUnsupported from 'src/pages/MobileUnsupported';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
/**
|
||||
* Routes that are supported on mobile devices.
|
||||
* All other routes will show the MobileUnsupported page on mobile.
|
||||
*/
|
||||
export const MOBILE_SUPPORTED_ROUTES: RegExp[] = [
|
||||
// Authentication
|
||||
/^\/login\/?$/,
|
||||
/^\/logout\/?$/,
|
||||
/^\/register\/?/,
|
||||
|
||||
// Welcome / Home page
|
||||
/^\/superset\/welcome\/?$/,
|
||||
|
||||
// Dashboard list and individual dashboards
|
||||
/^\/dashboard\/list\/?$/,
|
||||
/^\/superset\/dashboard\/[^/]+\/?$/,
|
||||
|
||||
// The mobile unsupported page itself
|
||||
/^\/mobile-unsupported\/?$/,
|
||||
|
||||
// User info
|
||||
/^\/user_info\/?$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a given path is supported on mobile
|
||||
*/
|
||||
export function isMobileSupportedRoute(path: string): boolean {
|
||||
// Remove query string and hash for matching
|
||||
const cleanPath = path.split(/[?#]/)[0];
|
||||
return MOBILE_SUPPORTED_ROUTES.some(pattern => pattern.test(cleanPath));
|
||||
}
|
||||
|
||||
interface MobileRouteGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that wraps route content and redirects to the
|
||||
* MobileUnsupported page when accessing non-mobile-friendly
|
||||
* routes on mobile devices.
|
||||
*
|
||||
* Users can bypass this by clicking "Continue anyway" which
|
||||
* sets a sessionStorage flag.
|
||||
*/
|
||||
function MobileRouteGuard({ children }: MobileRouteGuardProps) {
|
||||
const screens = useBreakpoint();
|
||||
const location = useLocation();
|
||||
const [bypassEnabled, setBypassEnabled] = useState(() => {
|
||||
try {
|
||||
return sessionStorage.getItem('mobile-bypass') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Check for bypass flag when location changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
const bypass = sessionStorage.getItem('mobile-bypass') === 'true';
|
||||
setBypassEnabled(bypass);
|
||||
} catch {
|
||||
// Storage access denied, keep current state
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// Determine if we're on mobile (< md breakpoint = < 768px)
|
||||
const isMobile = !screens.md;
|
||||
|
||||
// If not mobile, or bypass is enabled, render children normally
|
||||
if (!isMobile || bypassEnabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Check if the current route is mobile-supported
|
||||
const isSupported = isMobileSupportedRoute(location.pathname);
|
||||
|
||||
// If route is supported on mobile, render children
|
||||
if (isSupported) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Otherwise, show the mobile unsupported page
|
||||
return (
|
||||
<MobileUnsupported originalPath={location.pathname + location.search} />
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileRouteGuard;
|
||||
@@ -49,6 +49,15 @@ fetchMock.put('glob:*/api/v1/dashboard/*', {});
|
||||
// Add mock for logging endpoint
|
||||
fetchMock.post('glob:*/superset/log/?*', {});
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/actions/dashboardState', () => ({
|
||||
...jest.requireActual('src/dashboard/actions/dashboardState'),
|
||||
fetchFaveStar: jest.fn(),
|
||||
@@ -312,6 +321,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
@@ -338,6 +348,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: false,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
@@ -364,6 +375,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
hasFilters: false,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
@@ -423,6 +435,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
hasFilters: false,
|
||||
});
|
||||
const { queryByTestId } = setup();
|
||||
|
||||
@@ -436,6 +449,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
const { queryByTestId } = setup();
|
||||
|
||||
@@ -449,6 +463,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
const { queryByTestId } = setup({
|
||||
dashboardState: { ...mockState.dashboardState, editMode: true },
|
||||
@@ -521,3 +536,82 @@ test('should maintain layout when switching between tabs', async () => {
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
expect(tabPanels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Mobile support tests
|
||||
// Note: The main mobile tests require mocking useBreakpoint to return mobile breakpoints
|
||||
// which is done at the module level. These tests verify mobile-related component behavior.
|
||||
|
||||
test('should not render filter bar panel on desktop when nativeFiltersEnabled is false', () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
jest.fn(),
|
||||
]);
|
||||
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
|
||||
jest.spyOn(useNativeFiltersModule, 'useNativeFilters').mockReturnValue({
|
||||
showDashboard: true,
|
||||
missingInitialFilters: [],
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
hasFilters: false,
|
||||
});
|
||||
|
||||
const { queryByTestId } = render(<DashboardBuilder />, {
|
||||
useRedux: true,
|
||||
store: storeWithState({
|
||||
...mockState,
|
||||
dashboardLayout: undoableDashboardLayout,
|
||||
}),
|
||||
useDnd: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
// Filter panel should not be present when native filters are disabled
|
||||
expect(queryByTestId('dashboard-filters-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render dashboard content wrapper', () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
jest.fn(),
|
||||
]);
|
||||
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
|
||||
const { getByTestId } = render(<DashboardBuilder />, {
|
||||
useRedux: true,
|
||||
store: storeWithState({
|
||||
...mockState,
|
||||
dashboardLayout: undoableDashboardLayout,
|
||||
}),
|
||||
useDnd: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
// Dashboard content wrapper should always be present
|
||||
expect(getByTestId('dashboard-content-wrapper')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render header container', () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
jest.fn(),
|
||||
]);
|
||||
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
|
||||
const { queryByTestId } = render(<DashboardBuilder />, {
|
||||
useRedux: true,
|
||||
store: storeWithState({
|
||||
...mockState,
|
||||
dashboardLayout: undoableDashboardLayout,
|
||||
}),
|
||||
useDnd: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
// Header container should be present
|
||||
expect(queryByTestId('dashboard-header-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -23,7 +23,12 @@ import { t } from '@apache-superset/core';
|
||||
import { addAlpha, JsonObject, useElementOnScreen } from '@superset-ui/core';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/ui';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import {
|
||||
Drawer,
|
||||
EmptyState,
|
||||
Grid,
|
||||
Loading,
|
||||
} from '@superset-ui/core/components';
|
||||
import { ErrorBoundary, BasicErrorAlert } from 'src/components';
|
||||
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
|
||||
import DashboardHeader from 'src/dashboard/components/Header';
|
||||
@@ -359,6 +364,8 @@ const DashboardBuilder = () => {
|
||||
const dispatch = useDispatch();
|
||||
const uiConfig = useUiConfig();
|
||||
const theme = useTheme();
|
||||
const { md: isNotMobile } = Grid.useBreakpoint();
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
|
||||
const dashboardId = useSelector<RootState, string>(
|
||||
({ dashboardInfo }) => `${dashboardInfo.id}`,
|
||||
@@ -449,13 +456,14 @@ const DashboardBuilder = () => {
|
||||
dashboardFiltersOpen,
|
||||
toggleDashboardFiltersOpen,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters,
|
||||
} = useNativeFilters();
|
||||
|
||||
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>(
|
||||
ELEMENT_ON_SCREEN_OPTIONS,
|
||||
);
|
||||
|
||||
const showFilterBar = !editMode && nativeFiltersEnabled;
|
||||
const showFilterBar = isNotMobile && !editMode && nativeFiltersEnabled;
|
||||
|
||||
const offset =
|
||||
FILTER_BAR_HEADER_HEIGHT +
|
||||
@@ -467,6 +475,7 @@ const DashboardBuilder = () => {
|
||||
const draggableStyle = useMemo(
|
||||
() => ({
|
||||
marginLeft:
|
||||
!isNotMobile ||
|
||||
dashboardFiltersOpen ||
|
||||
editMode ||
|
||||
!nativeFiltersEnabled ||
|
||||
@@ -475,6 +484,7 @@ const DashboardBuilder = () => {
|
||||
: -32,
|
||||
}),
|
||||
[
|
||||
isNotMobile,
|
||||
dashboardFiltersOpen,
|
||||
editMode,
|
||||
filterBarOrientation,
|
||||
@@ -506,7 +516,15 @@ const DashboardBuilder = () => {
|
||||
const renderDraggableContent = useCallback(
|
||||
({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) => (
|
||||
<div>
|
||||
{!hideDashboardHeader && <DashboardHeader />}
|
||||
{!hideDashboardHeader && (
|
||||
<DashboardHeader
|
||||
onOpenMobileFilters={
|
||||
!isNotMobile && nativeFiltersEnabled && hasFilters
|
||||
? () => setMobileFiltersOpen(true)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showFilterBar &&
|
||||
filterBarOrientation === FilterBarOrientation.Horizontal && (
|
||||
<FilterBar
|
||||
@@ -543,6 +561,7 @@ const DashboardBuilder = () => {
|
||||
),
|
||||
[
|
||||
nativeFiltersEnabled,
|
||||
hasFilters,
|
||||
filterBarOrientation,
|
||||
editMode,
|
||||
handleChangeTab,
|
||||
@@ -551,6 +570,8 @@ const DashboardBuilder = () => {
|
||||
isReport,
|
||||
topLevelTabs,
|
||||
uiConfig.hideNav,
|
||||
isNotMobile,
|
||||
theme,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -712,6 +733,89 @@ const DashboardBuilder = () => {
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{/* Mobile filters drawer */}
|
||||
{!isNotMobile && nativeFiltersEnabled && (
|
||||
<Drawer
|
||||
title={t('Filters')}
|
||||
placement="left"
|
||||
onClose={() => setMobileFiltersOpen(false)}
|
||||
open={mobileFiltersOpen}
|
||||
width="85vw"
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}}
|
||||
css={css`
|
||||
/* Mobile filter drawer overrides */
|
||||
|
||||
/* Hide the Header component (contains Actions title, settings, collapse button) */
|
||||
/* Target the parent div that contains the collapse button using :has() */
|
||||
div:has([data-test='filter-bar-collapse-button']) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Hide the collapsed bar */
|
||||
[data-test='filter-bar-collapsable'] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Action buttons: side by side, not fixed position */
|
||||
[data-test='filterbar-action-buttons'] {
|
||||
position: relative !important;
|
||||
flex-direction: row !important;
|
||||
width: 100% !important;
|
||||
padding: ${theme.sizeUnit * 4}px !important;
|
||||
background: ${theme.colorBgContainer} !important;
|
||||
border-top: 1px solid ${theme.colorBorderSecondary} !important;
|
||||
gap: ${theme.sizeUnit * 2}px !important;
|
||||
bottom: auto !important;
|
||||
left: auto !important;
|
||||
|
||||
.filter-apply-button {
|
||||
margin-bottom: 0 !important;
|
||||
flex: 1;
|
||||
}
|
||||
.filter-clear-all-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove border-right and make full width */
|
||||
[data-test='filter-bar'] {
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
border-right: none;
|
||||
|
||||
& > .open {
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
border-right: none !important;
|
||||
border-bottom: none !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`}
|
||||
>
|
||||
<FilterBar
|
||||
orientation={FilterBarOrientation.Vertical}
|
||||
verticalConfig={{
|
||||
filtersOpen: true,
|
||||
toggleFiltersBar: () => {},
|
||||
width: 300,
|
||||
height: '100%',
|
||||
offset: 0,
|
||||
}}
|
||||
hidden={false}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</DashboardWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -110,6 +110,29 @@ const StyledDiv = styled.div`
|
||||
i.warning {
|
||||
color: ${theme.colorWarning};
|
||||
}
|
||||
|
||||
/* Mobile: consumption-only mode */
|
||||
@media (max-width: 767px) {
|
||||
/* Hide chart kebab menu (SliceHeaderControls) */
|
||||
[data-test='slice-header'] .header-controls [id$='-controls'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Disable chart title links - make them plain text */
|
||||
[data-test='slice-header'] .header-title a {
|
||||
pointer-events: none;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* Show full chart title without truncation - no tooltip needed */
|
||||
[data-test='slice-header'] .header-title {
|
||||
-webkit-line-clamp: unset;
|
||||
display: block;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -121,5 +121,6 @@ export const useNativeFilters = () => {
|
||||
dashboardFiltersOpen,
|
||||
toggleDashboardFiltersOpen,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters: filterValues.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -172,6 +172,15 @@ const recordError = jest.fn();
|
||||
const setPaused = jest.fn();
|
||||
const setPausedByTab = jest.fn();
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
|
||||
useUnsavedChangesPrompt: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -23,7 +23,13 @@ import {
|
||||
FeatureFlag,
|
||||
getExtensionsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { styled, css, SupersetTheme, t } from '@apache-superset/core/ui';
|
||||
import {
|
||||
styled,
|
||||
css,
|
||||
SupersetTheme,
|
||||
t,
|
||||
useTheme,
|
||||
} from '@apache-superset/core/ui';
|
||||
import { Global } from '@emotion/react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
@@ -34,6 +40,7 @@ import {
|
||||
Tooltip,
|
||||
DeleteModal,
|
||||
UnsavedChangesModal,
|
||||
Grid,
|
||||
} from '@superset-ui/core/components';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
@@ -219,8 +226,17 @@ const discardChanges = () => {
|
||||
window.location.assign(url);
|
||||
};
|
||||
|
||||
const Header = (): JSX.Element => {
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
interface HeaderComponentProps {
|
||||
onOpenMobileFilters?: () => void;
|
||||
}
|
||||
|
||||
const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] =
|
||||
useState(false);
|
||||
const [emphasizeUndo, setEmphasizeUndo] = useState(false);
|
||||
@@ -635,7 +651,7 @@ const Header = (): JSX.Element => {
|
||||
onTogglePause={handlePauseToggle}
|
||||
/>
|
||||
),
|
||||
!editMode && (
|
||||
!editMode && !isMobile && (
|
||||
<PublishedStatus
|
||||
key="published-status"
|
||||
dashboardId={dashboardInfo.id}
|
||||
@@ -645,12 +661,13 @@ const Header = (): JSX.Element => {
|
||||
userCanSave={userCanSaveAs}
|
||||
/>
|
||||
),
|
||||
!editMode && !isEmbedded && metadataBar,
|
||||
!editMode && !isEmbedded && !isMobile && metadataBar,
|
||||
],
|
||||
[
|
||||
boundActionCreators.savePublished,
|
||||
dashboardInfo.id,
|
||||
editMode,
|
||||
isMobile,
|
||||
metadataBar,
|
||||
isEmbedded,
|
||||
isPublished,
|
||||
@@ -745,7 +762,7 @@ const Header = (): JSX.Element => {
|
||||
) : (
|
||||
<div css={actionButtonsStyle}>
|
||||
{NavExtension && <NavExtension />}
|
||||
{userCanEdit && (
|
||||
{userCanEdit && !isMobile && (
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={handleEnterEditMode}
|
||||
@@ -772,6 +789,7 @@ const Header = (): JSX.Element => {
|
||||
handleCtrlZ,
|
||||
handleEnterEditMode,
|
||||
hasUnsavedChanges,
|
||||
isMobile,
|
||||
overwriteDashboard,
|
||||
redoLength,
|
||||
undoLength,
|
||||
@@ -808,6 +826,10 @@ const Header = (): JSX.Element => {
|
||||
userCanCurate,
|
||||
userCanExport,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isStarred,
|
||||
isPublished,
|
||||
saveFaveStar: boundActionCreators.saveFaveStar,
|
||||
showReportModal,
|
||||
showPropertiesModal,
|
||||
showRefreshModal,
|
||||
@@ -827,6 +849,21 @@ const Header = (): JSX.Element => {
|
||||
editableTitleProps={editableTitleProps}
|
||||
certificatiedBadgeProps={certifiedBadgeProps}
|
||||
faveStarProps={faveStarProps}
|
||||
leftPanelItems={
|
||||
onOpenMobileFilters && (
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
aria-label={t('Open filters')}
|
||||
onClick={onOpenMobileFilters}
|
||||
data-test="mobile-filters-trigger"
|
||||
>
|
||||
<Icons.FilterOutlined
|
||||
iconColor={theme.colorPrimary}
|
||||
iconSize="l"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
titlePanelAdditionalItems={titlePanelAdditionalItems}
|
||||
rightPanelAdditionalItems={rightPanelAdditionalItems}
|
||||
menuDropdownProps={{
|
||||
@@ -834,7 +871,7 @@ const Header = (): JSX.Element => {
|
||||
onOpenChange: setIsDropdownVisible,
|
||||
}}
|
||||
additionalActionsMenu={menu}
|
||||
showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
|
||||
showFaveStar={!!(user?.userId && dashboardInfo?.id && !isMobile)}
|
||||
showTitlePanelItems
|
||||
/>
|
||||
{showingPropertiesModal && (
|
||||
|
||||
@@ -43,6 +43,10 @@ export interface HeaderDropdownProps {
|
||||
forceRefreshAllCharts: () => unknown;
|
||||
hasUnsavedChanges: boolean;
|
||||
isLoading: boolean;
|
||||
isMobile?: boolean;
|
||||
isStarred?: boolean;
|
||||
isPublished?: boolean;
|
||||
saveFaveStar?: (id: number, isStarred: boolean) => void;
|
||||
layout: Layout;
|
||||
onSave: (...args: unknown[]) => unknown;
|
||||
refreshFrequency: number;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
|
||||
import getOwnerName from 'src/utils/getOwnerName';
|
||||
|
||||
export const useHeaderActionsMenu = ({
|
||||
customCss,
|
||||
@@ -55,6 +56,10 @@ export const useHeaderActionsMenu = ({
|
||||
userCanCurate,
|
||||
userCanExport,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isStarred,
|
||||
isPublished,
|
||||
saveFaveStar,
|
||||
lastModifiedTime,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
@@ -111,6 +116,11 @@ export const useHeaderActionsMenu = ({
|
||||
case MenuKeys.ManageEmbedded:
|
||||
manageEmbedded();
|
||||
break;
|
||||
case 'toggle-favorite':
|
||||
if (saveFaveStar && isStarred !== undefined) {
|
||||
saveFaveStar(dashboardId, isStarred);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -122,6 +132,9 @@ export const useHeaderActionsMenu = ({
|
||||
showPropertiesModal,
|
||||
showRefreshModal,
|
||||
manageEmbedded,
|
||||
saveFaveStar,
|
||||
dashboardId,
|
||||
isStarred,
|
||||
history,
|
||||
],
|
||||
);
|
||||
@@ -193,6 +206,50 @@ export const useHeaderActionsMenu = ({
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
// Mobile-only: show dashboard info items in menu
|
||||
if (isMobile && !editMode) {
|
||||
// Favorite toggle
|
||||
if (saveFaveStar) {
|
||||
menuItems.push({
|
||||
key: 'toggle-favorite',
|
||||
label: isStarred ? t('Remove from favorites') : t('Add to favorites'),
|
||||
});
|
||||
}
|
||||
|
||||
// Published status
|
||||
menuItems.push({
|
||||
key: 'status-info',
|
||||
label: isPublished ? t('Status: Published') : t('Status: Draft'),
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
// Owner info
|
||||
const ownerNames =
|
||||
dashboardInfo?.owners?.length > 0
|
||||
? dashboardInfo.owners.map(getOwnerName).join(', ')
|
||||
: t('None');
|
||||
menuItems.push({
|
||||
key: 'owner-info',
|
||||
label: t('Owner: %(names)s', { names: ownerNames }),
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
// Last modified
|
||||
const modifiedBy =
|
||||
getOwnerName(dashboardInfo?.changed_by) || t('Not available');
|
||||
const modifiedDate = dashboardInfo?.changed_on_delta_humanized || '';
|
||||
menuItems.push({
|
||||
key: 'modified-info',
|
||||
label: t('Modified %(date)s by %(user)s', {
|
||||
date: modifiedDate,
|
||||
user: modifiedBy,
|
||||
}),
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
menuItems.push({ type: 'divider' });
|
||||
}
|
||||
|
||||
// Refresh dashboard
|
||||
if (!editMode) {
|
||||
menuItems.push({
|
||||
@@ -212,8 +269,8 @@ export const useHeaderActionsMenu = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle fullscreen
|
||||
if (!editMode && !isEmbedded) {
|
||||
// Toggle fullscreen (hide on mobile)
|
||||
if (!editMode && !isEmbedded && !isMobile) {
|
||||
menuItems.push({
|
||||
key: MenuKeys.ToggleFullscreen,
|
||||
label: getUrlParam(URL_PARAMS.standalone)
|
||||
@@ -281,15 +338,15 @@ export const useHeaderActionsMenu = ({
|
||||
|
||||
// Only add divider if there are items after it
|
||||
const hasItemsAfterDivider =
|
||||
(!editMode && reportMenuItem) ||
|
||||
(!editMode && reportMenuItem && !isMobile) ||
|
||||
(editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes));
|
||||
|
||||
if (hasItemsAfterDivider) {
|
||||
menuItems.push({ type: 'divider' });
|
||||
}
|
||||
|
||||
// Report dropdown
|
||||
if (!editMode && reportMenuItem) {
|
||||
// Report dropdown (hide on mobile)
|
||||
if (!editMode && reportMenuItem && !isMobile) {
|
||||
menuItems.push(reportMenuItem);
|
||||
}
|
||||
|
||||
@@ -327,11 +384,15 @@ export const useHeaderActionsMenu = ({
|
||||
expandedSlices,
|
||||
handleMenuClick,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isPublished,
|
||||
isStarred,
|
||||
lastModifiedTime,
|
||||
layout,
|
||||
onSave,
|
||||
refreshFrequency,
|
||||
reportMenuItem,
|
||||
saveFaveStar,
|
||||
shareMenuItems,
|
||||
shouldPersistRefreshFrequency,
|
||||
userCanCurate,
|
||||
|
||||
@@ -120,6 +120,15 @@ const GridRow = styled.div<{ editMode: boolean }>`
|
||||
&.grid-row--empty {
|
||||
min-height: ${theme.sizeUnit * 25}px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
flex-direction: column;
|
||||
|
||||
& > :not(.hover-menu) {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -81,6 +81,16 @@ const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
|
||||
display: none !important;
|
||||
}
|
||||
`}
|
||||
|
||||
/* Sticky tabs on mobile */
|
||||
@media (max-width: 767px) {
|
||||
.ant-tabs-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background-color: ${({ theme }) => theme.colorBgContainer};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface TabItem {
|
||||
|
||||
@@ -147,6 +147,13 @@ const StyledResizable = styled(Resizable)`
|
||||
& .resizable-container-handle--bottom {
|
||||
bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* Full width on mobile */
|
||||
@media (max-width: 767px) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ResizableContainer({
|
||||
|
||||
@@ -24,6 +24,18 @@ import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
import { Menu } from './Menu';
|
||||
import * as getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile menu rendering)
|
||||
// Note: We mock 'antd' directly rather than '@superset-ui/core/components' because
|
||||
// mocking the latter causes circular dependency issues with ActionButton during
|
||||
// jest.requireActual evaluation. Since Grid is re-exported from antd, this works.
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
label: 'Data',
|
||||
|
||||
@@ -333,7 +333,18 @@ export function Menu({
|
||||
return (
|
||||
<StyledHeader className="top" id="main-menu" role="navigation">
|
||||
<StyledRow>
|
||||
<StyledCol md={16} xs={24}>
|
||||
{/* Mobile: left placeholder for future icon */}
|
||||
{!screens.md && <Col xs={4} />}
|
||||
<StyledCol
|
||||
md={16}
|
||||
xs={screens.md ? 24 : 16}
|
||||
css={
|
||||
!screens.md &&
|
||||
css`
|
||||
justify-content: center;
|
||||
`
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
id="brand-tooltip"
|
||||
placement="bottomLeft"
|
||||
@@ -347,39 +358,43 @@ export function Menu({
|
||||
<span>{brand.text}</span>
|
||||
</StyledBrandText>
|
||||
)}
|
||||
<StyledMainNav
|
||||
mode="horizontal"
|
||||
data-test="navbar-top"
|
||||
className="main-nav"
|
||||
selectedKeys={activeTabs}
|
||||
disabledOverflow
|
||||
items={menu.map(item => {
|
||||
const props = {
|
||||
...item,
|
||||
isFrontendRoute: isFrontendRoute(item.url),
|
||||
childs: item.childs?.map(c => {
|
||||
if (typeof c === 'string') {
|
||||
return c;
|
||||
}
|
||||
{/* Only show nav items on desktop */}
|
||||
{screens.md && (
|
||||
<StyledMainNav
|
||||
mode="horizontal"
|
||||
data-test="navbar-top"
|
||||
className="main-nav"
|
||||
selectedKeys={activeTabs}
|
||||
disabledOverflow
|
||||
items={menu.map(item => {
|
||||
const props = {
|
||||
...item,
|
||||
isFrontendRoute: isFrontendRoute(item.url),
|
||||
childs: item.childs?.map(c => {
|
||||
if (typeof c === 'string') {
|
||||
return c;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
isFrontendRoute: isFrontendRoute(c.url),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...c,
|
||||
isFrontendRoute: isFrontendRoute(c.url),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return buildMenuItem(props);
|
||||
})}
|
||||
/>
|
||||
return buildMenuItem(props);
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</StyledCol>
|
||||
<Col md={8} xs={24}>
|
||||
<Col md={8} xs={screens.md ? 24 : 4}>
|
||||
<RightMenu
|
||||
align={screens.md ? 'flex-end' : 'flex-start'}
|
||||
align="flex-end"
|
||||
settings={settings}
|
||||
navbarRight={navbarRight}
|
||||
isFrontendRoute={isFrontendRoute}
|
||||
environmentTag={environmentTag}
|
||||
menu={menu}
|
||||
/>
|
||||
</Col>
|
||||
</StyledRow>
|
||||
|
||||
@@ -49,6 +49,15 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile menu rendering)
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('src/features/databases/DatabaseModal', () => {
|
||||
const DatabaseModal = () => <span />;
|
||||
DatabaseModal.displayName = 'DatabaseModal';
|
||||
|
||||
@@ -37,6 +37,9 @@ import {
|
||||
Icons,
|
||||
Typography,
|
||||
TelemetryPixel,
|
||||
Drawer,
|
||||
Grid,
|
||||
Button,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { ensureAppRoot, makeUrl } from 'src/utils/pathUtils';
|
||||
@@ -97,12 +100,15 @@ const StyledMenuItem = styled.div<{ disabled?: boolean }>`
|
||||
`}
|
||||
`;
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const RightMenu = ({
|
||||
align,
|
||||
settings,
|
||||
navbarRight,
|
||||
isFrontendRoute,
|
||||
environmentTag,
|
||||
menu,
|
||||
setQuery,
|
||||
}: RightMenuProps & {
|
||||
setQuery: ({
|
||||
@@ -114,6 +120,8 @@ const RightMenu = ({
|
||||
}) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const screens = useBreakpoint();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const user = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
@@ -616,6 +624,64 @@ const RightMenu = ({
|
||||
handleLogout,
|
||||
]);
|
||||
|
||||
// Build mobile menu items - consumption only (no create/admin actions)
|
||||
const mobileMenuItems = useMemo(() => {
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
// Add Dashboards link at top (from main menu)
|
||||
const dashboardsMenu = menu?.find(
|
||||
item => item.label === 'Dashboards' || item.name === 'Dashboards',
|
||||
);
|
||||
if (dashboardsMenu) {
|
||||
const dashboardUrl = dashboardsMenu.url || '/dashboard/list/';
|
||||
items.push({
|
||||
key: 'dashboards',
|
||||
label: isFrontendRoute(dashboardUrl) ? (
|
||||
<Link to={dashboardUrl}>{t('Dashboards')}</Link>
|
||||
) : (
|
||||
<Typography.Link href={dashboardUrl}>
|
||||
{t('Dashboards')}
|
||||
</Typography.Link>
|
||||
),
|
||||
icon: <Icons.DashboardOutlined />,
|
||||
});
|
||||
}
|
||||
|
||||
// Add theme menu (flatten children directly)
|
||||
menuItems.forEach(item => {
|
||||
if (!item || !('key' in item)) return;
|
||||
|
||||
// Only include theme-sub-menu and language picker
|
||||
if (item.key === 'theme-sub-menu' || item.key === 'language-picker') {
|
||||
items.push({ type: 'divider', key: `divider-before-${item.key}` });
|
||||
|
||||
if ('children' in item && item.children) {
|
||||
// Theme menu already has a nested group, so just add its children directly
|
||||
item.children.forEach(child => {
|
||||
items.push(child);
|
||||
});
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract user-related items from settings
|
||||
if (item.key === 'settings' && 'children' in item && item.children) {
|
||||
item.children.forEach(child => {
|
||||
if (!child || !('key' in child)) return;
|
||||
|
||||
// Only include user-section and about-section
|
||||
if (child.key === 'user-section' || child.key === 'about-section') {
|
||||
items.push({ type: 'divider', key: `divider-before-${child.key}` });
|
||||
items.push(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [menu, menuItems, isFrontendRoute]);
|
||||
|
||||
return (
|
||||
<StyledDiv align={align}>
|
||||
{canDatabase && (
|
||||
@@ -676,48 +742,87 @@ const RightMenu = ({
|
||||
</Tag>
|
||||
);
|
||||
})()}
|
||||
<Menu
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
border-bottom: none !important;
|
||||
|
||||
/* Remove the underline from menu items */
|
||||
.ant-menu-item:after,
|
||||
.ant-menu-submenu:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.submenu-with-caret {
|
||||
{/* Mobile: hamburger menu with drawer */}
|
||||
{!screens.md && (
|
||||
<>
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label={t('Menu')}
|
||||
>
|
||||
<Icons.MenuOutlined iconSize="l" />
|
||||
</Button>
|
||||
<Drawer
|
||||
title={null}
|
||||
placement="right"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
width={280}
|
||||
styles={{
|
||||
header: { display: 'none' },
|
||||
body: { padding: 0 },
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectable={false}
|
||||
onClick={info => {
|
||||
handleMenuSelection(info);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
items={mobileMenuItems}
|
||||
css={css`
|
||||
border-inline-end: none !important;
|
||||
`}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
)}
|
||||
{/* Desktop: horizontal menu */}
|
||||
{screens.md && (
|
||||
<Menu
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
.ant-menu-submenu-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
flex-direction: row-reverse;
|
||||
border-bottom: none !important;
|
||||
|
||||
/* Remove the underline from menu items */
|
||||
.ant-menu-item:after,
|
||||
.ant-menu-submenu:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.submenu-with-caret {
|
||||
height: 100%;
|
||||
}
|
||||
&.ant-menu-submenu::after {
|
||||
inset-inline: ${theme.sizeUnit}px;
|
||||
}
|
||||
&.ant-menu-submenu:hover,
|
||||
&.ant-menu-submenu-active {
|
||||
.ant-menu-title-content {
|
||||
color: ${theme.colorPrimary};
|
||||
padding: 0;
|
||||
.ant-menu-submenu-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
flex-direction: row-reverse;
|
||||
height: 100%;
|
||||
}
|
||||
&.ant-menu-submenu::after {
|
||||
inset-inline: ${theme.sizeUnit}px;
|
||||
}
|
||||
&.ant-menu-submenu:hover,
|
||||
&.ant-menu-submenu-active {
|
||||
.ant-menu-title-content {
|
||||
color: ${theme.colorPrimary};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
selectable={false}
|
||||
mode="horizontal"
|
||||
onClick={handleMenuSelection}
|
||||
onOpenChange={onMenuOpen}
|
||||
disabledOverflow
|
||||
items={menuItems}
|
||||
/>
|
||||
`}
|
||||
selectable={false}
|
||||
mode="horizontal"
|
||||
onClick={handleMenuSelection}
|
||||
onOpenChange={onMenuOpen}
|
||||
disabledOverflow
|
||||
items={menuItems}
|
||||
/>
|
||||
)}
|
||||
{navbarRight.documentation_url && (
|
||||
<>
|
||||
<StyledAnchor
|
||||
|
||||
@@ -127,3 +127,35 @@ test('should render the buttons', async () => {
|
||||
userEvent.click(testButton);
|
||||
expect(mockFunc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Mobile support tests
|
||||
test('should render leftIcon when provided', async () => {
|
||||
setup({
|
||||
leftIcon: (
|
||||
<button type="button" data-test="left-icon-button">
|
||||
Search
|
||||
</button>
|
||||
),
|
||||
});
|
||||
expect(await screen.findByTestId('left-icon-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render rightIcon when provided', async () => {
|
||||
setup({
|
||||
rightIcon: (
|
||||
<button type="button" data-test="right-icon-button">
|
||||
Menu
|
||||
</button>
|
||||
),
|
||||
});
|
||||
expect(await screen.findByTestId('right-icon-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render both leftIcon and rightIcon together', async () => {
|
||||
setup({
|
||||
leftIcon: <span data-test="mobile-left">Left</span>,
|
||||
rightIcon: <span data-test="mobile-right">Right</span>,
|
||||
});
|
||||
expect(await screen.findByTestId('mobile-left')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('mobile-right')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -106,10 +106,29 @@ const StyledHeader = styled.div<{ backgroundColor?: string }>`
|
||||
padding: 10px 0;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.header,
|
||||
.nav-right {
|
||||
.header {
|
||||
position: relative;
|
||||
margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
margin-left: 0;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Hide all buttons on mobile */
|
||||
.nav-right,
|
||||
.nav-right-collapse {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Compact horizontal tabs on mobile (segmented-control style) */
|
||||
.menu > .ant-menu {
|
||||
padding-left: 0;
|
||||
|
||||
.ant-menu-item {
|
||||
padding: ${({ theme }) => theme.sizeUnit}px
|
||||
${({ theme }) => theme.sizeUnit * 2}px;
|
||||
margin-right: ${({ theme }) => theme.sizeUnit / 2}px;
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -159,6 +178,10 @@ export interface SubMenuProps {
|
||||
color?: string;
|
||||
dropDownLinks?: Array<MenuObjectProps>;
|
||||
backgroundColor?: string;
|
||||
/** Left icon for mobile - shown before the header */
|
||||
leftIcon?: ReactNode;
|
||||
/** Right icon for mobile - shown after the header */
|
||||
rightIcon?: ReactNode;
|
||||
}
|
||||
|
||||
const { SubMenu } = MainNav;
|
||||
@@ -179,8 +202,8 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
if (window.innerWidth <= 767) setMenu('inline');
|
||||
else setMenu('horizontal');
|
||||
// Keep horizontal mode on mobile - CSS handles compact display
|
||||
setMenu('horizontal');
|
||||
|
||||
if (
|
||||
props.buttons &&
|
||||
@@ -206,7 +229,9 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
|
||||
return (
|
||||
<StyledHeader backgroundColor={props.backgroundColor}>
|
||||
<Row className="menu" role="navigation">
|
||||
{props.leftIcon}
|
||||
{props.name && <div className="header">{props.name}</div>}
|
||||
{props.rightIcon}
|
||||
<Menu
|
||||
mode={showMenu}
|
||||
disabledOverflow
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface RightMenuProps {
|
||||
text: string;
|
||||
color: string;
|
||||
};
|
||||
menu?: MenuObjectProps[];
|
||||
}
|
||||
|
||||
export enum GlobalMenuDataOptions {
|
||||
|
||||
@@ -50,6 +50,15 @@ jest.mock('@superset-ui/core', () => ({
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockDashboards = Array.from({ length: 3 }, (_, i) => ({
|
||||
id: i,
|
||||
url: 'url',
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
FeatureFlag,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/ui';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/ui';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -36,10 +36,12 @@ import {
|
||||
import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel';
|
||||
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
|
||||
import {
|
||||
Button,
|
||||
CertifiedBadge,
|
||||
ConfirmStatusChange,
|
||||
DeleteModal,
|
||||
FaveStar,
|
||||
Grid,
|
||||
Loading,
|
||||
PublishedLabel,
|
||||
Tooltip,
|
||||
@@ -146,6 +148,9 @@ const DASHBOARD_COLUMNS_TO_FETCH = [
|
||||
|
||||
function DashboardList(props: DashboardListProps) {
|
||||
const { addDangerToast, addSuccessToast, user } = props;
|
||||
const { md: isNotMobile } = Grid.useBreakpoint();
|
||||
const theme = useTheme();
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
@@ -739,7 +744,25 @@ function DashboardList(props: DashboardListProps) {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SubMenu name={t('Dashboards')} buttons={subMenuButtons} />
|
||||
<SubMenu
|
||||
name={t('Dashboards')}
|
||||
buttons={subMenuButtons}
|
||||
leftIcon={
|
||||
!isNotMobile ? (
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
aria-label={t('Search')}
|
||||
css={css`
|
||||
padding: 0;
|
||||
margin-right: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
<Icons.SearchOutlined iconSize="l" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t(
|
||||
@@ -828,8 +851,12 @@ function DashboardList(props: DashboardListProps) {
|
||||
? 'card'
|
||||
: 'table'
|
||||
}
|
||||
forceViewMode={!isNotMobile ? 'card' : undefined}
|
||||
enableBulkTag={enableBulkTag}
|
||||
bulkTagResourceName="dashboard"
|
||||
mobileFiltersOpen={mobileFiltersOpen}
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle={t('Search Dashboards')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
186
superset-frontend/src/pages/Home/Home.mobile.test.tsx
Normal file
186
superset-frontend/src/pages/Home/Home.mobile.test.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile-specific tests for the Home/Welcome page.
|
||||
*
|
||||
* These tests verify that certain desktop-only sections are hidden
|
||||
* on mobile viewports.
|
||||
*/
|
||||
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import Welcome from 'src/pages/Home';
|
||||
|
||||
// Mock useBreakpoint to return MOBILE breakpoints
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: false, // Mobile: md is false
|
||||
lg: false,
|
||||
xl: false,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
// API mocks
|
||||
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
|
||||
const chartInfoEndpoint = 'glob:*/api/v1/chart/_info?*';
|
||||
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status?*';
|
||||
const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*';
|
||||
const dashboardInfoEndpoint = 'glob:*/api/v1/dashboard/_info?*';
|
||||
const dashboardFavoriteStatusEndpoint =
|
||||
'glob:*/api/v1/dashboard/favorite_status/?*';
|
||||
const savedQueryEndpoint = 'glob:*/api/v1/saved_query/?*';
|
||||
const savedQueryInfoEndpoint = 'glob:*/api/v1/saved_query/_info?*';
|
||||
const recentActivityEndpoint = 'glob:*/api/v1/log/recent_activity/*';
|
||||
|
||||
fetchMock.get(chartsEndpoint, {
|
||||
result: [
|
||||
{
|
||||
slice_name: 'ChartyChart',
|
||||
changed_on_utc: '24 Feb 2014 10:13:14',
|
||||
url: '/fakeUrl/explore',
|
||||
id: '4',
|
||||
table: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.get(dashboardsEndpoint, {
|
||||
result: [
|
||||
{
|
||||
dashboard_title: 'Dashboard_Test',
|
||||
changed_on_utc: '24 Feb 2014 10:13:14',
|
||||
url: '/fakeUrl/dashboard',
|
||||
id: '3',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.get(savedQueryEndpoint, {
|
||||
result: [],
|
||||
});
|
||||
|
||||
fetchMock.get(recentActivityEndpoint, {
|
||||
result: [
|
||||
{
|
||||
action: 'dashboard',
|
||||
item_title: "World Bank's Data",
|
||||
item_type: 'dashboard',
|
||||
item_url: '/superset/dashboard/world_health/',
|
||||
time: 1741644942130.566,
|
||||
time_delta_humanized: 'a day ago',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.get(chartInfoEndpoint, { permissions: [] });
|
||||
fetchMock.get(chartFavoriteStatusEndpoint, { result: [] });
|
||||
fetchMock.get(dashboardInfoEndpoint, { permissions: [] });
|
||||
fetchMock.get(dashboardFavoriteStatusEndpoint, { result: [] });
|
||||
fetchMock.get(savedQueryInfoEndpoint, { permissions: [] });
|
||||
|
||||
const mockedProps = {
|
||||
user: {
|
||||
username: 'alpha',
|
||||
firstName: 'alpha',
|
||||
lastName: 'alpha',
|
||||
createdOn: '2016-11-11T12:34:17',
|
||||
userId: 5,
|
||||
email: 'alpha@alpha.com',
|
||||
isActive: true,
|
||||
isAnonymous: false,
|
||||
permissions: {},
|
||||
roles: {
|
||||
sql_lab: [['can_read', 'SavedQuery']],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderWelcome = (props = mockedProps) =>
|
||||
waitFor(() => {
|
||||
render(<Welcome {...props} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory();
|
||||
});
|
||||
|
||||
test('Mobile view - renders Dashboards panel', async () => {
|
||||
await renderWelcome();
|
||||
expect(await screen.findByText('Dashboards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - renders Recents panel', async () => {
|
||||
await renderWelcome();
|
||||
expect(await screen.findByText('Recents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - does NOT render Charts panel', async () => {
|
||||
await renderWelcome();
|
||||
|
||||
// Wait for Dashboards to ensure the component has rendered
|
||||
await screen.findByText('Dashboards');
|
||||
|
||||
// Charts panel should NOT be present on mobile
|
||||
// Look specifically for the Charts collapse panel header
|
||||
const chartsPanel = screen.queryByRole('button', { name: /Charts/i });
|
||||
expect(chartsPanel).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - does NOT render Saved queries panel', async () => {
|
||||
await renderWelcome();
|
||||
|
||||
// Wait for Dashboards to ensure the component has rendered
|
||||
await screen.findByText('Dashboards');
|
||||
|
||||
// Saved queries panel should NOT be present on mobile
|
||||
const savedQueriesPanel = screen.queryByRole('button', {
|
||||
name: /Saved queries/i,
|
||||
});
|
||||
expect(savedQueriesPanel).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - only shows 2 panels (Recents and Dashboards)', async () => {
|
||||
await renderWelcome();
|
||||
|
||||
// Wait for content to load
|
||||
await screen.findByText('Dashboards');
|
||||
|
||||
// Should only have Recents and Dashboards panels visible
|
||||
// Charts and Saved queries are hidden on mobile
|
||||
const recentsPanel = screen.queryByText('Recents');
|
||||
const dashboardsPanel = screen.queryByText('Dashboards');
|
||||
|
||||
expect(recentsPanel).toBeInTheDocument();
|
||||
expect(dashboardsPanel).toBeInTheDocument();
|
||||
});
|
||||
@@ -146,6 +146,15 @@ jest.mock('@superset-ui/core', () => ({
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
const renderWelcome = (props = mockedProps) =>
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/ui';
|
||||
import rison from 'rison';
|
||||
import { Collapse, ListViewCard } from '@superset-ui/core/components';
|
||||
import { Collapse, ListViewCard, Grid } from '@superset-ui/core/components';
|
||||
import { User } from 'src/types/bootstrapTypes';
|
||||
import { reject } from 'lodash';
|
||||
import {
|
||||
@@ -147,6 +147,7 @@ export const LoadingCards = ({ cover }: LoadingProps) => (
|
||||
);
|
||||
|
||||
function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
const { md: isNotMobile = true } = Grid.useBreakpoint();
|
||||
const canReadSavedQueries = userHasPermission(user, 'SavedQuery', 'can_read');
|
||||
const userid = user.userId;
|
||||
const id = userid!.toString(); // confident that user is not a guest user
|
||||
@@ -397,24 +398,29 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'charts',
|
||||
label: t('Charts'),
|
||||
children:
|
||||
!chartData || isRecentActivityLoading ? (
|
||||
<LoadingCards cover={checked} />
|
||||
) : (
|
||||
<ChartTable
|
||||
showThumbnails={checked}
|
||||
user={user}
|
||||
mine={chartData}
|
||||
otherTabData={activityData?.[TableTab.Other]}
|
||||
otherTabFilters={otherTabFilters}
|
||||
otherTabTitle={otherTabTitle}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(canReadSavedQueries
|
||||
// Hide Charts and Saved queries on mobile - consumption-only mode
|
||||
...(isNotMobile
|
||||
? [
|
||||
{
|
||||
key: 'charts',
|
||||
label: t('Charts'),
|
||||
children:
|
||||
!chartData || isRecentActivityLoading ? (
|
||||
<LoadingCards cover={checked} />
|
||||
) : (
|
||||
<ChartTable
|
||||
showThumbnails={checked}
|
||||
user={user}
|
||||
mine={chartData}
|
||||
otherTabData={activityData?.[TableTab.Other]}
|
||||
otherTabFilters={otherTabFilters}
|
||||
otherTabTitle={otherTabTitle}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isNotMobile && canReadSavedQueries
|
||||
? [
|
||||
{
|
||||
key: 'saved-queries',
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import MobileUnsupported from './index';
|
||||
|
||||
// Mock useBreakpoint to return mobile by default
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: false,
|
||||
lg: false,
|
||||
xl: false,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useHistory
|
||||
const mockPush = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Store original sessionStorage
|
||||
const originalSessionStorage = window.sessionStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore sessionStorage
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: originalSessionStorage,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
const renderComponent = (props = {}) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/chart/list/']}>
|
||||
<MobileUnsupported {...props} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
test('renders the page title', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByText("This view isn't available on mobile"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the description text', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the View Dashboards button', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'View Dashboards' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Go to Welcome Page button', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Go to Welcome Page' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Continue anyway link', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText(/Continue anyway/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('View Dashboards button navigates to dashboard list', async () => {
|
||||
renderComponent();
|
||||
|
||||
const button = screen.getByRole('button', { name: 'View Dashboards' });
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/dashboard/list/');
|
||||
});
|
||||
|
||||
test('Go to Welcome Page button navigates to welcome page', async () => {
|
||||
renderComponent();
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Go to Welcome Page' });
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
});
|
||||
|
||||
test('Continue anyway sets sessionStorage and navigates to original path', async () => {
|
||||
renderComponent({ originalPath: '/chart/list/' });
|
||||
|
||||
const link = screen.getByText(/Continue anyway/);
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(sessionStorage.getItem('mobile-bypass')).toBe('true');
|
||||
expect(mockPush).toHaveBeenCalledWith('/chart/list/');
|
||||
});
|
||||
|
||||
test('uses originalPath prop when provided', async () => {
|
||||
renderComponent({ originalPath: '/explore/?form_data=123' });
|
||||
|
||||
const link = screen.getByText(/Continue anyway/);
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/explore/?form_data=123');
|
||||
});
|
||||
|
||||
test('handles sessionStorage errors gracefully', async () => {
|
||||
// Mock sessionStorage to throw
|
||||
const mockStorage = {
|
||||
getItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
setItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
length: 0,
|
||||
key: jest.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: mockStorage,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
renderComponent({ originalPath: '/chart/list/' });
|
||||
|
||||
const link = screen.getByText(/Continue anyway/);
|
||||
// Should not throw even though sessionStorage fails
|
||||
await userEvent.click(link);
|
||||
|
||||
// Should still navigate even if storage failed
|
||||
expect(mockPush).toHaveBeenCalledWith('/chart/list/');
|
||||
});
|
||||
|
||||
test('renders desktop icon', () => {
|
||||
renderComponent();
|
||||
// The icon should be present (DesktopOutlined)
|
||||
// Icon might not have aria-label, so we verify the page renders with the title
|
||||
expect(
|
||||
screen.getByText("This view isn't available on mobile"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
218
superset-frontend/src/pages/MobileUnsupported/index.tsx
Normal file
218
superset-frontend/src/pages/MobileUnsupported/index.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { t } from '@apache-superset/core';
|
||||
import { css, useTheme } from '@apache-superset/core/ui';
|
||||
import { Button, Grid } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
interface MobileUnsupportedProps {
|
||||
/** The original path the user was trying to access */
|
||||
originalPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mobile-friendly page shown when users try to access
|
||||
* features that aren't supported on mobile devices.
|
||||
*/
|
||||
function MobileUnsupported({ originalPath }: MobileUnsupportedProps) {
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const screens = useBreakpoint();
|
||||
|
||||
// Get the original path from props or query params
|
||||
const fromPath =
|
||||
originalPath ||
|
||||
new URLSearchParams(location.search).get('from') ||
|
||||
location.pathname;
|
||||
|
||||
const handleViewDashboards = useCallback(() => {
|
||||
history.push('/dashboard/list/');
|
||||
}, [history]);
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
history.push('/superset/welcome/');
|
||||
}, [history]);
|
||||
|
||||
const handleContinueAnyway = useCallback(() => {
|
||||
// Store preference in sessionStorage so we don't keep redirecting
|
||||
try {
|
||||
sessionStorage.setItem('mobile-bypass', 'true');
|
||||
} catch {
|
||||
// Storage access denied, continue anyway without persisting
|
||||
}
|
||||
history.push(fromPath);
|
||||
}, [history, fromPath]);
|
||||
|
||||
// Determine if we're at or above the 'md' breakpoint (i.e. not on mobile)
|
||||
const isNotMobile = screens.md;
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: ${theme.sizeUnit * 6}px;
|
||||
text-align: center;
|
||||
background: ${theme.colorBgContainer};
|
||||
`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
css={css`
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: ${theme.colorBgLayout};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: ${theme.sizeUnit * 6}px;
|
||||
`}
|
||||
>
|
||||
<Icons.DesktopOutlined
|
||||
iconSize="xxl"
|
||||
iconColor={theme.colorTextSecondary}
|
||||
css={css`
|
||||
font-size: 48px;
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1
|
||||
css={css`
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorText};
|
||||
margin: 0 0 ${theme.sizeUnit * 2}px 0;
|
||||
`}
|
||||
>
|
||||
{t("This view isn't available on mobile")}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
css={css`
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
margin: 0 0 ${theme.sizeUnit * 8}px 0;
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
`}
|
||||
>
|
||||
{t(
|
||||
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Primary action */}
|
||||
<div>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
onClick={handleViewDashboards}
|
||||
css={css`
|
||||
width: 280px;
|
||||
height: 48px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t('View Dashboards')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Secondary action */}
|
||||
<div
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 3}px;
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={handleGoHome}
|
||||
css={css`
|
||||
width: 280px;
|
||||
height: 48px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t('Go to Welcome Page')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Continue anyway link */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinueAnyway}
|
||||
css={css`
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${theme.colorPrimary};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
cursor: pointer;
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
|
||||
margin-top: ${theme.sizeUnit * 4}px;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colorPrimaryHover};
|
||||
}
|
||||
`}
|
||||
>
|
||||
{t('Continue anyway')} →
|
||||
</button>
|
||||
|
||||
{/* Show hint if screen is now larger */}
|
||||
{isNotMobile && (
|
||||
<p
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 6}px;
|
||||
font-size: ${theme.fontSizeXS}px;
|
||||
color: ${theme.colorTextDescription};
|
||||
`}
|
||||
>
|
||||
{t('Your screen is now large enough.')}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinueAnyway}
|
||||
css={css`
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${theme.colorPrimary};
|
||||
cursor: pointer;
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
text-decoration: underline;
|
||||
`}
|
||||
>
|
||||
{t('Continue to page')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileUnsupported;
|
||||
@@ -28,6 +28,7 @@ import { css } from '@apache-superset/core/ui';
|
||||
import { Layout, Loading } from '@superset-ui/core/components';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
import MobileRouteGuard from 'src/components/MobileRouteGuard';
|
||||
import Menu from 'src/features/home/Menu';
|
||||
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
|
||||
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
||||
@@ -84,22 +85,24 @@ const App = () => (
|
||||
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
|
||||
<Route path={path} key={path}>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<Layout>
|
||||
<Layout.Content
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary
|
||||
<MobileRouteGuard>
|
||||
<Layout>
|
||||
<Layout.Content
|
||||
css={css`
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`}
|
||||
>
|
||||
<Component user={bootstrapData.user} {...props} />
|
||||
</ErrorBoundary>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
<ErrorBoundary
|
||||
css={css`
|
||||
margin: 16px;
|
||||
`}
|
||||
>
|
||||
<Component user={bootstrapData.user} {...props} />
|
||||
</ErrorBoundary>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</MobileRouteGuard>
|
||||
</Suspense>
|
||||
</Route>
|
||||
))}
|
||||
|
||||
@@ -427,6 +427,13 @@ export const CardContainer = styled.div<{
|
||||
? `${theme.sizeUnit * 8 + 3}px ${theme.sizeUnit * 20}px`
|
||||
: `${theme.sizeUnit * 8 + 1}px ${theme.sizeUnit * 20}px`
|
||||
};
|
||||
|
||||
/* Full-width cards on mobile */
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
padding-right: ${theme.sizeUnit * 4}px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -439,6 +446,13 @@ export const CardStyles = styled.div`
|
||||
/* Height is calculated based on 300px width, to keep the same aspect ratio as the 800*450 thumbnails */
|
||||
height: 168px;
|
||||
}
|
||||
|
||||
/* Hide kebab menu on mobile - consumption mode only */
|
||||
@media (max-width: 767px) {
|
||||
.ant-dropdown-trigger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export /* eslint-disable no-underscore-dangle */
|
||||
|
||||
Reference in New Issue
Block a user