Compare commits

...

8 Commits

Author SHA1 Message Date
Superset Dev
aad2504a23 fix(mobile): viewport meta tag, swipeable tabs, and accurate title sizing
Three fixes surfaced by measuring the dashboard header in an iPhone
emulation instead of a resized desktop window:

- Serve <meta name="viewport" content="width=device-width"> from
  spa.html when MOBILE_CONSUMPTION_MODE is on. Without it mobile
  browsers lay the page out at the ~980px legacy viewport and none of
  the mobile breakpoints ever match on a real device — the mode only
  appeared to work in resized desktop windows and devtools emulation.
  Gated on the flag so desktop-only deployments keep shrink-to-fit
  rendering on phones; exposes is_feature_enabled to Jinja via the
  common context processor.
- Replace the dashboard tab overflow dropdown with a swipeable tab bar
  on mobile. The "more" menu is a poor touch target and duplicates
  half-clipped tabs; antd's tab nav scrolls natively on touch and its
  ping-edge shadows signal overflow.
- DynamicEditableTitle sized its input from a hidden sizer measured
  before webfonts loaded (fallback metrics ~10px narrower) and never
  re-measured, truncating titles that actually fit; the input also
  rendered at font-weight 400 while the sizer measured at the
  container's 500. Re-measure on document.fonts.ready and inherit the
  container's font weight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:11:16 -07:00
Superset Dev
ac71153d56 fix(mobile): hide redundant header refresh button, uniform gutters between stacked cards
- The dashboard header's refresh icon duplicates the kebab menu's
  "Refresh dashboard" item on mobile, where title space is scarce; hide
  the icon and keep the menu item
- Rows collapsed into a vertical stack lost their gutter (the desktop
  margin-right is zeroed), so same-row neighbors touched while
  cross-row neighbors kept GridContent's 16px margin; give stacked row
  children the same theme.sizeUnit * 4 gutter GridContent and Column use

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:37:16 -07:00
Superset Dev
33d570284b fix(mobile): full-width for all grid components, cap chart height to viewport
Fixes two layout defects in mobile consumption mode:

- Markdown, Column, and other non-chart grid components kept their
  desktop pixel widths (only ChartHolder got the JS-level mobile width),
  rendering narrow and clipping charts nested inside columns. Restore
  the full-width mobile CSS on ResizableContainer, gated on the feature
  flag, to cover every component that sizes via re-resizable's inline
  width.
- Charts authored tall for wide desktop grids became towers in the
  single-column stacked layout. Cap the height at the viewport minus
  app chrome (MOBILE_CHROME_HEIGHT), applied to both the container
  shell and the height handed to the chart plugin so they stay in sync.

Also rework useIsMobile to subscribe to matchMedia only when
MOBILE_CONSUMPTION_MODE is enabled, instead of antd Grid.useBreakpoint.
useBreakpoint forces a post-mount re-render of every consumer even with
the flag off, which changed measured chart header heights and made the
flag non-inert for default deployments. With the rework, flag off means
no subscription, no state updates, and zero render delta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:31:39 -07:00
Superset Dev
a175324cff chore(mobile): enable MOBILE_CONSUMPTION_MODE in the docker dev config
The shipped default in superset/config.py remains False; this only turns
the flag on for the docker compose development environment, matching the
other in-development flags enabled there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:55:28 -07:00
Superset Dev
9da3908bec refactor(mobile): gate consumption mode behind MOBILE_CONSUMPTION_MODE flag, fix chart sizing
Review-pass hardening of the mobile consumption mode:

- Add MOBILE_CONSUMPTION_MODE feature flag (default off, @lifecycle:
  development) and a shared useIsMobile() hook; all mobile behavior and
  mobile CSS now keys off the flag, so default behavior is unchanged
- Fix chart sizing: ChartHolder reports the full grid column count on
  mobile so plugins get the true stacked width; drop the
  width:100%!important CSS overrides on Chart/Row/ResizableContainer
- ListView: mount a single FilterControls instance (inline or drawer)
  so filtersRef/clearFilters always target the visible instance; drop
  the desktop-filters/desktop-sort CSS-hidden duplicates
- Route gating: routes declare mobileSupported in routes.tsx and
  MobileRouteGuard receives it as a prop; remove the regex allowlist,
  the unregistered /mobile-unsupported entry, and dead ?from= handling
- FilterBar: first-class mobileMode in verticalConfig replaces the
  :has()/!important drawer CSS surgery in DashboardBuilder
- Consumption-only controls (explore links, slice kebab menus, card
  kebab menus) are render-gated instead of CSS-hidden for a11y
- Use theme.screenSMMax instead of hardcoded 767px media queries
- RightMenu: match the Dashboards nav item by FAB-internal name, not
  translated label; fix getOwnerName -> getUserName and owners ->
  editors after the Subject-model refactor
- MobileUnsupported: use Button/link + icon instead of raw styled
  buttons and a trailing arrow outside the translated string
- Regenerate docs/static/feature-flags.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 23:53:04 -07:00
Claude Code
76c247513e feat(mobile): Add mobile-friendly dashboard consumption mode
Adds a mobile-optimized dashboard consumption experience:

- Drawer-based nav and filter menus on mobile
- Full-width chart layout on small screens, with charts stacked
- Compact dashboard header (hidden action buttons that don't apply in
  consumption mode)
- Force card view (and hide the table/card toggle) on the dashboard list
- Full-width cards on the welcome page when viewed on mobile
- Filter drawer for the dashboard list page
- Route guard for pages that don't yet have a mobile experience, with a
  dedicated MobileUnsupported page
- Mock-friendly `Grid.useBreakpoint` plumbing for existing component
  tests, plus a new mobile-focused test suite and Playwright coverage

Squashed from 32 commits on the long-running mobile-dashboard-support
branch and rebased onto current master.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 22:48:31 -07:00
Evan Rusackas
f697a0c24d test(charts): assert drill-to-detail carries applied filters (#28562) (#41960)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-15 22:24:33 -07:00
Evan Rusackas
13d38a9cbd test(security): assert intended trailing-slash behavior of security API (#29934) (#41965)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-15 22:24:15 -07:00
54 changed files with 2945 additions and 204 deletions

View File

@@ -109,6 +109,7 @@ FEATURE_FLAGS = {
"ALERT_REPORTS": True,
"DATASET_FOLDERS": True,
"ENABLE_EXTENSIONS": True,
"MOBILE_CONSUMPTION_MODE": True,
"SEMANTIC_LAYERS": True,
}
EXTENSIONS_PATH = "/app/docker/extensions"

View File

@@ -69,6 +69,12 @@
"lifecycle": "development",
"description": "Enable Matrixify feature for matrix-style chart layouts"
},
{
"name": "MOBILE_CONSUMPTION_MODE",
"default": false,
"lifecycle": "development",
"description": "Serve a consumption-only mobile experience (dashboards, dashboard list, and home page) on small screens; other views show a \"not supported on mobile\" screen. Authoring features are hidden on mobile when enabled."
},
{
"name": "OPTIMIZE_SQL",
"default": false,

View File

@@ -49,6 +49,7 @@ const titleStyles = (theme: SupersetTheme) => css`
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
font-weight: inherit;
color: ${theme.colorText};
background-color: ${theme.colorBgContainer};
@@ -127,6 +128,21 @@ export const DynamicEditableTitle = memo(
}
}, [currentTitle, placeholder]);
// Webfont metrics differ from the fallback font's, so a measurement
// taken before fonts finish loading under- or over-sizes the input.
// Re-measure once all fonts are ready.
useEffect(() => {
let cancelled = false;
document.fonts?.ready?.then(() => {
if (!cancelled && sizerRef.current) {
setInputWidth(sizerRef.current.offsetWidth);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const inputElement = inputRef.current?.input;

View File

@@ -20,6 +20,7 @@ import { ReactNode, ReactElement, memo } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, SupersetTheme, useTheme } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { FeatureFlag, isFeatureEnabled } from '../../utils/featureFlags';
import type { DropdownProps } from '../Dropdown/types';
import type { TooltipPlacement } from '../Tooltip/types';
import type { CertifiedBadgeProps } from '../CertifiedBadge/types';
@@ -82,6 +83,18 @@ const headerStyles = (theme: SupersetTheme) => css`
display: flex;
align-items: center;
}
/* Mobile consumption mode: center the title between left/right panels */
${isFeatureEnabled(FeatureFlag.MobileConsumptionMode) &&
css`
@media (max-width: ${theme.screenSMMax}px) {
.title-panel {
flex: 1;
justify-content: center;
margin-right: 0;
}
}
`}
`;
const buttonsStyles = (theme: SupersetTheme) => css`
@@ -109,6 +122,7 @@ export type PageHeaderWithActionsProps = {
showFaveStar: boolean;
showMenuDropdown?: boolean;
faveStarProps: FaveStarProps;
leftPanelItems?: ReactNode;
titlePanelAdditionalItems: ReactNode;
rightPanelAdditionalItems: ReactNode;
additionalActionsMenu: ReactElement;
@@ -126,6 +140,7 @@ export const PageHeaderWithActions = memo(
certificatiedBadgeProps,
showFaveStar,
faveStarProps,
leftPanelItems,
titlePanelAdditionalItems,
rightPanelAdditionalItems,
additionalActionsMenu,
@@ -136,6 +151,7 @@ export const PageHeaderWithActions = memo(
const theme = useTheme();
return (
<div css={headerStyles} className="header-with-actions">
{leftPanelItems}
<div className="title-panel">
<DynamicEditableTitle {...editableTitleProps} />
{showTitlePanelItems && (

View File

@@ -59,6 +59,7 @@ export enum FeatureFlag {
GranularExportControls = 'GRANULAR_EXPORT_CONTROLS',
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
Matrixify = 'MATRIXIFY',
MobileConsumptionMode = 'MOBILE_CONSUMPTION_MODE',
ScheduledQueries = 'SCHEDULED_QUERIES',
SemanticLayers = 'SEMANTIC_LAYERS',
SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE',

View File

@@ -0,0 +1,326 @@
/**
* 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';
// NOTE: These tests exercise the mobile consumption experience and require
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
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 => /\/dashboard\/(?!list)/.test(url.pathname), {
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 => /\/dashboard\/(?!list)/.test(url.pathname), {
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 => /\/dashboard\/(?!list)/.test(url.pathname), {
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 => /\/dashboard\/(?!list)/.test(url.pathname), {
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()).toMatch(/\/dashboard\/(?!list)/);
}
}
}
});
});
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 => /\/dashboard\/(?!list)/.test(url.pathname), {
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 => /\/dashboard\/(?!list)/.test(url.pathname), {
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,
});
}
}
});
});

View File

@@ -0,0 +1,221 @@
/**
* 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';
// NOTE: These tests exercise the mobile consumption experience and require
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
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('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 });
});
});

View File

@@ -0,0 +1,193 @@
/**
* 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.
*/
import { FeatureFlag } from '@superset-ui/core';
/**
* 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,
},
});
/**
* Mocks window.matchMedia so `(max-width: ...)` queries match, simulating
* a mobile viewport for the useIsMobile hook. Returns a cleanup function
* restoring the previous matchMedia. Mobile behavior requires BOTH this
* AND the MOBILE_CONSUMPTION_MODE flag (see enableMobileConsumptionFlag).
*/
export const mockMobileMatchMedia = () => {
const previous = window.matchMedia;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches: query.includes('max-width'),
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
return () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: previous,
});
};
};
/**
* Enables the MOBILE_CONSUMPTION_MODE feature flag on window.featureFlags.
* Mobile behavior requires BOTH a small viewport (mockMobileMatchMedia)
* AND this flag; call this in beforeAll/beforeEach of mobile test suites.
* Returns a cleanup function restoring the previous flags.
*/
export const enableMobileConsumptionFlag = () => {
const previous = window.featureFlags;
window.featureFlags = {
...window.featureFlags,
[FeatureFlag.MobileConsumptionMode]: true,
};
return () => {
window.featureFlags = previous;
};
};
/**
* 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),
});

View File

@@ -19,6 +19,7 @@
import { ReactNode, MouseEvent as ReactMouseEvent } from 'react';
import { TableInstance, Row, UseRowSelectRowProps } from 'react-table';
import { styled } from '@apache-superset/core/theme';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import cx from 'classnames';
interface CardCollectionProps {
@@ -42,6 +43,18 @@ 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 (consumption mode) */
${
isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
grid-template-columns: 1fr;
grid-gap: ${theme.sizeUnit * 4}px;
padding-left: ${theme.sizeUnit * 4}px;
padding-right: ${theme.sizeUnit * 4}px;
}`
: ''
}
`}
`;

View File

@@ -386,3 +386,227 @@ describe('ListView', () => {
expect(screen.getByTestId('empty-state')).toHaveClass('card');
});
});
// 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 the filter controls; select filters render
// as popover triggers (aria-haspopup="listbox") in the drawer
const drawer = screen.getByRole('dialog');
const filterTriggers = drawer.querySelectorAll('[aria-haspopup="listbox"]');
expect(filterTriggers.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();
});

View File

@@ -34,6 +34,7 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
import {
Button,
Tooltip,
Drawer,
Icons,
EmptyState,
Loading,
@@ -239,6 +240,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,
@@ -305,6 +330,7 @@ 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;
@@ -320,6 +346,12 @@ export interface ListViewProps<T extends object = any> {
expandable?: Record<string, unknown>;
/** Content rendered between the filter bar and the table/card body. */
headerContent?: ReactNode;
/** 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>({
@@ -341,6 +373,7 @@ export function ListView<T extends object = any>({
showThumbnails,
cardSortSelectOptions,
defaultViewMode = 'card',
forceViewMode,
highlightRowId,
emptyState,
columnsForWrapText,
@@ -351,6 +384,9 @@ export function ListView<T extends object = any>({
headerContent,
addSuccessToast,
addDangerToast,
mobileFiltersOpen = false,
setMobileFiltersOpen,
mobileFiltersDrawerTitle,
}: ListViewProps<T>) {
const {
getTableProps,
@@ -377,6 +413,7 @@ export function ListView<T extends object = any>({
initialFilters: filters,
renderCard: Boolean(renderCard),
defaultViewMode,
forceViewMode,
});
const allowBulkTagActions = bulkTagResourceName && enableBulkTag;
const filterable = Boolean(filters.length);
@@ -453,11 +490,15 @@ 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 && (
{/* When a mobile drawer callback is provided, filters and sort
render inside the drawer instead of inline. Only one
FilterControls instance is ever mounted, so filtersRef and
filterControlsRef always point at the visible instance. */}
{filterable && !setMobileFiltersOpen && (
<FilterControls
ref={filterControlsRef}
filters={filters}
@@ -465,13 +506,15 @@ export function ListView<T extends object = any>({
updateFilterValue={applyFilterValue}
/>
)}
{viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{viewMode === 'card' &&
cardSortSelectOptions &&
!setMobileFiltersOpen && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{filterable && (
<Tooltip
title={!hasActiveFilters ? t('No filters applied') : undefined}
@@ -659,6 +702,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>
);
}

View File

@@ -195,6 +195,7 @@ interface UseListViewConfig {
initialFilters?: Filter[];
renderCard?: boolean;
defaultViewMode?: ViewModeType;
forceViewMode?: ViewModeType;
}
export function useListViewState({
@@ -207,6 +208,7 @@ export function useListViewState({
initialSort = [],
renderCard = false,
defaultViewMode = 'card',
forceViewMode,
}: UseListViewConfig) {
const [query, setQuery] = useQueryParams({
filters: RisonParam,
@@ -234,10 +236,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 columnsWithFilter = useMemo(
// add exact filter type so filters with falsy values are not filtered out
() => columns.map(f => ({ ...f, filter: 'exact' })),

View File

@@ -0,0 +1,73 @@
/**
* 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 { MemoryRouter } from 'react-router-dom';
import { render, screen } from 'spec/helpers/testing-library';
import { useIsMobile } from 'src/hooks/useIsMobile';
import MobileRouteGuard from '.';
import { MOBILE_BYPASS_STORAGE_KEY } from '../../pages/MobileUnsupported';
jest.mock('src/hooks/useIsMobile', () => ({
useIsMobile: jest.fn(),
isMobileConsumptionEnabled: jest.fn().mockReturnValue(true),
}));
const mockedUseIsMobile = useIsMobile as jest.MockedFunction<
typeof useIsMobile
>;
const renderGuard = (mobileSupported?: boolean) =>
render(
<MemoryRouter initialEntries={['/some/route/']}>
<MobileRouteGuard mobileSupported={mobileSupported}>
<div data-test="guarded-content">Content</div>
</MobileRouteGuard>
</MemoryRouter>,
);
beforeEach(() => {
sessionStorage.clear();
mockedUseIsMobile.mockReturnValue(false);
});
test('renders children on desktop regardless of mobileSupported', () => {
renderGuard(undefined);
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
});
test('renders children on mobile when the route is mobileSupported', () => {
mockedUseIsMobile.mockReturnValue(true);
renderGuard(true);
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
});
test('shows the unsupported screen on mobile for unsupported routes', () => {
mockedUseIsMobile.mockReturnValue(true);
renderGuard(undefined);
expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument();
expect(
screen.getByText("This view isn't available on mobile"),
).toBeInTheDocument();
});
test('renders children on mobile when the bypass flag is set', () => {
mockedUseIsMobile.mockReturnValue(true);
sessionStorage.setItem(MOBILE_BYPASS_STORAGE_KEY, 'true');
renderGuard(undefined);
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
});

View File

@@ -0,0 +1,78 @@
/**
* 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 { useIsMobile } from 'src/hooks/useIsMobile';
import MobileUnsupported, {
MOBILE_BYPASS_STORAGE_KEY,
} from 'src/pages/MobileUnsupported';
interface MobileRouteGuardProps {
children: ReactNode;
/**
* Whether the wrapped route is part of the mobile consumption
* experience. Set via the `mobileSupported` flag on the route
* definition in `src/views/routes.tsx`.
*/
mobileSupported?: boolean;
}
/**
* Wraps route content and shows the MobileUnsupported page when a
* non-mobile-friendly route is accessed on a small screen with
* MOBILE_CONSUMPTION_MODE enabled.
*
* Users can bypass this by clicking "Continue anyway", which sets a
* sessionStorage flag for the rest of the browser session.
*/
function MobileRouteGuard({
children,
mobileSupported,
}: MobileRouteGuardProps) {
const isMobile = useIsMobile();
const location = useLocation();
const [bypassEnabled, setBypassEnabled] = useState(() => {
try {
return sessionStorage.getItem(MOBILE_BYPASS_STORAGE_KEY) === 'true';
} catch {
return false;
}
});
// Check for bypass flag when location changes
useEffect(() => {
try {
const bypass =
sessionStorage.getItem(MOBILE_BYPASS_STORAGE_KEY) === 'true';
setBypassEnabled(bypass);
} catch {
// Storage access denied, keep current state
}
}, [location.pathname]);
if (!isMobile || bypassEnabled || mobileSupported) {
return <>{children}</>;
}
return (
<MobileUnsupported originalPath={location.pathname + location.search} />
);
}
export default MobileRouteGuard;

View File

@@ -50,6 +50,15 @@ fetchMock.put('glob:*/api/v1/dashboard/*', {});
// Add mock for logging endpoint
fetchMock.post('glob:*/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(),
@@ -422,6 +431,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { getByTestId } = setup();
@@ -448,6 +458,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: false,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { getByTestId } = setup();
@@ -474,6 +485,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
hasFilters: false,
});
const { getByTestId } = setup();
@@ -533,6 +545,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
hasFilters: false,
});
const { queryByTestId } = setup();
@@ -546,6 +559,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { queryByTestId } = setup();
@@ -559,6 +573,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { queryByTestId } = setup({
dashboardState: { ...mockState.dashboardState, editMode: true },
@@ -719,3 +734,85 @@ 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,
useRouter: 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,
useRouter: 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,
useRouter: true,
});
// Header container should be present
expect(queryByTestId('dashboard-header-container')).toBeInTheDocument();
});

View File

@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core/translation';
import { addAlpha, JsonObject, useElementOnScreen } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { useDispatch, useSelector } from 'react-redux';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { Drawer, EmptyState, 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';
@@ -59,6 +59,7 @@ import {
} from 'src/dashboard/util/constants';
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
import { useUiConfig } from 'src/components/UiConfigContext';
import { useIsMobile } from 'src/hooks/useIsMobile';
import ResizableSidebar from 'src/components/ResizableSidebar';
import {
BUILDER_SIDEPANEL_WIDTH,
@@ -142,9 +143,8 @@ const DashboardContentWrapper = styled.div`
& .dashboard-component-tabs {
box-shadow: 0 ${theme.sizeUnit}px ${theme.sizeUnit}px 0
${addAlpha(theme.colorBorderSecondary, 0.1)};
padding-left: ${
theme.sizeUnit * 2
}px; /* note this is added to tab-level padding, to match header */
padding-left: ${theme.sizeUnit *
2}px; /* note this is added to tab-level padding, to match header */
}
.dropdown-toggle.btn.btn-primary .caret {
@@ -297,14 +297,12 @@ const StyledDashboardContent = styled.div<{
margin: ${theme.sizeUnit * 4}px;
margin-left: ${marginLeft}px;
${
editMode &&
`
${editMode &&
`
max-width: calc(100% - ${
BUILDER_SIDEPANEL_WIDTH + theme.sizeUnit * 16
}px);
`
}
`}
/* this is the ParentSize wrapper */
& > div:first-of-type {
@@ -341,10 +339,8 @@ const StyledDashboardContent = styled.div<{
}
&.fade-out {
box-shadow: ${
theme.dashboardTileBoxShadow ??
`0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`
};
box-shadow: ${theme.dashboardTileBoxShadow ??
`0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`};
}
& .missing-chart-container {
@@ -372,6 +368,8 @@ const DashboardBuilder = () => {
const dispatch = useDispatch();
const uiConfig = useUiConfig();
const theme = useTheme();
const isNotMobile = !useIsMobile();
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const dashboardId = useSelector<RootState, string>(
({ dashboardInfo }) => `${dashboardInfo.id}`,
@@ -462,13 +460,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 +
@@ -480,6 +479,7 @@ const DashboardBuilder = () => {
const draggableStyle = useMemo(
() => ({
marginLeft:
!isNotMobile ||
dashboardFiltersOpen ||
editMode ||
!nativeFiltersEnabled ||
@@ -488,6 +488,7 @@ const DashboardBuilder = () => {
: -32,
}),
[
isNotMobile,
dashboardFiltersOpen,
editMode,
filterBarOrientation,
@@ -519,7 +520,15 @@ const DashboardBuilder = () => {
const headerContent = useMemo(
() => (
<>
{!hideDashboardHeader && <DashboardHeader />}
{!hideDashboardHeader && (
<DashboardHeader
onOpenMobileFilters={
!isNotMobile && nativeFiltersEnabled && hasFilters
? () => setMobileFiltersOpen(true)
: undefined
}
/>
)}
{/* Report mode is a one-shot screenshot render (reports, thumbnails),
so it must never start a refresh timer that could re-fetch charts
mid-capture. */}
@@ -533,7 +542,15 @@ const DashboardBuilder = () => {
)}
</>
),
[hideDashboardHeader, showFilterBar, filterBarOrientation, isReport],
[
hideDashboardHeader,
isNotMobile,
nativeFiltersEnabled,
hasFilters,
showFilterBar,
filterBarOrientation,
isReport,
],
);
const renderDraggableContent = useCallback(
@@ -577,6 +594,8 @@ const DashboardBuilder = () => {
topLevelTabs,
uiConfig.hideTab,
uiConfig.hideNav,
isNotMobile,
theme,
],
);
@@ -739,6 +758,36 @@ 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',
},
}}
>
<FilterBar
orientation={FilterBarOrientation.Vertical}
verticalConfig={{
filtersOpen: true,
toggleFiltersBar: () => {},
width: 300,
height: '100%',
offset: 0,
mobileMode: true,
}}
hidden={false}
/>
</Drawer>
)}
</DashboardWrapper>
);
};

View File

@@ -25,6 +25,7 @@ import { useSelector } from 'react-redux';
import { useDragDropManager } from 'react-dnd';
import classNames from 'classnames';
import { debounce } from 'lodash-es';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
const StyledDiv = styled.div`
${({ theme }) => css`
@@ -110,6 +111,20 @@ const StyledDiv = styled.div`
i.warning {
color: ${theme.colorWarning};
}
/* Mobile consumption mode: show the full chart title without
truncation (controls and links are render-gated in SliceHeader) */
${isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
[data-test='slice-header'] .header-title {
-webkit-line-clamp: unset;
display: block;
white-space: normal;
overflow: visible;
text-overflow: unset;
}
}`
: ''}
`}
`;

View File

@@ -121,5 +121,6 @@ export const useNativeFilters = () => {
dashboardFiltersOpen,
toggleDashboardFiltersOpen,
nativeFiltersEnabled,
hasFilters: filterValues.length > 0,
};
};

View File

@@ -186,6 +186,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(),
}));

View File

@@ -23,7 +23,12 @@ import {
FeatureFlag,
getExtensionsRegistry,
} from '@superset-ui/core';
import { styled, css, SupersetTheme } from '@apache-superset/core/theme';
import {
styled,
css,
SupersetTheme,
useTheme,
} from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { Global } from '@emotion/react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
@@ -37,6 +42,7 @@ import {
UnsavedChangesModal,
} from '@superset-ui/core/components';
import { findPermission } from 'src/utils/findPermission';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { safeStringify } from 'src/utils/safeStringify';
import Subject from 'src/types/Subject';
import { DashboardLayout, RootState } from 'src/dashboard/types';
@@ -219,8 +225,14 @@ const discardChanges = () => {
window.location.assign(url);
};
const Header = (): JSX.Element => {
interface HeaderComponentProps {
onOpenMobileFilters?: () => void;
}
const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
const dispatch = useDispatch();
const theme = useTheme();
const isMobile = useIsMobile();
const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] =
useState(false);
const [emphasizeUndo, setEmphasizeUndo] = useState(false);
@@ -631,7 +643,8 @@ const Header = (): JSX.Element => {
const titlePanelAdditionalItems = useMemo(
() => [
!editMode && (
// The kebab menu's "Refresh dashboard" item covers this on mobile
!editMode && !isMobile && (
<RefreshButton key="refresh-button" onRefresh={forceRefresh} />
),
!editMode && (
@@ -640,7 +653,7 @@ const Header = (): JSX.Element => {
onTogglePause={handlePauseToggle}
/>
),
!editMode && (
!editMode && !isMobile && (
<PublishedStatus
key="published-status"
dashboardId={dashboardInfo.id}
@@ -650,12 +663,13 @@ const Header = (): JSX.Element => {
userCanSave={userCanSaveAs}
/>
),
!editMode && !isEmbedded && metadataBar,
!editMode && !isEmbedded && !isMobile && metadataBar,
],
[
boundActionCreators.savePublished,
dashboardInfo.id,
editMode,
isMobile,
metadataBar,
isEmbedded,
isPublished,
@@ -750,7 +764,7 @@ const Header = (): JSX.Element => {
) : (
<div css={actionButtonsStyle}>
{NavExtension && <NavExtension />}
{userCanEdit && !isEmbedded && (
{userCanEdit && !isEmbedded && !isMobile && (
<Button
buttonStyle="secondary"
onClick={handleEnterEditMode}
@@ -778,6 +792,7 @@ const Header = (): JSX.Element => {
handleEnterEditMode,
hasUnsavedChanges,
isEmbedded,
isMobile,
overwriteDashboard,
redoLength,
undoLength,
@@ -814,6 +829,10 @@ const Header = (): JSX.Element => {
userCanCurate,
userCanExport,
isLoading,
isMobile,
isStarred,
isPublished,
saveFaveStar: boundActionCreators.saveFaveStar,
showReportModal,
showPropertiesModal,
showRefreshModal,
@@ -833,6 +852,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={{
@@ -840,7 +874,7 @@ const Header = (): JSX.Element => {
onOpenChange: setIsDropdownVisible,
}}
additionalActionsMenu={menu}
showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
showFaveStar={!!(user?.userId && dashboardInfo?.id && !isMobile)}
showTitlePanelItems
/>
{showingPropertiesModal && (

View File

@@ -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;

View File

@@ -37,6 +37,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
import { usePermissions } from 'src/hooks/usePermissions';
import getUserName from 'src/utils/getUserName';
export const useHeaderActionsMenu = ({
customCss,
@@ -56,6 +57,10 @@ export const useHeaderActionsMenu = ({
userCanCurate,
userCanExport,
isLoading,
isMobile,
isStarred,
isPublished,
saveFaveStar,
lastModifiedTime,
addSuccessToast,
addDangerToast,
@@ -117,6 +122,11 @@ export const useHeaderActionsMenu = ({
case MenuKeys.ManageEmbedded:
manageEmbedded();
break;
case 'toggle-favorite':
if (saveFaveStar && isStarred !== undefined) {
saveFaveStar(dashboardId, isStarred);
}
break;
default:
break;
}
@@ -128,6 +138,9 @@ export const useHeaderActionsMenu = ({
showPropertiesModal,
showRefreshModal,
manageEmbedded,
saveFaveStar,
dashboardId,
isStarred,
history,
location,
],
@@ -205,6 +218,52 @@ 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,
});
// Editor info
const editorNames = dashboardInfo?.editors?.length
? dashboardInfo.editors
.map((editor: { label?: string }) => editor.label)
.filter(Boolean)
.join(', ')
: t('None');
menuItems.push({
key: 'owner-info',
label: t('Owner: %(names)s', { names: editorNames }),
disabled: true,
});
// Last modified
const modifiedBy =
getUserName(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({
@@ -224,8 +283,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)
@@ -293,15 +352,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);
}
@@ -339,11 +398,15 @@ export const useHeaderActionsMenu = ({
expandedSlices,
handleMenuClick,
isLoading,
isMobile,
isPublished,
isStarred,
lastModifiedTime,
layout,
onSave,
refreshFrequency,
reportMenuItem,
saveFaveStar,
shareMenuItems,
shouldPersistRefreshFrequency,
userCanCurate,

View File

@@ -43,6 +43,7 @@ import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { Tooltip, EditableTitle, Icons } from '@superset-ui/core/components';
import { useSelector } from 'react-redux';
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types';
import FiltersBadge from 'src/dashboard/components/FiltersBadge';
import CustomizationsBadge from 'src/dashboard/components/CustomizationsBadge';
@@ -229,7 +230,9 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
0,
);
const canExplore = !editMode && supersetCanExplore;
// Consumption-only mobile mode: no explore link, no chart controls
const isMobile = useIsMobile();
const canExplore = !editMode && supersetCanExplore && !isMobile;
const showRowLimitWarning =
shouldShowRowLimitWarning && sqlRowCount >= rowLimit && rowLimit > 0;
@@ -355,7 +358,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
}
/>
)}
{!uiConfig.hideChartControls && (
{!uiConfig.hideChartControls && !isMobile && (
<SliceHeaderControls
slice={slice}
isCached={isCached}

View File

@@ -36,13 +36,19 @@ import { AntdThemeProvider } from '@superset-ui/core/components';
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
import {
GRID_BASE_UNIT,
GRID_COLUMN_COUNT,
GRID_GUTTER_SIZE,
GRID_MIN_COLUMN_COUNT,
GRID_MIN_ROW_UNITS,
} from 'src/dashboard/util/constants';
import { useIsMobile } from 'src/hooks/useIsMobile';
export const CHART_MARGIN = 32;
// Vertical space reserved for app chrome (main nav + dashboard header +
// tab bar) when capping chart heights to the viewport on mobile.
export const MOBILE_CHROME_HEIGHT = 160;
export interface ChartHolderProps {
id: string;
parentId: string;
@@ -96,6 +102,7 @@ const ChartHolder = ({
isInView,
}: ChartHolderProps) => {
const theme = useTheme();
const isMobile = useIsMobile();
const fullSizeStyle = css`
&& {
position: fixed !important;
@@ -167,6 +174,14 @@ const ChartHolder = ({
}, [outlinedComponentId]);
const widthMultiple = useMemo(() => {
// Mobile consumption mode stacks charts vertically at full width, so
// report the full column count. This keeps the pixel width handed to the
// chart plugin (and to ResizableContainer's inline size) in sync with the
// stacked layout instead of the desktop grid fraction.
if (isMobile && !editMode) {
return GRID_COLUMN_COUNT;
}
const columnParentWidth = getComponentById(
parentComponent.parents?.find(parent => parent.startsWith(COLUMN_TYPE)),
)?.meta?.width;
@@ -182,11 +197,29 @@ const ChartHolder = ({
}, [
component,
getComponentById,
isMobile,
editMode,
parentComponent.meta.width,
parentComponent.parents,
parentComponent.type,
]);
// Grid units of height for this chart. In mobile consumption mode the
// authored desktop height is capped to the viewport (minus app chrome) so
// tall charts don't dominate the single-column stacked layout. Used for
// both the ResizableContainer shell and the height handed to the plugin,
// so the two can't disagree.
const heightMultiple = useMemo(() => {
const authoredHeight = component.meta.height ?? GRID_MIN_ROW_UNITS;
if (isMobile && !editMode) {
const maxUnits = Math.floor(
(window.innerHeight - MOBILE_CHROME_HEIGHT) / GRID_BASE_UNIT,
);
return Math.max(GRID_MIN_ROW_UNITS, Math.min(authoredHeight, maxUnits));
}
return authoredHeight;
}, [component.meta.height, isMobile, editMode]);
const { chartWidth, chartHeight } = useMemo(() => {
let width = 0;
let height = 0;
@@ -200,16 +233,14 @@ const ChartHolder = ({
(widthMultiple - 1) * GRID_GUTTER_SIZE -
CHART_MARGIN,
);
height = Math.floor(
(component.meta.height ?? 0) * GRID_BASE_UNIT - CHART_MARGIN,
);
height = Math.floor(heightMultiple * GRID_BASE_UNIT - CHART_MARGIN);
}
return {
chartWidth: width,
chartHeight: height,
};
}, [columnWidth, component, isFullSize, widthMultiple]);
}, [columnWidth, heightMultiple, isFullSize, widthMultiple]);
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
@@ -250,7 +281,7 @@ const ChartHolder = ({
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
heightMultiple={heightMultiple}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
@@ -342,12 +373,12 @@ const ChartHolder = ({
),
[
component.id,
component.meta.height,
component.meta.chartId,
component.meta.sliceNameOverride,
component.meta.sliceName,
parentComponent.type,
columnWidth,
heightMultiple,
widthMultiple,
availableColumnCount,
onResizeStart,

View File

@@ -37,6 +37,7 @@ import {
Droppable,
} from 'src/dashboard/components/dnd/DragDroppable';
import DragHandle from 'src/dashboard/components/dnd/DragHandle';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
@@ -120,6 +121,27 @@ const GridRow = styled.div<{ editMode: boolean }>`
&.grid-row--empty {
min-height: ${theme.sizeUnit * 25}px;
}
${
isMobileConsumptionEnabled() &&
css`
@media (max-width: ${theme.screenSMMax}px) {
flex-direction: column;
& > :not(.hover-menu) {
width: 100% !important;
margin-right: 0 !important;
}
/* Stacked children get the same vertical gutter GridContent puts
between rows and Column puts between its children, so spacing
stays uniform across the whole stacked layout */
& > :not(.hover-menu):not(:last-child) {
margin-bottom: ${theme.sizeUnit * 4}px;
}
}
`
}
`}
`;

View File

@@ -25,7 +25,8 @@ import {
useRef,
useState,
} from 'react';
import { styled } from '@apache-superset/core/theme';
import { css, styled } from '@apache-superset/core/theme';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import {
LineEditableTabs,
TabsProps as AntdTabsProps,
@@ -81,6 +82,28 @@ const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
display: none !important;
}
`}
/* Sticky tabs on mobile (consumption mode) */
${({ theme }) =>
isMobileConsumptionEnabled() &&
css`
@media (max-width: ${theme.screenSMMax}px) {
.ant-tabs-nav {
position: sticky;
top: 0;
z-index: 100;
background-color: ${theme.colorBgContainer};
}
/* Swipeable tab bar instead of the overflow dropdown: the "more"
menu is a poor touch target and duplicates half-clipped tabs.
antd's tab nav supports touch-drag scrolling natively and shows
edge shadows (ping classes) when tabs overflow. */
.ant-tabs-nav-operations {
display: none !important;
}
}
`}
`;
export interface TabItem {

View File

@@ -32,7 +32,7 @@ import {
import { useSelector } from 'react-redux';
import cx from 'classnames';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme } from '@apache-superset/core/theme';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { RootState } from 'src/dashboard/types';
import { DataMaskStateWithId } from '@superset-ui/core';
import { Icons } from '@superset-ui/core/components/Icons';
@@ -141,6 +141,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
onPendingCustomizationDataMaskChange,
toggleFiltersBar,
width,
mobileMode,
}) => {
const theme = useTheme();
const [isScrolling, setIsScrolling] = useState(false);
@@ -260,33 +261,56 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
{...getFilterBarTestId()}
className={cx({ open: filtersOpen })}
width={width}
css={
mobileMode &&
css`
width: 100%;
&.open {
width: 100%;
}
`
}
>
<CollapsedBar
{...getFilterBarTestId('collapsable')}
className={cx({ open: !filtersOpen })}
onClick={openFiltersBar}
role="button"
tabIndex={0}
offset={offset}
{!mobileMode && (
<CollapsedBar
{...getFilterBarTestId('collapsable')}
className={cx({ open: !filtersOpen })}
onClick={openFiltersBar}
role="button"
tabIndex={0}
offset={offset}
>
<Icons.VerticalAlignTopOutlined
iconSize="l"
css={{
transform: 'rotate(90deg)',
marginBottom: `${theme.sizeUnit * 3}px`,
}}
className="collapse-icon"
iconColor={theme.colorPrimary}
{...getFilterBarTestId('expand-button')}
/>
<Icons.FilterOutlined
{...getFilterBarTestId('filter-icon')}
iconColor={theme.colorTextTertiary}
iconSize="l"
/>
</CollapsedBar>
)}
<Bar
className={cx({ open: filtersOpen })}
width={width}
css={
mobileMode &&
css`
position: relative;
width: 100%;
border-right: none;
border-bottom: none;
`
}
>
<Icons.VerticalAlignTopOutlined
iconSize="l"
css={{
transform: 'rotate(90deg)',
marginBottom: `${theme.sizeUnit * 3}px`,
}}
className="collapse-icon"
iconColor={theme.colorPrimary}
{...getFilterBarTestId('expand-button')}
/>
<Icons.FilterOutlined
{...getFilterBarTestId('filter-icon')}
iconColor={theme.colorTextTertiary}
iconSize="l"
/>
</CollapsedBar>
<Bar className={cx({ open: filtersOpen })} width={width}>
<Header toggleFiltersBar={toggleFiltersBar} />
{!mobileMode && <Header toggleFiltersBar={toggleFiltersBar} />}
{!isInitialized ? (
<div
css={{

View File

@@ -469,7 +469,8 @@ const FilterBar: FC<FiltersBarProps> = ({
);
const pendingItems = (
Object.values(pendingChartCustomizations).filter(Boolean) as (
ChartCustomization | ChartCustomizationDivider
| ChartCustomization
| ChartCustomizationDivider
)[]
).filter(item => existingCustomizationIds.has(item.id));
@@ -680,6 +681,7 @@ const FilterBar: FC<FiltersBarProps> = ({
}
toggleFiltersBar={verticalConfig.toggleFiltersBar}
width={verticalConfig.width}
mobileMode={verticalConfig.mobileMode}
clearAllTriggers={clearAllTriggers}
onClearAllComplete={handleClearAllComplete}
/>

View File

@@ -53,6 +53,11 @@ interface VerticalBarConfig {
offset: number;
toggleFiltersBar: any;
width: number;
/**
* Renders the bar for a mobile drawer: full-width, in normal document
* flow, without the header or the collapsed-bar affordance.
*/
mobileMode?: boolean;
}
export interface FiltersBarProps {

View File

@@ -35,6 +35,7 @@ import {
BottomResizeHandle,
BottomRightResizeHandle,
} from './ResizableHandle';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import resizableConfig from '../../util/resizableConfig';
import {
GRID_BASE_UNIT,
@@ -178,6 +179,21 @@ const StyledResizable = styled(Resizable)`
& .resizable-container-handle--bottom {
bottom: 0 !important;
}
/* Mobile consumption mode stacks all grid components full-width.
!important is required to beat re-resizable's inline width, which is
sized for the desktop grid (Markdown, Column, and other non-chart
components don't get the JS-level mobile width that ChartHolder
computes). */
${({ theme }) =>
isMobileConsumptionEnabled() &&
css`
@media (max-width: ${theme.screenSMMax}px) {
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
}
`}
`;
export default function ResizableContainer({

View File

@@ -35,6 +35,7 @@ import { Dashboard } from 'src/views/CRUD/types';
import { assetUrl } from 'src/utils/assetUrl';
import { SubjectPile } from 'src/features/subjects/SubjectPile';
import { KebabMenuButton } from 'src/components';
import { useIsMobile } from 'src/hooks/useIsMobile';
interface DashboardCardProps {
isChart?: boolean;
@@ -63,6 +64,7 @@ function DashboardCard({
handleBulkDashboardExport,
onDelete,
}: DashboardCardProps) {
const isMobile = useIsMobile();
const history = useHistory();
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
@@ -174,10 +176,12 @@ function DashboardCard({
isStarred={favoriteStatus}
/>
)}
<KebabMenuButton
menuItems={menuItems}
dataTest="dashboard-card-menu"
/>
{!isMobile && (
<KebabMenuButton
menuItems={menuItems}
dataTest="dashboard-card-menu"
/>
)}
</ListViewCard.Actions>
}
/>

View File

@@ -59,11 +59,15 @@ jest.mock('@apache-superset/core/theme', () => ({
useTheme: jest.fn(),
}));
// 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: () => ({ md: true }),
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
},
}));

View File

@@ -29,6 +29,7 @@ import { NavLink, useLocation } from 'react-router-dom';
import { Icons } from '@superset-ui/core/components/Icons';
import { Typography } from '@superset-ui/core/components/Typography';
import { useUiConfig } from 'src/components/UiConfigContext';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { URL_PARAMS } from 'src/constants';
import {
MenuObjectChildProps,
@@ -199,6 +200,7 @@ export function Menu({
isFrontendRoute = () => false,
}: MenuProps) {
const screens = useBreakpoint();
const isMobile = useIsMobile();
const uiConfig = useUiConfig();
const theme = useTheme();
@@ -400,7 +402,18 @@ export function Menu({
aria-label={t('Main navigation')}
>
<StyledRow>
<StyledCol md={16} xs={24}>
{/* Mobile: left placeholder for future icon */}
{isMobile && <Col xs={4} />}
<StyledCol
md={16}
xs={isMobile ? 16 : 24}
css={
isMobile &&
css`
justify-content: center;
`
}
>
{!brand.hide_logo && (
<Tooltip
id="brand-tooltip"
@@ -416,40 +429,44 @@ export function Menu({
<span>{brand.text}</span>
</StyledBrandText>
)}
<StyledMainNav
mode={screens.md ? 'horizontal' : 'inline'}
data-test="navbar-top"
className="main-nav"
selectedKeys={activeTabs}
disabledOverflow
items={menu.map(item => {
const props = {
...item,
label: t(item.label),
isFrontendRoute: isFrontendRoute(item.url),
childs: item.childs?.map(c => {
if (typeof c === 'string') {
return c;
}
{/* Consumption mode: hide nav items on mobile (drawer holds them) */}
{!isMobile && (
<StyledMainNav
mode={screens.md ? 'horizontal' : 'inline'}
data-test="navbar-top"
className="main-nav"
selectedKeys={activeTabs}
disabledOverflow
items={menu.map(item => {
const props = {
...item,
label: t(item.label),
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={isMobile ? 4 : 24}>
<RightMenu
align={screens.md ? 'flex-end' : 'flex-start'}
align={screens.md || isMobile ? 'flex-end' : 'flex-start'}
settings={settings}
navbarRight={navbarRight}
isFrontendRoute={isFrontendRoute}
environmentTag={environmentTag}
menu={menu}
/>
</Col>
</StyledRow>

View File

@@ -50,6 +50,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';

View File

@@ -51,9 +51,12 @@ import {
Icons,
Typography,
TelemetryPixel,
Drawer,
Button,
} from '@superset-ui/core/components';
import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu';
import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { findPermission } from 'src/utils/findPermission';
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
@@ -104,12 +107,10 @@ const StyledMenuItem = styled.div<{ disabled?: boolean }>`
color: ${!disabled && theme.colorPrimary};
cursor: ${!disabled ? 'pointer' : 'not-allowed'};
}
${
disabled &&
css`
color: ${theme.colorTextDisabled};
`
}
${disabled &&
css`
color: ${theme.colorTextDisabled};
`}
`}
`;
@@ -119,6 +120,7 @@ const RightMenu = ({
navbarRight,
isFrontendRoute,
environmentTag,
menu,
setQuery,
}: RightMenuProps & {
setQuery: ({
@@ -130,6 +132,8 @@ const RightMenu = ({
}) => void;
}) => {
const theme = useTheme();
const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const user = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -646,6 +650,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)
// Match on the FAB-internal `name`, which is stable across locales
// (`label` is translated and would break in non-English deployments)
const dashboardsMenu = menu?.find(item => 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 && (
@@ -706,48 +768,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 */}
{isMobile && (
<>
<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 */}
{!isMobile && (
<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

View File

@@ -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();
});

View File

@@ -37,6 +37,7 @@ import {
} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { MenuObjectProps } from 'src/types/bootstrapTypes';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import { Typography } from '@superset-ui/core/components/Typography';
const StyledHeader = styled.div<{ backgroundColor?: string }>`
@@ -109,13 +110,44 @@ const StyledHeader = styled.div<{ backgroundColor?: string }>`
.btn-link {
padding: 10px 0;
}
@media (max-width: 767px) {
.header,
.nav-right {
position: relative;
margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
}
}
${({ theme }) =>
isMobileConsumptionEnabled()
? css`
@media (max-width: ${theme.screenSMMax}px) {
.header {
position: relative;
margin-left: 0;
flex: 1;
text-align: center;
}
/* Consumption mode: 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.sizeUnit}px ${theme.sizeUnit * 2}px;
margin-right: ${theme.sizeUnit / 2}px;
font-size: ${theme.fontSizeSM}px;
}
}
}
`
: css`
@media (max-width: ${theme.screenSMMax}px) {
.header,
.nav-right {
position: relative;
margin-left: ${theme.sizeUnit * 2}px;
}
}
`}
`;
const styledDisabled = (theme: SupersetTheme) => css`
@@ -165,6 +197,10 @@ export interface SubMenuProps {
dropDownLinks?: Array<MenuObjectProps>;
backgroundColor?: string;
children?: ReactNode;
/** Left icon for mobile - shown before the header */
leftIcon?: ReactNode;
/** Right icon for mobile - shown after the header */
rightIcon?: ReactNode;
}
const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
@@ -186,8 +222,16 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
function handleResize() {
if (!isMounted) return;
if (window.innerWidth <= 767) setMenu('inline');
else setMenu('horizontal');
// In consumption mode the tabs stay horizontal on mobile (the CSS
// renders them compact); otherwise fall back to the inline layout.
if (
!isMobileConsumptionEnabled() &&
window.innerWidth <= theme.screenSMMax
) {
setMenu('inline');
} else {
setMenu('horizontal');
}
if (
props.buttons &&
@@ -211,12 +255,14 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
resize.cancel();
window.removeEventListener('resize', resize);
};
}, [props.buttons]);
}, [props.buttons, theme.screenSMMax]);
return (
<StyledHeader backgroundColor={props.backgroundColor}>
<Row className="menu" role="navigation" aria-label={t('Page navigation')}>
{props.leftIcon}
{props.name && <div className="header">{props.name}</div>}
{props.rightIcon}
<Menu
mode={showMenu}
disabledOverflow

View File

@@ -48,6 +48,7 @@ export interface RightMenuProps {
color: string;
};
children?: ReactNode;
menu?: MenuObjectProps[];
}
export enum GlobalMenuDataOptions {

View File

@@ -0,0 +1,71 @@
/**
* 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 { useEffect, useState } from 'react';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
// Matches antd's screenSMMax token; used only when no theme is in scope.
const FALLBACK_MOBILE_MAX_WIDTH = 767;
/**
* Whether the mobile consumption-only experience is enabled for this
* deployment. Non-hook variant for use inside styled-component
* interpolations; prefer `useIsMobile` in components.
*/
export function isMobileConsumptionEnabled(): boolean {
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode);
}
/**
* Returns true when MOBILE_CONSUMPTION_MODE is enabled AND the viewport is
* at or below the theme's `screenSMMax` breakpoint. All mobile-specific
* behavior (route guarding, consumption-only chrome, drawer navigation)
* should key off this hook so the flag remains a single kill switch.
*
* The matchMedia subscription is only installed when the flag is on, and
* state only changes when the match flips, so with the flag off (or on
* desktop) this hook never causes a re-render — consumers are inert.
*
* The initial value is always false (desktop), so the first paint never
* takes the mobile branch by accident.
*/
export function useIsMobile(): boolean {
const enabled = isMobileConsumptionEnabled();
const theme = useTheme();
const maxWidth = theme?.screenSMMax ?? FALLBACK_MOBILE_MAX_WIDTH;
const [isSmallScreen, setIsSmallScreen] = useState(false);
useEffect(() => {
if (!enabled) {
return undefined;
}
const mediaQuery = window.matchMedia(`(max-width: ${maxWidth}px)`);
const update = () => setIsSmallScreen(mediaQuery.matches);
update();
// Safari < 14 lacks addEventListener on MediaQueryList
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', update);
return () => mediaQuery.removeEventListener('change', update);
}
mediaQuery.addListener(update);
return () => mediaQuery.removeListener(update);
}, [enabled, maxWidth]);
return enabled && isSmallScreen;
}

View File

@@ -45,6 +45,15 @@ jest.mock('src/utils/export', () => ({
default: 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 mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
typeof isFeatureEnabled
>;

View File

@@ -23,7 +23,7 @@ import {
SupersetClient,
handleKeyboardActivation,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { useSelector } from 'react-redux';
import { useState, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
@@ -39,7 +39,9 @@ import Subject from 'src/types/Subject';
import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel';
import { SubjectPile } from 'src/features/subjects/SubjectPile';
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
import { useIsMobile } from 'src/hooks/useIsMobile';
import {
Button,
CertifiedBadge,
ConfirmStatusChange,
DeleteModal,
@@ -175,6 +177,9 @@ const DASHBOARD_COLUMNS_TO_FETCH = [
function DashboardList(props: DashboardListProps) {
const { addDangerToast, addSuccessToast, user } = props;
const isNotMobile = !useIsMobile();
const theme = useTheme();
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -824,7 +829,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(
@@ -913,8 +936,14 @@ function DashboardList(props: DashboardListProps) {
? 'card'
: 'table'
}
forceViewMode={!isNotMobile ? 'card' : undefined}
enableBulkTag={enableBulkTag}
bulkTagResourceName="dashboard"
mobileFiltersOpen={mobileFiltersOpen}
setMobileFiltersOpen={
!isNotMobile ? setMobileFiltersOpen : undefined
}
mobileFiltersDrawerTitle={t('Search Dashboards')}
/>
</>
);

View File

@@ -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.
*/
/**
* 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';
import { mockMobileMatchMedia } from 'spec/helpers/mobileTestUtils';
// Enable only the mobile consumption mode flag so the mobile branch renders
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(
(flag: string) => flag === 'MOBILE_CONSUMPTION_MODE',
),
}));
// Simulate a mobile viewport for the useIsMobile hook
mockMobileMatchMedia();
// 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();
});

View File

@@ -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) =>

View File

@@ -28,6 +28,7 @@ import { styled } from '@apache-superset/core/theme';
import rison from 'rison';
import { Collapse, ListViewCard } from '@superset-ui/core/components';
import { User } from 'src/types/bootstrapTypes';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { reject } from 'lodash-es';
import {
dangerouslyGetItemDoNotUse,
@@ -147,6 +148,7 @@ export const LoadingCards = ({ cover }: LoadingProps) => (
);
function Welcome({ user, addDangerToast }: WelcomeProps) {
const isNotMobile = !useIsMobile();
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 +399,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',

View File

@@ -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('/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();
});

View File

@@ -0,0 +1,196 @@
/**
* 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/translation';
import { css, useTheme } from '@apache-superset/core/theme';
import { Button, Grid } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
const { useBreakpoint } = Grid;
/** sessionStorage key for the "Continue anyway" mobile bypass */
export const MOBILE_BYPASS_STORAGE_KEY = 'mobile-bypass';
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();
const fromPath = originalPath || location.pathname;
const handleViewDashboards = useCallback(() => {
history.push('/dashboard/list/');
}, [history]);
const handleGoHome = useCallback(() => {
history.push('/welcome/');
}, [history]);
const handleContinueAnyway = useCallback(() => {
// Store preference in sessionStorage so we don't keep redirecting
try {
sessionStorage.setItem(MOBILE_BYPASS_STORAGE_KEY, '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
buttonStyle="link"
onClick={handleContinueAnyway}
css={css`
margin-top: ${theme.sizeUnit * 4}px;
`}
>
{t('Continue anyway')}
<Icons.ArrowRightOutlined iconSize="s" />
</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 buttonStyle="link" onClick={handleContinueAnyway}>
{t('Continue to page')}
</Button>
</p>
)}
</div>
);
}
export default MobileUnsupported;

View File

@@ -29,6 +29,7 @@ import { css, useTheme } from '@apache-superset/core/theme';
import { Flex, 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';
@@ -91,19 +92,29 @@ const RouteSwitch = () => {
const theme = useTheme();
return (
<Switch>
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
<Route path={path} key={path}>
<Suspense fallback={<Fallback />}>
<ErrorBoundary
css={css`
margin: ${theme.sizeUnit * 4}px;
`}
>
<Component user={bootstrapData.user} {...props} />
</ErrorBoundary>
</Suspense>
</Route>
))}
{routes.map(
({
path,
Component,
props = {},
Fallback = Loading,
mobileSupported,
}) => (
<Route path={path} key={path}>
<Suspense fallback={<Fallback />}>
<MobileRouteGuard mobileSupported={mobileSupported}>
<ErrorBoundary
css={css`
margin: ${theme.sizeUnit * 4}px;
`}
>
<Component user={bootstrapData.user} {...props} />
</ErrorBoundary>
</MobileRouteGuard>
</Suspense>
</Route>
),
)}
<Redirect from="/" to="/welcome/" exact />
</Switch>
);

View File

@@ -26,6 +26,7 @@ import {
lruCache,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import Chart from 'src/types/Chart';
import { intersection } from 'lodash-es';
import rison from 'rison';
@@ -293,7 +294,8 @@ const createFetchSubjectRelation =
...result,
data: result.data.flatMap(item => {
const secondaryLabel = item.extra?.secondary_label as
string | undefined;
| string
| undefined;
const type = item.extra?.type as number | undefined;
const value = normalizeSubjectToPickerValue({
value: item.value,
@@ -456,6 +458,17 @@ 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 (consumption mode) */
${
isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
grid-template-columns: 1fr;
padding-left: ${theme.sizeUnit * 4}px;
padding-right: ${theme.sizeUnit * 4}px;
}`
: ''
}
`}
`;

View File

@@ -194,18 +194,36 @@ type Routes = {
Component: ComponentType<any>;
Fallback?: ComponentType<any>;
props?: ComponentProps<any>;
/**
* Marks a route as usable in the mobile consumption-only experience.
* Routes without this flag show the MobileUnsupported screen on small
* screens when MOBILE_CONSUMPTION_MODE is enabled.
*/
mobileSupported?: boolean;
}[];
export const routes: Routes = [
{ path: RoutePaths.REDIRECT, Component: RedirectWarning },
{ path: RoutePaths.LOGIN, Component: Login },
{ path: RoutePaths.REGISTER_ACTIVATION, Component: Register },
{ path: RoutePaths.REGISTER, Component: Register },
{ path: RoutePaths.LOGOUT, Component: Login },
{ path: RoutePaths.HOME, Component: Home },
{
path: RoutePaths.REDIRECT,
Component: RedirectWarning,
mobileSupported: true,
},
{ path: RoutePaths.LOGIN, Component: Login, mobileSupported: true },
{
path: RoutePaths.REGISTER_ACTIVATION,
Component: Register,
mobileSupported: true,
},
{ path: RoutePaths.REGISTER, Component: Register, mobileSupported: true },
{ path: RoutePaths.LOGOUT, Component: Login, mobileSupported: true },
{ path: RoutePaths.HOME, Component: Home, mobileSupported: true },
{ path: RoutePaths.FILE_HANDLER, Component: FileHandler },
{ path: RoutePaths.DASHBOARD_LIST, Component: DashboardList },
{ path: RoutePaths.DASHBOARD, Component: Dashboard },
{
path: RoutePaths.DASHBOARD_LIST,
Component: DashboardList,
mobileSupported: true,
},
{ path: RoutePaths.DASHBOARD, Component: Dashboard, mobileSupported: true },
{ path: RoutePaths.CHART_ADD, Component: ChartCreation },
{ path: RoutePaths.CHART_LIST, Component: ChartList },
{ path: RoutePaths.DATASET_LIST, Component: DatasetList },
@@ -235,7 +253,7 @@ export const routes: Routes = [
{ path: RoutePaths.ROW_LEVEL_SECURITY, Component: RowLevelSecurityList },
{ path: RoutePaths.TASKS, Component: TaskList },
{ path: RoutePaths.SQLLAB, Component: SqlLab },
{ path: RoutePaths.USER_INFO, Component: UserInfo },
{ path: RoutePaths.USER_INFO, Component: UserInfo, mobileSupported: true },
{ path: RoutePaths.ACTION_LOG, Component: ActionLogList },
{ path: RoutePaths.REGISTRATIONS, Component: UserRegistrations },
];

View File

@@ -707,6 +707,11 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# Enable Matrixify feature for matrix-style chart layouts
# @lifecycle: development
"MATRIXIFY": False,
# Serve a consumption-only mobile experience (dashboards, dashboard list,
# and home page) on small screens; other views show a "not supported on
# mobile" screen. Authoring features are hidden on mobile when enabled.
# @lifecycle: development
"MOBILE_CONSUMPTION_MODE": False,
# Try to optimize SQL queries — for now only predicate pushdown is supported
# @lifecycle: development
"OPTIMIZE_SQL": False,

View File

@@ -1061,6 +1061,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
@self.superset_app.context_processor
def get_common_bootstrap_data() -> dict[str, Any]:
# Import here to avoid circular imports
from superset.extensions import feature_flag_manager
from superset.utils import json
from superset.views.base import common_bootstrap_payload
@@ -1070,7 +1071,10 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
default=json.pessimistic_json_iso_dttm_ser,
)
return {"bootstrap_data": serialize_bootstrap_data}
return {
"bootstrap_data": serialize_bootstrap_data,
"is_feature_enabled": feature_flag_manager.is_feature_enabled,
}
def check_and_warn_database_connection(self) -> None:
"""Check database connection and warn if unavailable"""

View File

@@ -31,6 +31,13 @@
</title>
{% block head_meta %}
{% if is_feature_enabled('MOBILE_CONSUMPTION_MODE') %}
{# Without this, mobile browsers lay the page out at the ~980px legacy
viewport and the mobile consumption mode's breakpoints never match.
Only emitted when the flag is on so desktop-only deployments keep
the shrink-to-fit rendering on phones. #}
<meta name="viewport" content="width=device-width, initial-scale=1">
{% endif %}
{# Manifest is served dynamically by PwaManifestView so APPLICATION_ROOT
and STATIC_ASSETS_PREFIX resolve at request time. Do NOT use
`assets_prefix` here — that may point to a CDN host with no

View File

@@ -0,0 +1,88 @@
# 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.
from typing import Any
from unittest.mock import MagicMock, patch
from superset.common.chart_data import ChartDataResultType
from superset.common.query_actions import _get_drill_detail
from superset.common.query_object import QueryObject
from superset.utils.core import QueryObjectFilterClause
@patch("superset.common.query_actions._get_full")
def test_get_drill_detail_does_not_strip_filters(
mock_get_full: MagicMock,
) -> None:
"""
Characterization test for ``_get_drill_detail`` (superset/common/query_actions.py),
the backend transform behind the "Drill to Detail by" samples query.
``_get_drill_detail`` shallow-copies the incoming ``QueryObject`` and rewrites
``is_timeseries``, ``metrics``, ``post_processing``, ``columns`` and ``orderby``;
it never reads or reassigns ``QueryObject.filter``. This test pins that
behavior down: filters present on the ``QueryObject`` handed to
``_get_drill_detail`` are still present, unmodified, on the object it hands off
to ``_get_full``.
This is deliberately narrow and does NOT reproduce or close #28562 ("Drill to
Detail by" ignoring a dashboard's applied filters) -- that report describes
filters missing from the query *before* it reaches this function, which points
at the payload assembled upstream of ``_get_drill_detail`` (dashboard native
filter propagation into the chart's form data / the drill payload built on the
frontend), not at this transform. If that assembly is ever changed to drop
filters before calling into this code path, this test would not catch it; it
only guards against a regression introduced inside ``_get_drill_detail`` itself.
"""
applied_filter: QueryObjectFilterClause = {
"col": "region",
"op": "==",
"val": "USA",
}
query_obj: QueryObject = QueryObject(
columns=["region", "sales"],
metrics=["count"],
filters=[applied_filter],
)
col_region: MagicMock = MagicMock()
col_region.column_name = "region"
col_sales: MagicMock = MagicMock()
col_sales.column_name = "sales"
datasource = MagicMock()
datasource.columns = [col_region, col_sales]
query_context: MagicMock = MagicMock()
query_context.datasource = datasource
query_context.result_type = ChartDataResultType.DRILL_DETAIL
captured: dict[str, QueryObject] = {}
def _capture(_ctx: MagicMock, obj: QueryObject, _force: bool) -> dict[str, Any]:
captured["query_obj"] = obj
return {}
mock_get_full.side_effect = _capture
_get_drill_detail(query_context, query_obj)
executed: QueryObject = captured["query_obj"]
assert applied_filter in executed.filter, (
"_get_drill_detail unexpectedly stripped a filter it never touches; "
"this guards against a regression introduced in that function, not #28562."
)

View File

@@ -66,6 +66,61 @@ def test_csrf_exempt_blueprints_with_api_key(app: Any, app_context: None) -> Non
assert "ApiKeyApi" in {blueprint.name for blueprint in csrf._exempt_blueprints}
def test_security_api_trailing_slash_matches_route_ownership(client: Any) -> None:
"""Regression for #29934: sibling ``/api/v1/security/*`` endpoints respond to
a misspelled (wrong trailing-slash) URL differently, and that difference is
the *intended* behavior — a Werkzeug routing artifact of who owns each route,
not a bug.
Three routes live under the same ``/api/v1/security/`` prefix but are
declared with different slash conventions because they come from different
owners:
* ``login`` -> ``@expose("/login")`` (no trailing slash)
Flask-AppBuilder's own route. Superset does not own or register it, so
it inherits FAB's no-trailing-slash convention. Werkzeug hard-404s a
request that adds a stray trailing slash to a no-slash route (there is
no canonical slashed URL to redirect to).
* ``csrf_token`` -> ``@expose("/csrf_token/")`` (trailing slash)
* ``guest_token`` -> ``@expose("/guest_token/")`` (trailing slash)
Superset's own routes, whose trailing-slash URLs are the documented
canonical URLs (the Embedded SDK depends on them). Werkzeug 308-redirects
a request that omits the trailing slash to the canonical slashed URL.
Unifying the two would either break the documented ``csrf_token`` /
``guest_token`` URLs the Embedded SDK relies on, or require patching FAB /
an app-wide routing change. So the divergence is working-as-designed. This
test pins that intended per-route contract so the behavior stays documented
and any accidental future change is caught.
"""
# Control: the canonical (no trailing slash) login route is registered and
# reachable, so the 404 below is specific to the stray slash rather than
# the route being missing entirely.
response = client.open("/api/v1/security/login", method="POST")
assert response.status_code != 404
# FAB-owned no-trailing-slash route: adding a stray slash hard-404s because
# there is no canonical slashed URL to redirect to.
response = client.open(
"/api/v1/security/login/", method="POST", follow_redirects=False
)
assert response.status_code == 404
# Superset-owned canonical trailing-slash routes: omitting the trailing
# slash 308-redirects to the documented canonical URL.
response = client.open(
"/api/v1/security/csrf_token", method="GET", follow_redirects=False
)
assert response.status_code == 308
assert response.headers["Location"].endswith("/api/v1/security/csrf_token/")
response = client.open(
"/api/v1/security/guest_token", method="POST", follow_redirects=False
)
assert response.status_code == 308
assert response.headers["Location"].endswith("/api/v1/security/guest_token/")
def test_rls_rule_schema_accepts_dataset_scoped_rule() -> None:
"""A rule with an integer ``dataset`` and a ``clause`` loads unchanged."""
result = RlsRuleSchema().load({"dataset": 41, "clause": "tenant_id = 1"})